feat: add app_config table with runtime log level control and POS structured logging #5
@@ -13,8 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
|
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'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface StoreSettings {
|
interface StoreSettings {
|
||||||
@@ -236,6 +237,9 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{/* Modules */}
|
{/* Modules */}
|
||||||
<ModulesCard />
|
<ModulesCard />
|
||||||
|
|
||||||
|
{/* App Configuration */}
|
||||||
|
<AppConfigCard />
|
||||||
</div>
|
</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 }) {
|
function LocationCard({ location }: { location: Location }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
|||||||
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,
|
"when": 1775408000000,
|
||||||
"tag": "0039_cash-rounding",
|
"tag": "0039_cash-rounding",
|
||||||
"breakpoints": true
|
"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(),
|
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 Company = typeof companies.$inferSelect
|
||||||
export type CompanyInsert = typeof companies.$inferInsert
|
export type CompanyInsert = typeof companies.$inferInsert
|
||||||
export type Location = typeof locations.$inferSelect
|
export type Location = typeof locations.$inferSelect
|
||||||
export type LocationInsert = typeof locations.$inferInsert
|
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 { vaultRoutes } from './routes/v1/vault.js'
|
||||||
import { webdavRoutes } from './routes/webdav/index.js'
|
import { webdavRoutes } from './routes/webdav/index.js'
|
||||||
import { moduleRoutes } from './routes/v1/modules.js'
|
import { moduleRoutes } from './routes/v1/modules.js'
|
||||||
|
import { configRoutes } from './routes/v1/config.js'
|
||||||
import { RbacService } from './services/rbac.service.js'
|
import { RbacService } from './services/rbac.service.js'
|
||||||
import { ModuleService } from './services/module.service.js'
|
import { ModuleService } from './services/module.service.js'
|
||||||
|
import { AppConfigService } from './services/config.service.js'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -106,6 +108,7 @@ export async function buildApp() {
|
|||||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||||
await app.register(storeRoutes, { prefix: '/v1' })
|
await app.register(storeRoutes, { prefix: '/v1' })
|
||||||
await app.register(moduleRoutes, { prefix: '/v1' })
|
await app.register(moduleRoutes, { prefix: '/v1' })
|
||||||
|
await app.register(configRoutes, { prefix: '/v1' })
|
||||||
await app.register(lookupRoutes, { prefix: '/v1' })
|
await app.register(lookupRoutes, { prefix: '/v1' })
|
||||||
|
|
||||||
// Module-gated routes
|
// Module-gated routes
|
||||||
@@ -146,6 +149,16 @@ export async function buildApp() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error({ err }, 'Failed to load module cache')
|
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
|
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 } })
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
}
|
}
|
||||||
const discount = await DiscountService.create(app.db, parsed.data)
|
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)
|
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)
|
const discount = await DiscountService.update(app.db, id, parsed.data)
|
||||||
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
|
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)
|
return reply.send(discount)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ export const discountRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const discount = await DiscountService.softDelete(app.db, id)
|
const discount = await DiscountService.softDelete(app.db, id)
|
||||||
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
|
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)
|
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 } })
|
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)
|
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)
|
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 } })
|
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)
|
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)
|
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 } })
|
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)
|
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)
|
return reply.status(201).send(txn)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
await TransactionService.complete(app.db, id, parsed.data)
|
await TransactionService.complete(app.db, id, parsed.data)
|
||||||
const txn = await TransactionService.getById(app.db, id)
|
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)
|
return reply.send(txn)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
await TransactionService.void(app.db, id, request.user.id)
|
await TransactionService.void(app.db, id, request.user.id)
|
||||||
const txn = await TransactionService.getById(app.db, id)
|
const txn = await TransactionService.getById(app.db, id)
|
||||||
|
request.log.info({ transactionId: id, voidedBy: request.user.id }, 'Transaction voided')
|
||||||
return reply.send(txn)
|
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,
|
DrawerOpenInput,
|
||||||
DrawerCloseInput,
|
DrawerCloseInput,
|
||||||
} from './pos.schema.js'
|
} 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