feat: add app_config table with runtime log level control and POS structured logging
- New app_config key-value table for system settings, with in-memory cache (mirrors ModuleService pattern) - GET/PATCH /v1/config endpoints for reading and updating config (settings.view/settings.edit permissions) - Runtime log level: PATCH /v1/config/log_level applies immediately, persists across restarts - Startup loads log level from DB in onReady hook (env var is default, DB overrides) - Add structured request.log.info() to POS routes: transaction create/complete/void, drawer open/close, discount create/update/delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
packages/backend/src/db/migrations/0040_app-config.sql
Normal file
11
packages/backend/src/db/migrations/0040_app-config.sql
Normal 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;
|
||||
@@ -281,6 +281,13 @@
|
||||
"when": 1775408000000,
|
||||
"tag": "0039_cash-rounding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1775494000000,
|
||||
"tag": "0040_app-config",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
48
packages/backend/src/routes/v1/config.ts
Normal file
48
packages/backend/src/routes/v1/config.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
46
packages/backend/src/services/config.service.ts
Normal file
46
packages/backend/src/services/config.service.ts
Normal 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
|
||||
},
|
||||
}
|
||||
9
packages/shared/src/schemas/config.schema.ts
Normal file
9
packages/shared/src/schemas/config.schema.ts
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user