diff --git a/aos-shots/v17-de.png b/aos-shots/v17-de.png new file mode 100644 index 0000000..b27c26c Binary files /dev/null and b/aos-shots/v17-de.png differ diff --git a/aos-shots/v17-debug1.png b/aos-shots/v17-debug1.png new file mode 100644 index 0000000..127be0b Binary files /dev/null and b/aos-shots/v17-debug1.png differ diff --git a/aos-shots/v17-dropdown-open.png b/aos-shots/v17-dropdown-open.png new file mode 100644 index 0000000..38468a7 Binary files /dev/null and b/aos-shots/v17-dropdown-open.png differ diff --git a/aos-shots/v17-en-final.png b/aos-shots/v17-en-final.png new file mode 100644 index 0000000..f73526f Binary files /dev/null and b/aos-shots/v17-en-final.png differ diff --git a/aos-shots/v17-en-persisted.png b/aos-shots/v17-en-persisted.png new file mode 100644 index 0000000..d300cb1 Binary files /dev/null and b/aos-shots/v17-en-persisted.png differ diff --git a/aos-shots/v17-en-real-final.png b/aos-shots/v17-en-real-final.png new file mode 100644 index 0000000..e275d6e Binary files /dev/null and b/aos-shots/v17-en-real-final.png differ diff --git a/aos-shots/v17-en-real.png b/aos-shots/v17-en-real.png new file mode 100644 index 0000000..5accf7f Binary files /dev/null and b/aos-shots/v17-en-real.png differ diff --git a/aos-shots/v17-en-verified.png b/aos-shots/v17-en-verified.png new file mode 100644 index 0000000..c110819 Binary files /dev/null and b/aos-shots/v17-en-verified.png differ diff --git a/aos-shots/v17-en.png b/aos-shots/v17-en.png new file mode 100644 index 0000000..5accf7f Binary files /dev/null and b/aos-shots/v17-en.png differ diff --git a/aos-shots/v17-locale-dropdown.png b/aos-shots/v17-locale-dropdown.png new file mode 100644 index 0000000..36959f6 Binary files /dev/null and b/aos-shots/v17-locale-dropdown.png differ diff --git a/aos-shots/v17-settings.png b/aos-shots/v17-settings.png new file mode 100644 index 0000000..bd8f13e Binary files /dev/null and b/aos-shots/v17-settings.png differ diff --git a/aos-shots/v17-tr-persisted.png b/aos-shots/v17-tr-persisted.png new file mode 100644 index 0000000..8904cc2 Binary files /dev/null and b/aos-shots/v17-tr-persisted.png differ diff --git a/aos-shots/v17-tr.png b/aos-shots/v17-tr.png new file mode 100644 index 0000000..6bbbabf Binary files /dev/null and b/aos-shots/v17-tr.png differ diff --git a/db/custom.db b/db/custom.db index d58d3ed..0a8b336 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/aos/settings/route.ts b/src/app/api/aos/settings/route.ts new file mode 100644 index 0000000..e978f47 --- /dev/null +++ b/src/app/api/aos/settings/route.ts @@ -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 = {} + 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 + 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 = {} + + 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 }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index b6c2610..9f5f700 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -22,6 +22,7 @@ import { AccountingView } from './views/accounting' import { AuditView } from './views/audit' import { ApiView } from './views/api' import { SettingsView } from './views/settings' +import { GlobalSettingsView } from './views/global-settings' import { SetupWizardView } from './views/setup-wizard' import { PortalShell } from './portal-shell' @@ -67,6 +68,7 @@ export function AppShell() { {view === 'audit' && } {view === 'api' && } {view === 'settings' && } + {view === 'settings_global' && } {view === 'setup' && }