From 2614ce4e3de16ded7c95f73816c94aac943cdd43 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 21:29:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Zod=20validation=20on=20all=20API=20inp?= =?UTF-8?q?uts=20(PRD=20=C2=A75.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 26 files with Zod schemas, 27 safeParse calls. All POST/PATCH handlers now validate input: - Missing required fields → 400 with structured error - Invalid types (string vs number) → 400 - Out-of-range values (probability >100, negative amounts) → 400 - Valid data still works (behavior preserved) Pattern: schema.safeParse(await req.json()) → 400 on failure --- .../aos/accounting/bank/[id]/match/route.ts | 11 +++++- .../api/aos/accounting/bank/import/route.ts | 12 +++++- src/app/api/aos/anlagen/route.ts | 20 +++++++--- src/app/api/aos/api-tokens/route.ts | 18 +++++---- src/app/api/aos/belege/route.ts | 22 ++++++++--- .../aos/buchhaltung/jahresabschluss/route.ts | 13 +++++-- src/app/api/aos/buchhaltung/route.ts | 37 +++++++++++++------ src/app/api/aos/contracts/[id]/sign/route.ts | 11 +++++- src/app/api/aos/crm/deals/route.ts | 32 ++++++++-------- src/app/api/aos/crm/import/route.ts | 17 ++++++--- .../api/aos/estimates/[id]/accept/route.ts | 11 +++++- .../api/aos/finance/[id]/finalize/route.ts | 12 +++++- src/app/api/aos/kassenbuch/route.ts | 22 +++++++---- src/app/api/aos/kb/generate/route.ts | 17 ++++++--- src/app/api/aos/leads/[id]/route.ts | 15 +++++--- src/app/api/aos/leads/convert/route.ts | 11 +++++- src/app/api/aos/leads/explain/route.ts | 11 +++++- src/app/api/aos/pauschalen/route.ts | 33 ++++++++++------- .../aos/portal/approvals/[id]/decide/route.ts | 15 ++++++-- src/app/api/aos/projects/[id]/tasks/route.ts | 16 +++++++- src/app/api/aos/projects/[id]/time/route.ts | 16 +++++++- src/app/api/aos/setup/test-mail/route.ts | 19 +++++++--- src/app/api/aos/tasks/[id]/comments/route.ts | 13 ++++++- src/app/api/aos/timesheet/route.ts | 12 +++++- src/app/api/aos/webhooks/route.ts | 16 +++++--- 25 files changed, 316 insertions(+), 116 deletions(-) diff --git a/src/app/api/aos/accounting/bank/[id]/match/route.ts b/src/app/api/aos/accounting/bank/[id]/match/route.ts index e1033ac..7beacff 100755 --- a/src/app/api/aos/accounting/bank/[id]/match/route.ts +++ b/src/app/api/aos/accounting/bank/[id]/match/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const matchTransactionSchema = z.object({ + invoiceId: z.string().min(1), +}) + // POST /api/aos/accounting/bank/[id]/match — match a bank transaction to an invoice. // Body: { invoiceId } — marks the transaction matched + invoice paid. export async function POST( @@ -11,7 +16,11 @@ export async function POST( ) { try { const { id } = await params - const { invoiceId } = (await req.json()) as { invoiceId: string } + const parsed = matchTransactionSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { invoiceId } = parsed.data const tx = await db.bankTransaction.findUnique({ where: { id } }) if (!tx) return NextResponse.json({ error: 'transaction not found' }, { status: 404 }) diff --git a/src/app/api/aos/accounting/bank/import/route.ts b/src/app/api/aos/accounting/bank/import/route.ts index dbb155b..8da94cf 100755 --- a/src/app/api/aos/accounting/bank/import/route.ts +++ b/src/app/api/aos/accounting/bank/import/route.ts @@ -1,10 +1,15 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash, randomBytes } from 'crypto' export const dynamic = 'force-dynamic' export const maxDuration = 30 +const importBankSchema = z.object({ + xml: z.string().min(1), +}) + interface ParsedTx { amountCents: number bookingDate: Date @@ -25,8 +30,11 @@ export async function POST(req: Request) { let xmlString: string if (contentType.includes('application/json')) { - const body = (await req.json()) as { xml?: string } - xmlString = body.xml ?? '' + const parsed = importBankSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + xmlString = parsed.data.xml } else { xmlString = await req.text() } diff --git a/src/app/api/aos/anlagen/route.ts b/src/app/api/aos/anlagen/route.ts index 3f3b849..f95dde7 100644 --- a/src/app/api/aos/anlagen/route.ts +++ b/src/app/api/aos/anlagen/route.ts @@ -1,8 +1,18 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const createAnlageSchema = z.object({ + name: z.string().min(1), + category: z.string().optional().default('hardware'), + purchaseDate: z.string().min(1), + purchaseCostCents: z.number().int().positive(), + usefulLifeYears: z.number().int().min(1).optional(), + afaMethod: z.string().optional(), +}) + // GET /api/aos/anlagen — list asset register with AfA export async function GET() { try { @@ -26,13 +36,11 @@ export async function GET() { // POST /api/aos/anlagen — create an asset with AfA computation export async function POST(req: Request) { try { - const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = (await req.json()) as { - name: string; category: string; purchaseDate: string; purchaseCostCents: number - usefulLifeYears?: number; afaMethod?: string + const parsed = createAnlageSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 }) - if (!purchaseCostCents || purchaseCostCents <= 0) return NextResponse.json({ error: 'purchaseCostCents must be > 0' }, { status: 400 }) + const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = parsed.data const cost = Math.round(purchaseCostCents) const years = usefulLifeYears ?? 3 diff --git a/src/app/api/aos/api-tokens/route.ts b/src/app/api/aos/api-tokens/route.ts index c41dba0..f986252 100755 --- a/src/app/api/aos/api-tokens/route.ts +++ b/src/app/api/aos/api-tokens/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash, randomBytes } from 'crypto' @@ -22,6 +23,12 @@ const ALL_SCOPES = [ 'mcp:write', ] +const createTokenSchema = z.object({ + name: z.string().min(1), + scopes: z.array(z.string()).min(1), + expiresAt: z.string().optional(), +}) + // GET /api/aos/api-tokens — list tokens (without revealing the secret) export async function GET() { const tokens = await db.apiToken.findMany({ orderBy: { createdAt: 'desc' } }) @@ -44,14 +51,11 @@ export async function GET() { // POST /api/aos/api-tokens — create a new token. Returns the plaintext token ONCE. export async function POST(req: Request) { try { - const { name, scopes, expiresAt } = (await req.json()) as { - name: string - scopes: string[] - expiresAt?: string + const parsed = createTokenSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 }) - if (!scopes?.length) return NextResponse.json({ error: 'at least one scope required' }, { status: 400 }) + const { name, scopes, expiresAt } = parsed.data // validate scopes const invalid = scopes.filter((s) => !ALL_SCOPES.includes(s)) diff --git a/src/app/api/aos/belege/route.ts b/src/app/api/aos/belege/route.ts index 93e2218..73febae 100644 --- a/src/app/api/aos/belege/route.ts +++ b/src/app/api/aos/belege/route.ts @@ -1,9 +1,22 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash } from 'crypto' export const dynamic = 'force-dynamic' +const createBelegSchema = z.object({ + fileName: z.string().min(1), + fileType: z.string().optional().default(''), + fileSizeBytes: z.number().int().min(0).optional().default(0), + fileContent: z.string().optional(), + type: z.string().min(1), + vendor: z.string().optional(), + invoiceNumber: z.string().optional(), + amountCents: z.number().int().optional(), + bookingDate: z.string().optional(), +}) + // GET /api/aos/belege — list receipts/documents export async function GET() { try { @@ -23,12 +36,11 @@ export async function GET() { // POST /api/aos/belege — register a receipt (content-addressed) export async function POST(req: Request) { try { - const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = (await req.json()) as { - fileName: string; fileType: string; fileSizeBytes: number; fileContent: string // base64 - type: string; vendor?: string; invoiceNumber?: string; amountCents?: number; bookingDate?: string + const parsed = createBelegSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!fileName?.trim()) return NextResponse.json({ error: 'fileName required' }, { status: 400 }) + const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = parsed.data // Compute SHA-256 hash of file content (content-addressed storage) const fileHash = createHash('sha256').update(fileContent ?? fileName).digest('hex') diff --git a/src/app/api/aos/buchhaltung/jahresabschluss/route.ts b/src/app/api/aos/buchhaltung/jahresabschluss/route.ts index da8ce67..50eabc6 100644 --- a/src/app/api/aos/buchhaltung/jahresabschluss/route.ts +++ b/src/app/api/aos/buchhaltung/jahresabschluss/route.ts @@ -1,17 +1,24 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash, randomBytes } from 'crypto' export const dynamic = 'force-dynamic' +const createJahresabschlussSchema = z.object({ + jahr: z.number().int().min(1900).max(9999), +}) + // POST /api/aos/buchhaltung/jahresabschluss // GoBD Jahresabschluss: creates an immutable archive with SHA-256 hash chain. // Links to previous year's archive (hash chain). export async function POST(req: Request) { try { - const { jahr } = (await req.json()) as { jahr: number } - - if (!jahr) return NextResponse.json({ error: 'jahr required' }, { status: 400 }) + const parsed = createJahresabschlussSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { jahr } = parsed.data // Check if archive already exists for this year const existing = await db.jahresarchiv.findFirst({ where: { jahr } }) diff --git a/src/app/api/aos/buchhaltung/route.ts b/src/app/api/aos/buchhaltung/route.ts index 5018d32..404fc5f 100644 --- a/src/app/api/aos/buchhaltung/route.ts +++ b/src/app/api/aos/buchhaltung/route.ts @@ -1,9 +1,26 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash } from 'crypto' export const dynamic = 'force-dynamic' +const createBuchungSchema = z.object({ + belegdatum: z.string().min(1), + sollKontoNummer: z.string().min(1), + habenKontoNummer: z.string().min(1), + betragCents: z.number().int(), + buchungstext: z.string().min(1), + belegNummer: z.string().optional(), + steuerBp: z.number().int().min(0).optional(), + reverseCharge: z.boolean().optional(), + par19UStG: z.boolean().optional(), +}) + +const festschreibenSchema = z.object({ + buchungId: z.string().min(1), +}) + // GET /api/aos/buchhaltung — SKR04 Konten + Journal + Saldenliste + EÜR + Jahresabschluss export async function GET() { try { @@ -69,17 +86,11 @@ export async function GET() { // POST /api/aos/buchhaltung/buchung — create a Buchungssatz (Soll an Haben) export async function POST(req: Request) { try { - const body = (await req.json()) as { - belegdatum: string - sollKontoNummer: string - habenKontoNummer: string - betragCents: number - buchungstext: string - belegNummer?: string - steuerBp?: number - reverseCharge?: boolean - par19UStG?: boolean + const parsed = createBuchungSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const body = parsed.data const sollKonto = await db.konto.findUnique({ where: { nummer: body.sollKontoNummer } }) const habenKonto = await db.konto.findUnique({ where: { nummer: body.habenKontoNummer } }) @@ -120,7 +131,11 @@ export async function POST(req: Request) { // PATCH — Festschreibung (GoBD: make immutable) export async function PATCH(req: Request) { try { - const { buchungId } = (await req.json()) as { buchungId: string } + const parsed = festschreibenSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { buchungId } = parsed.data const buchung = await db.buchungssatz.findUnique({ where: { id: buchungId } }) if (!buchung) return NextResponse.json({ error: 'not found' }, { status: 404 }) if (buchung.festgeschrieben) return NextResponse.json({ error: 'already festgeschrieben' }, { status: 409 }) diff --git a/src/app/api/aos/contracts/[id]/sign/route.ts b/src/app/api/aos/contracts/[id]/sign/route.ts index bd6903e..e1f2f25 100755 --- a/src/app/api/aos/contracts/[id]/sign/route.ts +++ b/src/app/api/aos/contracts/[id]/sign/route.ts @@ -1,9 +1,14 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { randomBytes, createHash } from 'crypto' export const dynamic = 'force-dynamic' +const signContractSchema = z.object({ + signedBy: z.string().optional(), +}) + // Extract the real client IP from the request (behind Caddy/proxy). function getClientIp(req: Request): string { const headers = new Headers(req.headers) @@ -31,7 +36,11 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const body = (await req.json().catch(() => ({}))) as { signedBy?: string } + const parsed = signContractSchema.safeParse(await req.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const body = parsed.data const contract = await db.contract.findUnique({ where: { id } }) if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 }) diff --git a/src/app/api/aos/crm/deals/route.ts b/src/app/api/aos/crm/deals/route.ts index a29fecb..b78f569 100755 --- a/src/app/api/aos/crm/deals/route.ts +++ b/src/app/api/aos/crm/deals/route.ts @@ -1,34 +1,34 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const createDealSchema = z.object({ + companyId: z.string().min(1), + title: z.string().min(1).max(200), + valueCents: z.number().int().min(0), + stage: z.string().optional().default('lead'), + probability: z.number().int().min(0).max(100).optional().default(10), + expectedClose: z.string().nullable().optional(), +}) + // POST /api/aos/crm/deals — create a new deal in the pipeline export async function POST(req: Request) { try { - const body = (await req.json()) as { - companyId: string - title: string - valueCents: number - stage?: string - probability?: number - expectedClose?: string | null - } - - if (!body.companyId) { - return NextResponse.json({ error: 'companyId required' }, { status: 400 }) - } - if (!body.title?.trim()) { - return NextResponse.json({ error: 'title required' }, { status: 400 }) + const parsed = createDealSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const body = parsed.data const deal = await db.deal.create({ data: { companyId: body.companyId, title: body.title.trim(), valueCents: Math.max(0, Math.round(body.valueCents)), - stage: body.stage ?? 'lead', - probability: body.probability ?? 10, + stage: body.stage, + probability: body.probability, expectedClose: body.expectedClose ? new Date(body.expectedClose) : null, }, }) diff --git a/src/app/api/aos/crm/import/route.ts b/src/app/api/aos/crm/import/route.ts index 3b8a19b..592028d 100644 --- a/src/app/api/aos/crm/import/route.ts +++ b/src/app/api/aos/crm/import/route.ts @@ -1,9 +1,15 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' export const maxDuration = 30 +const importSchema = z.object({ + csv: z.string().min(1), + mapping: z.record(z.string(), z.number()), +}) + // POST /api/aos/crm/import // Import companies + persons from a CSV string. // Body: { csv: string, mapping: { name: number, vatId: number, ... } } @@ -13,13 +19,14 @@ export const maxDuration = 30 // Duplicate detection: skips companies with the same name or website. export async function POST(req: Request) { try { - const { csv, mapping } = (await req.json()) as { - csv: string - mapping: Record + const parsed = importSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const { csv, mapping } = parsed.data - if (!csv?.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 }) - if (!mapping?.name && mapping.name !== 0) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 }) + if (!csv.trim()) return NextResponse.json({ error: 'csv data required' }, { status: 400 }) + if (mapping.name === undefined) return NextResponse.json({ error: 'mapping.name required' }, { status: 400 }) // Parse CSV (simple RFC 4180 parser — handles quoted fields with commas) const lines = parseCSV(csv) diff --git a/src/app/api/aos/estimates/[id]/accept/route.ts b/src/app/api/aos/estimates/[id]/accept/route.ts index c657fae..d4108e3 100755 --- a/src/app/api/aos/estimates/[id]/accept/route.ts +++ b/src/app/api/aos/estimates/[id]/accept/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const acceptEstimateSchema = z.object({ + acceptedBy: z.string().optional(), +}) + // PATCH /api/aos/estimates/[id]/accept — customer accepts the quote. // Domain event: creates a Project (from the quote) + updates Deal stage → won. // (PRD §5.4 events, §6.5 "accepted quote seeds project budget") @@ -11,7 +16,11 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const body = (await req.json().catch(() => ({}))) as { acceptedBy?: string } + const parsed = acceptEstimateSchema.safeParse(await req.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const body = parsed.data const estimate = await db.estimate.findUnique({ where: { id }, include: { lines: true } }) if (!estimate) return NextResponse.json({ error: 'not found' }, { status: 404 }) diff --git a/src/app/api/aos/finance/[id]/finalize/route.ts b/src/app/api/aos/finance/[id]/finalize/route.ts index 2141223..6a754e9 100755 --- a/src/app/api/aos/finance/[id]/finalize/route.ts +++ b/src/app/api/aos/finance/[id]/finalize/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const finalizeInvoiceSchema = z.object({ + zugferd: z.boolean().optional(), + xrechnung: z.boolean().optional(), +}) + // PATCH /api/aos/finance/[id]/finalize — finalize a draft invoice: // assigns gapless number if needed, marks ZUGFeRD/XRechnung, sets status=sent, locks. export async function PATCH( @@ -10,7 +16,11 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const body = (await req.json().catch(() => ({}))) as { zugferd?: boolean; xrechnung?: boolean } + const parsed = finalizeInvoiceSchema.safeParse(await req.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const body = parsed.data const inv = await db.invoice.findUnique({ where: { id } }) if (!inv) return NextResponse.json({ error: 'not found' }, { status: 404 }) diff --git a/src/app/api/aos/kassenbuch/route.ts b/src/app/api/aos/kassenbuch/route.ts index 6ae6eea..651cbec 100644 --- a/src/app/api/aos/kassenbuch/route.ts +++ b/src/app/api/aos/kassenbuch/route.ts @@ -1,8 +1,19 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const createKassenbuchSchema = z.object({ + date: z.string().min(1), + type: z.enum(['einnahme', 'ausgabe']), + description: z.string().min(1), + amountCents: z.number().int().positive(), + taxBp: z.number().int().min(0).optional(), + taxFree: z.boolean().optional(), + belegId: z.string().optional(), +}) + // GET /api/aos/kassenbuch — list cash book entries (GoBD: unveränderlich) export async function GET() { try { @@ -30,14 +41,11 @@ export async function GET() { // POST /api/aos/kassenbuch — add a cash book entry (§ 146 AO, § 22 UStG) export async function POST(req: Request) { try { - const { date, type, description, amountCents, taxBp, taxFree, belegId } = (await req.json()) as { - date: string; type: 'einnahme' | 'ausgabe'; description: string; amountCents: number - taxBp?: number; taxFree?: boolean; belegId?: string + const parsed = createKassenbuchSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!description?.trim()) return NextResponse.json({ error: 'description required' }, { status: 400 }) - if (!amountCents || amountCents <= 0) return NextResponse.json({ error: 'amountCents must be > 0' }, { status: 400 }) - if (!['einnahme', 'ausgabe'].includes(type)) return NextResponse.json({ error: 'type must be einnahme|ausgabe' }, { status: 400 }) + const { date, type, description, amountCents, taxBp, taxFree, belegId } = parsed.data // Compute running balance const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } }) diff --git a/src/app/api/aos/kb/generate/route.ts b/src/app/api/aos/kb/generate/route.ts index f07106a..ed31d32 100755 --- a/src/app/api/aos/kb/generate/route.ts +++ b/src/app/api/aos/kb/generate/route.ts @@ -1,21 +1,26 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import ZAI from 'z-ai-web-dev-sdk' export const dynamic = 'force-dynamic' export const maxDuration = 60 +const generateKbSchema = z.object({ + topic: z.string().min(1), + category: z.string().optional(), + audience: z.enum(['internal', 'client', 'public']).optional(), +}) + // POST /api/aos/kb/generate // AI-drafts a knowledge-base article from a brief/topic. // Structured output (Zod-validated), markdown content. export async function POST(req: Request) { - const { topic, category, audience } = (await req.json()) as { - topic: string - category?: string - audience?: 'internal' | 'client' | 'public' + const parsed = generateKbSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!topic?.trim()) return NextResponse.json({ error: 'topic required' }, { status: 400 }) + const { topic, category, audience } = parsed.data const sys = `Du bist ein technischer Redakteur einer deutschen Software-Agentur (Nordlicht Digital). Schreibe einen Wissensdatenbank-Artikel in Markdown. diff --git a/src/app/api/aos/leads/[id]/route.ts b/src/app/api/aos/leads/[id]/route.ts index f6c6f37..ece6590 100755 --- a/src/app/api/aos/leads/[id]/route.ts +++ b/src/app/api/aos/leads/[id]/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const updateLeadSchema = z.object({ + status: z.enum(['pending', 'approved', 'rejected', 'snoozed', 'converted']), + reviewReason: z.string().optional(), +}) + // PATCH /api/aos/leads/[id] — human review: approve / reject / snooze / convert export async function PATCH( req: Request, @@ -10,12 +16,11 @@ export async function PATCH( ) { try { const { id } = await params - const body = await req.json() - const { status, reviewReason } = body as { status: string; reviewReason?: string } - - if (!['pending', 'approved', 'rejected', 'snoozed', 'converted'].includes(status)) { - return NextResponse.json({ error: 'invalid status' }, { status: 400 }) + const parsed = updateLeadSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const { status, reviewReason } = parsed.data const lead = await db.lead.update({ where: { id }, diff --git a/src/app/api/aos/leads/convert/route.ts b/src/app/api/aos/leads/convert/route.ts index 408081e..00c3faf 100755 --- a/src/app/api/aos/leads/convert/route.ts +++ b/src/app/api/aos/leads/convert/route.ts @@ -1,14 +1,23 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const convertLeadSchema = z.object({ + leadId: z.string().min(1), +}) + // POST /api/aos/leads/convert // Convert an approved lead into a CRM Company + Deal. // This is the "lead → deal" domain-event flow from the PRD (§5.4 events, §6.2 hand-off). export async function POST(req: Request) { try { - const { leadId } = (await req.json()) as { leadId: string } + const parsed = convertLeadSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { leadId } = parsed.data const lead = await db.lead.findUnique({ where: { id: leadId } }) if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 }) diff --git a/src/app/api/aos/leads/explain/route.ts b/src/app/api/aos/leads/explain/route.ts index 203d96b..d795f50 100755 --- a/src/app/api/aos/leads/explain/route.ts +++ b/src/app/api/aos/leads/explain/route.ts @@ -1,15 +1,24 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import ZAI from 'z-ai-web-dev-sdk' export const dynamic = 'force-dynamic' export const maxDuration = 60 +const explainLeadSchema = z.object({ + leadId: z.string().min(1), +}) + // POST /api/aos/leads/explain // Generates an evidence-grounded "why this lead" draft via LLM. // Every claim in the output must reference an evidence id. export async function POST(req: Request) { - const { leadId } = (await req.json()) as { leadId: string } + const parsed = explainLeadSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { leadId } = parsed.data const lead = await db.lead.findUnique({ where: { id: leadId } }) if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 }) diff --git a/src/app/api/aos/pauschalen/route.ts b/src/app/api/aos/pauschalen/route.ts index 5ff5f61..34e8943 100644 --- a/src/app/api/aos/pauschalen/route.ts +++ b/src/app/api/aos/pauschalen/route.ts @@ -1,10 +1,25 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' const CENTS = (e: number) => Math.round(e * 100) +const createPauschaleSchema = z.object({ + type: z.enum(['homeoffice', 'kilometer', 'verpflegung']), + date: z.string().min(1), + userId: z.string().optional(), + days: z.number().int().min(0).optional(), + km: z.number().min(0).optional(), + vehicleType: z.string().optional(), + tripPurpose: z.string().optional(), + hoursAway: z.number().min(0).optional(), + overnight: z.boolean().optional(), + description: z.string().optional(), + projectId: z.string().optional(), +}) + // GET /api/aos/pauschalen — list flat-rate allowances export async function GET() { try { @@ -26,21 +41,11 @@ export async function GET() { // POST /api/aos/pauschalen — create a pauschale (auto-computes amount) export async function POST(req: Request) { try { - const body = (await req.json()) as { - type: 'homeoffice' | 'kilometer' | 'verpflegung' - date: string - userId?: string - days?: number - km?: number - vehicleType?: string - tripPurpose?: string - hoursAway?: number - overnight?: boolean - description?: string - projectId?: string + const parsed = createPauschaleSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } - - if (!body.type || !body.date) return NextResponse.json({ error: 'type + date required' }, { status: 400 }) + const body = parsed.data // Auto-compute amount based on German tax law (2026 rates) const rates = getRates() diff --git a/src/app/api/aos/portal/approvals/[id]/decide/route.ts b/src/app/api/aos/portal/approvals/[id]/decide/route.ts index f75d63c..e2e8037 100755 --- a/src/app/api/aos/portal/approvals/[id]/decide/route.ts +++ b/src/app/api/aos/portal/approvals/[id]/decide/route.ts @@ -1,9 +1,16 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { createHash } from 'crypto' export const dynamic = 'force-dynamic' +const decideApprovalSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'changes-requested']), + decidedBy: z.string().optional(), + feedback: z.string().optional(), +}) + // Extract the real client IP from the request (behind Caddy/proxy) function getClientIp(req: Request): string { const headers = new Headers(req.headers) @@ -23,11 +30,11 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const body = (await req.json()) as { decision: string; decidedBy?: string; feedback?: string } - - if (!['approved', 'rejected', 'changes-requested'].includes(body.decision)) { - return NextResponse.json({ error: 'invalid decision' }, { status: 400 }) + const parsed = decideApprovalSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const body = parsed.data const approval = await db.portalApproval.findUnique({ where: { id } }) if (!approval) return NextResponse.json({ error: 'not found' }, { status: 404 }) diff --git a/src/app/api/aos/projects/[id]/tasks/route.ts b/src/app/api/aos/projects/[id]/tasks/route.ts index 0de9901..e4023ac 100755 --- a/src/app/api/aos/projects/[id]/tasks/route.ts +++ b/src/app/api/aos/projects/[id]/tasks/route.ts @@ -1,8 +1,16 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const createTaskSchema = z.object({ + title: z.string().min(1), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional().default('medium'), + assignee: z.string().optional(), + estimateMin: z.number().int().min(0).optional(), +}) + // POST /api/aos/projects/[id]/tasks — add a task to a project export async function POST( req: Request, @@ -10,13 +18,17 @@ export async function POST( ) { try { const { id } = await params - const body = (await req.json()) as { title: string; priority?: string; assignee?: string; estimateMin?: number } + const parsed = createTaskSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const body = parsed.data const task = await db.task.create({ data: { projectId: id, title: body.title, - priority: body.priority ?? 'medium', + priority: body.priority, assignee: body.assignee ?? null, estimateMin: body.estimateMin ?? null, status: 'todo', diff --git a/src/app/api/aos/projects/[id]/time/route.ts b/src/app/api/aos/projects/[id]/time/route.ts index d077993..47cf70e 100755 --- a/src/app/api/aos/projects/[id]/time/route.ts +++ b/src/app/api/aos/projects/[id]/time/route.ts @@ -1,22 +1,34 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const logTimeSchema = z.object({ + description: z.string().min(1), + minutes: z.number().int().positive(), + billable: z.boolean().optional().default(true), + rateCentsPerHour: z.number().int().min(0).optional(), +}) + // POST /api/aos/projects/[id]/time — log a time entry on a project export async function POST( req: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const body = (await req.json()) as { description: string; minutes: number; billable?: boolean; rateCentsPerHour?: number } + const parsed = logTimeSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const body = parsed.data const entry = await db.timeEntry.create({ data: { projectId: id, description: body.description, minutes: Math.max(1, Math.round(body.minutes)), - billable: body.billable ?? true, + billable: body.billable, rateCentsPerHour: body.rateCentsPerHour ?? null, date: new Date(), }, diff --git a/src/app/api/aos/setup/test-mail/route.ts b/src/app/api/aos/setup/test-mail/route.ts index 8712bed..8ca01fb 100755 --- a/src/app/api/aos/setup/test-mail/route.ts +++ b/src/app/api/aos/setup/test-mail/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import * as net from 'net' export const dynamic = 'force-dynamic' @@ -13,6 +14,14 @@ interface MailTestResult { error?: string } +const testMailSchema = z.object({ + smtpHost: z.string().optional(), + smtpPort: z.number().int().min(0).max(65535).optional(), + imapHost: z.string().optional(), + imapPort: z.number().int().min(0).max(65535).optional(), + user: z.string().optional(), +}) + // Real TCP connection test to an SMTP/IMAP server. // Attempts to open a socket, read the greeting banner, then close. // No fake success — returns the actual connection result or error. @@ -76,13 +85,11 @@ function testConnection(host: string, port: number, timeoutMs = 8000): Promise ({}))) as { - smtpHost?: string - smtpPort?: number - imapHost?: string - imapPort?: number - user?: string + const parsed = testMailSchema.safeParse(await req.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } + const body = parsed.data const [smtp, imap] = await Promise.all([ testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0), diff --git a/src/app/api/aos/tasks/[id]/comments/route.ts b/src/app/api/aos/tasks/[id]/comments/route.ts index 95d8167..8ee42de 100755 --- a/src/app/api/aos/tasks/[id]/comments/route.ts +++ b/src/app/api/aos/tasks/[id]/comments/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const createCommentSchema = z.object({ + body: z.string().min(1), + author: z.string().optional(), +}) + // GET /api/aos/tasks/[id]/comments — list comments on a task export async function GET( _req: Request, @@ -23,8 +29,11 @@ export async function POST( ) { try { const { id } = await params - const { body, author } = (await req.json()) as { body: string; author?: string } - if (!body?.trim()) return NextResponse.json({ error: 'body required' }, { status: 400 }) + const parsed = createCommentSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { body, author } = parsed.data const comment = await db.taskComment.create({ data: { taskId: id, body: body.trim(), author: author ?? 'Max Brandt' }, diff --git a/src/app/api/aos/timesheet/route.ts b/src/app/api/aos/timesheet/route.ts index 92f683f..1376ba1 100755 --- a/src/app/api/aos/timesheet/route.ts +++ b/src/app/api/aos/timesheet/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' export const dynamic = 'force-dynamic' +const lockWeekSchema = z.object({ + week: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'week required (YYYY-MM-DD)'), +}) + // GET /api/aos/timesheet?week=YYYY-MM-DD // Returns a weekly timesheet grid: projects × days (Mon-Sun) // with time entries per cell + totals + lock status. @@ -111,8 +116,11 @@ export async function GET(req: Request) { // PATCH /api/aos/timesheet — lock all entries in a week (PM approval) export async function PATCH(req: Request) { try { - const { week } = (await req.json()) as { week?: string } - if (!week) return NextResponse.json({ error: 'week required (YYYY-MM-DD)' }, { status: 400 }) + const parsed = lockWeekSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { week } = parsed.data const refDate = new Date(week + 'T00:00:00Z') if (isNaN(refDate.getTime())) return NextResponse.json({ error: 'invalid date' }, { status: 400 }) diff --git a/src/app/api/aos/webhooks/route.ts b/src/app/api/aos/webhooks/route.ts index fd98636..1e2242b 100755 --- a/src/app/api/aos/webhooks/route.ts +++ b/src/app/api/aos/webhooks/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { db } from '@/lib/db' import { randomBytes } from 'crypto' @@ -14,6 +15,11 @@ const KNOWN_EVENTS = [ 'test.event', ] +const createWebhookSchema = z.object({ + url: z.string().min(1).startsWith('https://'), + events: z.array(z.string()).min(1), +}) + // GET /api/aos/webhooks — list registered webhooks export async function GET() { const webhooks = await db.webhook.findMany({ orderBy: { createdAt: 'desc' } }) @@ -37,11 +43,11 @@ export async function GET() { // POST /api/aos/webhooks — register a webhook (generates a real HMAC secret) export async function POST(req: Request) { try { - const { url, events } = (await req.json()) as { url: string; events: string[] } - - if (!url?.trim()) return NextResponse.json({ error: 'url required' }, { status: 400 }) - if (!url.startsWith('https://')) return NextResponse.json({ error: 'url must be https' }, { status: 400 }) - if (!events?.length) return NextResponse.json({ error: 'at least one event required' }, { status: 400 }) + const parsed = createWebhookSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { url, events } = parsed.data const invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*') if (invalid.length) return NextResponse.json({ error: `invalid events: ${invalid.join(', ')}`, valid: KNOWN_EVENTS }, { status: 400 })