f30e7158-97a8-4eaa-b0e8-a28e8a54a3fe

This commit is contained in:
Z User committed 2026-10-26 23:08:38 +00:00
1 parent 9feb124da6
commit e16d7a748a
24 files changed
+641 -12

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
// Typed setting schema — validation per key (PRD §5.5: "Typed, database-backed")
const SETTING_SCHEMA = z.record(z.string(), z.string())
// GET /api/aos/settings — all settings as a typed object
export async function GET() {
const rows = await db.setting.findMany()
const settings: Record<string, string> = {}
for (const r of rows) settings[r.key] = r.value
return NextResponse.json({ settings })
}
// PATCH /api/aos/settings — update multiple settings at once.
// Each key is validated against an allow-list to prevent arbitrary writes.
const ALLOWED_KEYS = new Set([
'company.name', 'company.legalForm', 'company.vatId', 'company.registerCourt', 'company.registerNumber',
'company.city', 'company.zip', 'company.street', 'company.bank', 'company.bic',
'invoice.numberPrefix', 'invoice.numberYear', 'invoice.numberNext',
'tax.standardBp', 'tax.reducedBp', 'tax.reverseChargeDefault',
'leadFinder.radiusKm', 'leadFinder.costCapEur', 'leadFinder.autoApproveThreshold',
'erechnung.format', 'erechnung.leitwegId',
'compliance.retentionInvoicesYears', 'compliance.retentionBusinessYears',
'compliance.encryptionKeyEscrow',
'locale.default', 'locale.available',
'mail.smtpHost', 'mail.smtpPort', 'mail.imapHost', 'mail.imapPort',
])
// Numeric keys that must parse as numbers
const NUMERIC_KEYS = new Set([
'invoice.numberNext', 'tax.standardBp', 'tax.reducedBp',
'leadFinder.radiusKm', 'leadFinder.costCapEur', 'leadFinder.autoApproveThreshold',
'mail.smtpPort', 'mail.imapPort',
'compliance.retentionInvoicesYears', 'compliance.retentionBusinessYears',
])
export async function PATCH(req: Request) {
const body = (await req.json()) as Record<string, string>
const parsed = SETTING_SCHEMA.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'invalid payload', details: parsed.error.flatten() }, { status: 400 })
}
const updates = parsed.data
const errors: string[] = []
const before: Record<string, string> = {}
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED_KEYS.has(key)) {
errors.push(`unknown key: ${key}`)
continue
}
if (NUMERIC_KEYS.has(key)) {
if (isNaN(Number(value))) {
errors.push(`${key} must be numeric, got "${value}"`)
continue
}
}
const existing = await db.setting.findUnique({ where: { key } })
if (existing) before[key] = existing.value
await db.setting.upsert({
where: { key },
update: { value },
create: { key, value },
})
}
if (errors.length > 0) {
return NextResponse.json({ error: 'validation_failed', details: errors }, { status: 422 })
}
await db.auditLog.create({
data: {
actor: 'lena@nordlicht.digital',
action: 'settings.update',
entity: 'Setting',
entityId: 'bulk',
before: JSON.stringify(before),
after: JSON.stringify(updates),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, updated: Object.keys(updates).length })
}