From 772d5578ada3f0e418f9105ce6c779aeeff81a6a Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 18:56:21 +0000 Subject: [PATCH] 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) --- .../src/db/migrations/0040_app-config.sql | 11 +++++ .../src/db/migrations/meta/_journal.json | 7 +++ packages/backend/src/db/schema/stores.ts | 9 ++++ packages/backend/src/main.ts | 13 +++++ packages/backend/src/routes/v1/config.ts | 48 +++++++++++++++++++ packages/backend/src/routes/v1/discounts.ts | 3 ++ packages/backend/src/routes/v1/drawer.ts | 2 + .../backend/src/routes/v1/transactions.ts | 3 ++ .../backend/src/services/config.service.ts | 46 ++++++++++++++++++ packages/shared/src/schemas/config.schema.ts | 9 ++++ packages/shared/src/schemas/index.ts | 3 ++ 11 files changed, 154 insertions(+) create mode 100644 packages/backend/src/db/migrations/0040_app-config.sql create mode 100644 packages/backend/src/routes/v1/config.ts create mode 100644 packages/backend/src/services/config.service.ts create mode 100644 packages/shared/src/schemas/config.schema.ts diff --git a/packages/backend/src/db/migrations/0040_app-config.sql b/packages/backend/src/db/migrations/0040_app-config.sql new file mode 100644 index 0000000..9bacab6 --- /dev/null +++ b/packages/backend/src/db/migrations/0040_app-config.sql @@ -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; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index b61cc4d..047c876 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1775408000000, "tag": "0039_cash-rounding", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1775494000000, + "tag": "0040_app-config", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/stores.ts b/packages/backend/src/db/schema/stores.ts index a35cef1..aef6e0f 100644 --- a/packages/backend/src/db/schema/stores.ts +++ b/packages/backend/src/db/schema/stores.ts @@ -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 diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 90a4f3f..6483550 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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 diff --git a/packages/backend/src/routes/v1/config.ts b/packages/backend/src/routes/v1/config.ts new file mode 100644 index 0000000..4f75fd8 --- /dev/null +++ b/packages/backend/src/routes/v1/config.ts @@ -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) + }) +} diff --git a/packages/backend/src/routes/v1/discounts.ts b/packages/backend/src/routes/v1/discounts.ts index 4f9d849..8574de9 100644 --- a/packages/backend/src/routes/v1/discounts.ts +++ b/packages/backend/src/routes/v1/discounts.ts @@ -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) }) } diff --git a/packages/backend/src/routes/v1/drawer.ts b/packages/backend/src/routes/v1/drawer.ts index 2d87183..569e9c3 100644 --- a/packages/backend/src/routes/v1/drawer.ts +++ b/packages/backend/src/routes/v1/drawer.ts @@ -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) }) diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index c12192d..4b94d1f 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -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) }) } diff --git a/packages/backend/src/services/config.service.ts b/packages/backend/src/services/config.service.ts new file mode 100644 index 0000000..f99199d --- /dev/null +++ b/packages/backend/src/services/config.service.ts @@ -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 | null = null + +export const AppConfigService = { + async getAll(db: PostgresJsDatabase) { + return db.select().from(appConfig) + }, + + async get(db: PostgresJsDatabase, key: string): Promise { + if (!configCache) await this.refreshCache(db) + return configCache!.get(key) ?? null + }, + + async set(db: PostgresJsDatabase, 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) { + 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 + }, +} diff --git a/packages/shared/src/schemas/config.schema.ts b/packages/shared/src/schemas/config.schema.ts new file mode 100644 index 0000000..bdd9aa8 --- /dev/null +++ b/packages/shared/src/schemas/config.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const LogLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']) +export type LogLevel = z.infer + +export const AppConfigUpdateSchema = z.object({ + value: z.union([z.string(), z.number(), z.boolean(), z.null()]), +}) +export type AppConfigUpdateInput = z.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 34b9572..7416413 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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'