Merge pull request 'feat: add app_config table with runtime log level control and POS structured logging' (#5) from feature/structured-logging into main
All checks were successful
Build & Release / build (push) Successful in 58s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-04-04 19:08:48 +00:00
12 changed files with 235 additions and 1 deletions

View File

@@ -13,8 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock } from 'lucide-react'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2 } from 'lucide-react'
import { toast } from 'sonner'
interface StoreSettings {
@@ -236,6 +237,9 @@ function SettingsPage() {
{/* Modules */}
<ModulesCard />
{/* App Configuration */}
<AppConfigCard />
</div>
)
}
@@ -296,6 +300,82 @@ function ModulesCard() {
)
}
interface AppConfigEntry {
key: string
value: string | null
description: string | null
updatedAt: string
}
const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const
function configOptions() {
return queryOptions({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
})
}
function AppConfigCard() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const canEdit = hasPermission('settings.edit')
const { data: configData, isLoading } = useQuery(configOptions())
const configs = configData?.data ?? []
const logLevel = configs.find((c) => c.key === 'log_level')?.value ?? 'info'
const updateMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
api.patch<AppConfigEntry>(`/v1/config/${key}`, { value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['config'] })
toast.success('Configuration updated')
},
onError: (err) => toast.error(err.message),
})
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Settings2 className="h-5 w-5" />App Configuration
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-12 w-full" />
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-md border">
<div className="min-w-0">
<span className="font-medium text-sm">Log Level</span>
<p className="text-xs text-muted-foreground mt-0.5">Controls the verbosity of application logs</p>
</div>
<Select
value={logLevel}
onValueChange={(value) => updateMutation.mutate({ key: 'log_level', value })}
disabled={!canEdit || updateMutation.isPending}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
)
}
function LocationCard({ location }: { location: Location }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS "app_config" (
"key" varchar(100) PRIMARY KEY NOT NULL,
"value" text,
"description" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
-- Seed default log level
INSERT INTO "app_config" ("key", "value", "description")
VALUES ('log_level', 'info', 'Application log level (fatal, error, warn, info, debug, trace)')
ON CONFLICT ("key") DO NOTHING;

View File

@@ -281,6 +281,13 @@
"when": 1775408000000,
"tag": "0039_cash-rounding",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1775494000000,
"tag": "0040_app-config",
"breakpoints": true
}
]
}

View File

@@ -38,7 +38,16 @@ export const locations = pgTable('location', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const appConfig = pgTable('app_config', {
key: varchar('key', { length: 100 }).primaryKey(),
value: text('value'),
description: text('description'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type Company = typeof companies.$inferSelect
export type CompanyInsert = typeof companies.$inferInsert
export type Location = typeof locations.$inferSelect
export type LocationInsert = typeof locations.$inferInsert
export type AppConfig = typeof appConfig.$inferSelect
export type AppConfigInsert = typeof appConfig.$inferInsert

View File

@@ -30,8 +30,10 @@ import { storeRoutes } from './routes/v1/store.js'
import { vaultRoutes } from './routes/v1/vault.js'
import { webdavRoutes } from './routes/webdav/index.js'
import { moduleRoutes } from './routes/v1/modules.js'
import { configRoutes } from './routes/v1/config.js'
import { RbacService } from './services/rbac.service.js'
import { ModuleService } from './services/module.service.js'
import { AppConfigService } from './services/config.service.js'
export async function buildApp() {
const app = Fastify({
@@ -106,6 +108,7 @@ export async function buildApp() {
await app.register(rbacRoutes, { prefix: '/v1' })
await app.register(storeRoutes, { prefix: '/v1' })
await app.register(moduleRoutes, { prefix: '/v1' })
await app.register(configRoutes, { prefix: '/v1' })
await app.register(lookupRoutes, { prefix: '/v1' })
// Module-gated routes
@@ -146,6 +149,16 @@ export async function buildApp() {
} catch (err) {
app.log.error({ err }, 'Failed to load module cache')
}
try {
await AppConfigService.refreshCache(app.db)
const dbLogLevel = await AppConfigService.get(app.db, 'log_level')
if (dbLogLevel) {
app.log.level = dbLogLevel
app.log.info({ level: dbLogLevel }, 'Log level loaded from config')
}
} catch (err) {
app.log.error({ err }, 'Failed to load app config')
}
})
return app

View File

@@ -0,0 +1,48 @@
import type { FastifyPluginAsync } from 'fastify'
import { AppConfigService } from '../../services/config.service.js'
import { AppConfigUpdateSchema, LogLevel } from '@lunarfront/shared/schemas'
export const configRoutes: FastifyPluginAsync = async (app) => {
app.get('/config', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (_request, reply) => {
const configs = await AppConfigService.getAll(app.db)
return reply.send({ data: configs })
})
app.get('/config/:key', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => {
const { key } = request.params as { key: string }
const configs = await AppConfigService.getAll(app.db)
const entry = configs.find((c) => c.key === key)
if (!entry) return reply.status(404).send({ error: { message: 'Config key not found', statusCode: 404 } })
return reply.send(entry)
})
app.patch('/config/:key', { preHandler: [app.authenticate, app.requirePermission('settings.edit')] }, async (request, reply) => {
const { key } = request.params as { key: string }
const parsed = AppConfigUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const value = parsed.data.value === null ? null : String(parsed.data.value)
// Key-specific validation
if (key === 'log_level') {
const levelResult = LogLevel.safeParse(value)
if (!levelResult.success) {
return reply.status(400).send({
error: { message: 'Invalid log level. Must be one of: fatal, error, warn, info, debug, trace', statusCode: 400 },
})
}
}
const updated = await AppConfigService.set(app.db, key, value)
// Apply log level change immediately
if (key === 'log_level' && value) {
app.log.level = value
request.log.info({ level: value, changedBy: request.user.id }, 'Log level changed')
}
return reply.send(updated)
})
}

View File

@@ -9,6 +9,7 @@ export const discountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const discount = await DiscountService.create(app.db, parsed.data)
request.log.info({ discountId: discount.id, name: parsed.data.name, userId: request.user.id }, 'Discount created')
return reply.status(201).send(discount)
})
@@ -39,6 +40,7 @@ export const discountRoutes: FastifyPluginAsync = async (app) => {
}
const discount = await DiscountService.update(app.db, id, parsed.data)
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
request.log.info({ discountId: id, userId: request.user.id }, 'Discount updated')
return reply.send(discount)
})
@@ -46,6 +48,7 @@ export const discountRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params as { id: string }
const discount = await DiscountService.softDelete(app.db, id)
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
request.log.info({ discountId: id, userId: request.user.id }, 'Discount deactivated')
return reply.send(discount)
})
}

View File

@@ -9,6 +9,7 @@ export const drawerRoutes: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await DrawerService.open(app.db, parsed.data, request.user.id)
request.log.info({ drawerSessionId: session.id, locationId: parsed.data.locationId, openingBalance: parsed.data.openingBalance, userId: request.user.id }, 'Drawer opened')
return reply.status(201).send(session)
})
@@ -19,6 +20,7 @@ export const drawerRoutes: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await DrawerService.close(app.db, id, parsed.data, request.user.id)
request.log.info({ drawerSessionId: id, closingBalance: parsed.data.closingBalance, expectedBalance: session.expectedBalance, overShort: session.overShort, closedBy: request.user.id }, 'Drawer closed')
return reply.send(session)
})

View File

@@ -15,6 +15,7 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const txn = await TransactionService.create(app.db, parsed.data, request.user.id)
request.log.info({ transactionId: txn.id, type: parsed.data.transactionType, userId: request.user.id }, 'Transaction created')
return reply.status(201).send(txn)
})
@@ -80,6 +81,7 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
}
await TransactionService.complete(app.db, id, parsed.data)
const txn = await TransactionService.getById(app.db, id)
request.log.info({ transactionId: id, paymentMethod: parsed.data.paymentMethod, userId: request.user.id }, 'Transaction completed')
return reply.send(txn)
})
@@ -87,6 +89,7 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params as { id: string }
await TransactionService.void(app.db, id, request.user.id)
const txn = await TransactionService.getById(app.db, id)
request.log.info({ transactionId: id, voidedBy: request.user.id }, 'Transaction voided')
return reply.send(txn)
})
}

View File

@@ -0,0 +1,46 @@
import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { appConfig } from '../db/schema/stores.js'
let configCache: Map<string, string | null> | null = null
export const AppConfigService = {
async getAll(db: PostgresJsDatabase<any>) {
return db.select().from(appConfig)
},
async get(db: PostgresJsDatabase<any>, key: string): Promise<string | null> {
if (!configCache) await this.refreshCache(db)
return configCache!.get(key) ?? null
},
async set(db: PostgresJsDatabase<any>, key: string, value: string | null, description?: string) {
const [existing] = await db.select().from(appConfig).where(eq(appConfig.key, key)).limit(1)
if (existing) {
const [updated] = await db
.update(appConfig)
.set({ value, updatedAt: new Date(), ...(description !== undefined ? { description } : {}) })
.where(eq(appConfig.key, key))
.returning()
configCache = null
return updated
}
const [inserted] = await db
.insert(appConfig)
.values({ key, value, description, updatedAt: new Date() })
.returning()
configCache = null
return inserted
},
async refreshCache(db: PostgresJsDatabase<any>) {
const rows = await db.select({ key: appConfig.key, value: appConfig.value }).from(appConfig)
configCache = new Map(rows.map((r) => [r.key, r.value]))
},
invalidateCache() {
configCache = null
},
}

View File

@@ -0,0 +1,9 @@
import { z } from 'zod'
export const LogLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace'])
export type LogLevel = z.infer<typeof LogLevel>
export const AppConfigUpdateSchema = z.object({
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
})
export type AppConfigUpdateInput = z.infer<typeof AppConfigUpdateSchema>

View File

@@ -191,3 +191,6 @@ export type {
DrawerOpenInput,
DrawerCloseInput,
} from './pos.schema.js'
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
export type { AppConfigUpdateInput } from './config.schema.js'