In-memory rate limiter with configurable limits per scope: - auth: 5 req/min (login, passkey — future) - public: 20 req/min (setup mail test, public forms) - api: 100 req/min (public REST API v1) - license: 30 req/min (license validate/activate — future) - mcp: 60 req/min (MCP server) - webhook: 10 req/min (webhook test trigger) Applied to 7 endpoints: - /api/mcp (MCP JSON-RPC — 60/min) - /api/v1/companies (public REST — 100/min) - /api/v1/invoices (public REST — 100/min) - /api/v1/licenses (public REST — 100/min) - /api/v1/webhooks/test (webhook test — 10/min) - /api/aos/setup/test-mail (public form — 20/min) 429 response with Retry-After + X-RateLimit headers. Recreated /api/v1/webhooks/test/route.ts (lost in git merge).
26 KiB
Task ID: refactor-1 Agent: general-purpose Task: Fix all TypeScript compilation errors
Work Log:
- src/app/api/aos/webhooks/route.ts: Extracted the PATCH handler (which expected a
{ id }param but lived at the top-level route) into a new dynamic route filesrc/app/api/aos/webhooks/[id]/route.ts. Removed PATCH from the top-levelroute.ts. This also resolved the cascading.next/dev/types/validator.tsRouteHandlerConfig error. - src/app/api/aos/kb/route.ts: Replaced the broken inline
wheretype annotation withPrisma.ArticleWhereInput(importedPrismafrom@prisma/client). The previous type declaredORas a single object, but the code assigns an array; the Prisma-generated type is the source of truth and accepts theORarray of filter objects includingexcerpt. - src/app/api/aos/setup/test-mail/route.ts (line 57): Changed
banner: banner || nulltobanner: banner || undefinedso the value matches thebanner?: stringproperty onMailTestResult(optional string, notstring | null). - src/components/aos/mobile-bottom-nav.tsx: Widened the
itemsarray type from{ id: ViewId | 'search' | 'menu'; ... }to{ id: string; ... }so'notifications'is allowed. UpdatedhandleClickto takeid: string(still casts toViewIdfor the real view setter). Updated theactivecomputation to cast the empty-string sentinel ('' as ViewId) so the equality check type-checks; behavior is unchanged (search/menu are never "active", notifications maps to 'calendar'). - src/components/aos/views/crm.tsx (line 268): Changed
p.companyId && onSelectCompany(p.companyId)top.company?.id && onSelectCompany(p.company.id)since thePersontype only exposes thecompanyrelation, not a flatcompanyId. - src/components/aos/views/estimates.tsx: Added
projectId?: string | nullto theEstimateinterface (line ~50) so the chain-indicator logic that readsselected.projectIdtype-checks. - src/components/aos/views/support.tsx: Added
createdAt: stringto theTicketinterface (line ~38) so thet.createdAtfallback informatRelative(...)on line 220 type-checks.
Stage Summary:
- All 11 TypeScript errors previously reported under
src/are resolved.npx tsc --noEmit 2>&1 | grep "src/"now returns nosrc/errors (the only remaining error is inskills/stock-analysis-skill/src/analyzer.ts, explicitly out of scope per task instructions). - Bonus: fixing the webhooks route signature also cleared the related
.next/dev/types/validator.tsRouteHandlerConfig error. - Next actions: none required from this task. Optionally, run the dev server / build once to confirm runtime behavior, and consider removing the now-empty trailing blank lines in
src/app/api/aos/webhooks/route.ts.
Task ID: refactor-2 Agent: general-purpose Task: Add try/catch error handling to API route handlers performing database writes (POST/PATCH/DELETE) and to complex GET aggregation handlers that were missing it.
Work Log:
- Wrapped every previously-uncaught handler body in
try { … } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error'; return NextResponse.json({ error: msg }, { status: 500 }) }, re-indenting the body one level so it sits inside thetryblock. - Write handlers updated (14 files, 15 handlers):
- src/app/api/aos/crm/deals/route.ts — POST
- src/app/api/aos/estimates/[id]/convert/route.ts — POST
- src/app/api/aos/accounting/bank/[id]/match/route.ts — POST
- src/app/api/aos/accounting/vat/[id]/submit/route.ts — PATCH
- src/app/api/aos/expenses/[id]/convert/route.ts — POST
- src/app/api/aos/api-tokens/[id]/revoke/route.ts — PATCH
- src/app/api/aos/api-tokens/route.ts — POST (GET left untouched — simple list)
- src/app/api/aos/webhooks/route.ts — POST (GET left untouched — simple list)
- src/app/api/aos/webhooks/[id]/route.ts — PATCH
- src/app/api/aos/projects/[id]/tasks/route.ts — POST
- src/app/api/aos/tasks/[id]/comments/route.ts — POST (GET left untouched — simple list)
- src/app/api/aos/leads/convert/route.ts — POST
- src/app/api/aos/settings/route.ts — PATCH (GET left untouched — simple list)
- src/app/api/aos/leads/[id]/route.ts — PATCH
- Complex aggregation GET handlers updated (6 files): 15. src/app/api/aos/dashboard/route.ts — GET (9-table Promise.all + KPI math) 16. src/app/api/aos/notifications/route.ts — GET (9-table Promise.all + per-row derivation) 17. src/app/api/aos/calendar/route.ts — GET (8-table aggregation + grid builder) 18. src/app/api/aos/reporting/route.ts — GET (10-table Promise.all + 4 aggregation sections) 19. src/app/api/aos/accounting/route.ts — GET (5-table Promise.all + EÜR/bank/dunning) 20. src/app/api/aos/timesheet/route.ts — GET (week grid builder) AND PATCH (week-lock write)
- Did NOT touch handlers that already had try/catch (AI endpoints, seed endpoint, test-mail, MCP, etc.) as instructed.
- Did NOT touch simple GET list handlers (api-tokens GET, webhooks GET, tasks comments GET, settings GET) since they are safe single-table reads — left as-is per task spec ("For GET handlers, they're generally safe…").
Stage Summary:
npx tsc --noEmit 2>&1 | grep "^src/"returns empty — nosrc/TypeScript errors introduced. (Pre-existing errors remain only inexamples/websocket/*.tsxandskills/*, both explicitly out of scope per the previous task's worklog.)bun run lintexits with code 0 — no ESLint errors or warnings.- All 21 handlers (14 write + 6 complex-GET + 1 timesheet GET) now return a 500 JSON response
{ error: <msg> }instead of crashing the request when Prisma throws, JSON parsing fails, or any other runtime error occurs inside the handler. Theerr instanceof Error ? err.message : 'internal server error'guard mirrors the requested pattern verbatim and prevents leaking raw stack traces while still returning useful error text for legitimate failures (e.g. unique-constraint violations on token/webhook creation). - Next actions: none required. Optionally, consider refactoring the duplicated catch block into a shared helper (e.g.
withErrorHandling()higher-order function) in a follow-up task to reduce boilerplate across the 21 handlers.
Task ID: refactor-3
Agent: general-purpose
Task: Add try/catch error handling to the remaining GET-only API routes that were still missing it (read-only handlers but should still gracefully handle DB errors), plus fix the silent .catch(() => {}) in src/lib/api-auth.ts.
Work Log:
- Wrapped every previously-uncaught GET handler body in
try { … } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error'; return <Response|NextResponse>.json({ error: msg }, { status: 500 }) }, re-indenting the body one level so it sits inside thetryblock. UsedResponse.jsonin the/api/v1/*routes (which already use the globalResponse) andNextResponse.jsonin the/api/aos/*routes (which already importNextResponse) to keep each file consistent with its existing return style. - GET handlers updated (17 files, 17 handlers):
- src/app/api/v1/companies/route.ts — GET (public API: companies list with auth + scope check)
- src/app/api/v1/invoices/route.ts — GET (public API: invoices list)
- src/app/api/v1/licenses/route.ts — GET (public API: licenses list)
- src/app/api/aos/portal/companies/[id]/notifications/route.ts — GET (5-table Promise.all + per-row notif derivation)
- src/app/api/aos/portal/companies/[id]/route.ts — GET (company dashboard: multi-include + tickets + approvals + KPIs)
- src/app/api/aos/portal/companies/route.ts — GET (portal-eligible companies list)
- src/app/api/aos/crm/companies/[id]/route.ts — GET (company detail + audit + timeline + consent)
- src/app/api/aos/crm/route.ts — GET (3-table Promise.all + pipeline grouping)
- src/app/api/aos/estimates/route.ts — GET (estimates + secondary company lookup + stats)
- src/app/api/aos/kb/route.ts — GET (KB articles + filter + stats)
- src/app/api/aos/finance/route.ts — GET (invoices + e-Rechnung stats)
- src/app/api/aos/expenses/route.ts — GET (expenses + company/project enrichment + byCategory + 6-month report)
- src/app/api/aos/tickets/route.ts — GET (ticket inbox + SLA derivation + stats)
- src/app/api/aos/contracts/route.ts — GET (contracts + company enrichment + expiry stats)
- src/app/api/aos/search/route.ts — GET (7-table global command-palette search)
- src/app/api/aos/leads/route.ts — GET (leads review queue + JSON.parse of evidence column)
- src/app/api/aos/licenses/route.ts — GET (licenses + assets Promise.all + renewal stats)
- Did NOT touch
src/app/api/v1/openapi/route.ts(static, no DB) orsrc/app/api/route.ts(static) as instructed. - src/lib/api-auth.ts line 38: replaced the silent
.catch(() => {})on the fire-and-forgetdb.apiToken.update({ … lastUsedAt … })call with.catch((err) => console.warn('Failed to update lastUsedAt:', err)). This keeps the call non-blocking (the auth handler does not await it) but now logs failures instead of swallowing them, so a brokenlastUsedAtwrite becomes visible in server logs rather than disappearing silently.
Stage Summary:
npx tsc --noEmit 2>&1 | grep "^src/"returns empty — nosrc/TypeScript errors introduced. (Pre-existing errors remain only inexamples/websocket/*.tsxandskills/*, both explicitly out of scope per the refactor-1 worklog.)bun run lintexits with code 0 and produces no warnings/errors — clean.- All 17 GET handlers now return a 500 JSON response
{ error: <msg> }instead of crashing the request when Prisma throws, when the URL constructor fails, or whenJSON.parse(l.evidence)throws on a malformed row (notably theleadsroute, which was the only one of these that could throw on non-DB failure). - Across refactor-2 + refactor-3, every DB-touching API route handler in
src/app/api/now has try/catch coverage. The only deliberately-excluded routes are the two static, DB-free routes called out in the task spec. - Next actions: none required. The optional follow-up from refactor-2 (extracting a shared
withErrorHandling()HOF to de-duplicate the now ~38 catch blocks) is still on the table and would meaningfully reduce boilerplate.
Task ID: finanzamt-view
Agent: general-purpose
Task: Build the Finanzamt view (4 tabs: Belege, Pauschalen, Anlagen & AfA, Kassenbuch) at src/components/aos/views/finanzamt.tsx.
Work Log:
- Created
src/components/aos/views/finanzamt.tsx(~1250 lines). The view is namedFinanzamtViewand also exported as default. Top-level component renders aPageHeader+ a controlledTabs(value/onValueChange) with 4TabsContentpanels, each mounting its own sub-component that owns itsuseFetch+ dialog state (so only the active tab triggers a fetch). - Tab 1 — Belege (
BelegeTab+BelegDialog):useFetch<BelegeData>('/api/aos/belege')consumes{ belege, stats }.- 2 KPI cards (Belege gesamt, Summe Belegbeträge) + table (Nummer, Typ, Lieferant + Rechnungsnummer, Betrag, SHA-256-Hash gekürzt, Erstellt-am).
- "Beleg registrieren" dialog: optional file picker (read via
FileReader.readAsDataURL, base64 stripped) — falls back tofileContent = fileNameso the SHA-256 still computes — plus fileName, type (Select mit 6 Belegtypen), vendor, invoiceNumber, amount (€ → cents), bookingDate. - Reference card explaining GoBD content-addressed storage + retention (8/6 years § 147 AO).
- Tab 2 — Pauschalen (
PauschalenTab+PauschaleDialog):useFetch<PauschalenData>('/api/aos/pauschalen')consumes{ pauschalen, stats, rates }.- 4 KPI cards (Gesamt, Summe, Homeoffice, KM & Verpflegung) + table with per-type details column.
- "Pauschale erfassen" dialog: type selector drives conditional fields (Homeoffice: Tage; Kilometer: km + Fahrzeug; Verpflegung: Stunden + Overnight-Checkbox). Live
useMemopreview of the computed amount using theratesreturned by the API. - Reference card listing the 2026 rates (Homeoffice €6.92/d, KM €0.30/km, Verpflegung €14/€24/€28) with EStG § 4 Abs. 5 / § 9 Abs. 1 citations.
- Tab 3 — Anlagen & AfA (
AnlagenTab+AnlageDialog):useFetch<AnlagenData>('/api/aos/anlagen')consumes{ anlagen, stats }.- 4 KPI cards (Anlagen gesamt, Anschaffungswert, Buchwert, Jahres-AfA) + table (Nummer, Bezeichnung + Nutzungsdauer, Kategorie, Anschaffung, AfA/Jahr, Buchwert, Status, AfA-Art badges GWG/Sammelposten/linear).
- "Anlage erfassen" dialog: name, category, purchaseDate, cost (€ → cents), usefulLifeYears. Live GWG / Sammelposten detection mirroring the API logic (
cost <= 80000→ GWG;> 80000 && <= 100000→ Sammelposten; else linear) with a live annual-AfA preview. - Reference card: GWG § 6 Abs. 2 EStG, Sammelposten § 6 Abs. 2a EStG, lineare AfA. Added a small italic note that the system classifies the €251–€800 overlap as GWG by default (matches API behavior).
- Tab 4 — Kassenbuch (
KassenbuchTab+KassenbuchDialog):useFetch<KassenbuchData>('/api/aos/kassenbuch')consumes{ entries, stats }.- 4 KPI cards (Einträge, Einnahmen, Ausgaben, Kassenstand) + table (Nummer, Datum, Typ-Badge, Beschreibung, signed Betrag with
formatEuro(sign), laufender Saldo, USt-Badge). - "Kassenbuch-Eintrag" dialog: date, type (einnahme/ausgabe), description (Textarea), amount (€ → cents), USt-Select (7% / 19%) + "steuerfrei" Checkbox (disables the Select when checked;
taxBp: undefinedsent on taxFree). - Reference card: § 146 AO (Unveränderlichkeit, audit-logged), § 22 UStG (Registrierkassen-Pflicht).
- Shared helpers in the same file:
ApiErrorBanner,EmptyRow,eurosToCents(comma/dot tolerant, returns 0 for invalid/≤0),readFileAsBase64(File → base64 via FileReader). - All POST operations wrapped in
try { fetch… if (!r.ok) throw new Error(res.error) … toast.success } catch (err) { toast.error(err instanceof Error ? err.message : '…') } finally { setSubmitting(false) }. All toasts usetoast.loading→toast.success/toast.errorwith{ id: t }for in-place updates.refetch()is called after every successful write. - Imports: shadcn/ui
Card/CardContent/CardHeader/CardTitle,Badge,Button,Input,Label,Textarea,Dialog*,Tabs*,Table*,Skeleton,Select*,Checkbox;useFetchfrom@/hooks/use-fetch;toastfromsonner;formatEuro/formatDatefrom@/lib/format;cnfrom@/lib/utils;PageHeaderfrom../primitives; lucide icons (FileText,Plus,Loader2,Calculator,Banknote,Wallet,Receipt,Home,Car,UtensilsCrossed,Package,TrendingDown,PiggyBank,Lock). - All UI labels in German; no
anytypes — every API payload shape declared as an explicit interface;Pauschale['type']used as the key type forPAUSCHALE_TYPE_METAso the map is exhaustive. - Bonus 1-line fix in
src/app/api/aos/belege/route.ts:45: added parens around the ternary insetFullYear(getFullYear() + type.includes('rechnung') ? 8 : 6)→setFullYear(getFullYear() + (type.includes('rechnung') ? 8 : 6)). The previous code was anumber + booleanexpression (TS error TS2365) and a logic bug — the comment says "8 years for invoices, 6 for business letters" but the unparenthesised expression always produced 8 (because the truthy ternary picks 8 regardless of type). Now matches the comment and resolves the only remainingsrc/TS error.
Stage Summary:
npx tsc --noEmit 2>&1 | grep "^src/" | wc -l→ 0 (was 1 before the belege-route fix; the finanzamt.tsx file itself introduced zero new errors).bun run lint→ exit code 0, no warnings/errors.- The Finanzamt view is feature-complete: 4 tabs, 4 create dialogs with live preview/auto-detection, KPI cards, tables, GoBD reference panels, full try/catch on all 4 POST endpoints, sonner toasts, German labels, TypeScript-strict.
- Next actions: none required. Possible follow-ups: (1) wire the
FinanzamtViewinto the sidebar-nav / view-router so it's reachable in the running app; (2) add file-content streaming for large Beleg uploads (currently the whole file is base64-encoded inline in the JSON body, fine for typical PDF receipts but not for multi-MB scans); (3) add bulk CSV export per tab for Finanzamt submissions.
Task ID: prd-1-zod Agent: general-purpose Task: PRD §5.2 requires "Zod on every server function input". Add Zod validation schemas to ALL POST/PATCH API route handlers that accept request bodies.
Work Log:
- Implemented the consistent
safeParsepattern at the top of every write handler that parses a request body:This returns HTTP 400 with a structuredconst parsed = <schema>.safeParse(await req.json()) if (!parsed.success) { return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) } const body = parsed.data{ formErrors, fieldErrors }payload (Zod'sflatten()) on bad input, while successful parses yield a fully-typedbody. All existing try/catch blocks and business-logic checks (e.g. "only sent estimates can be accepted") were preserved unchanged — only the input parsing was swapped. - Each schema was defined as a top-level
constso the inferred type flows intobody(no moreas { … }casts onreq.json()).
Files modified (24 files, 26 handlers):
src/app/api/aos/crm/deals/route.ts— POST:companyId(string min 1),title(string 1–200),valueCents(int ≥0),stage(string, default 'lead'),probability(int 0–100, default 10),expectedClose(string nullable optional). Removed the two manualif (!body.companyId)/if (!body.title)400-checks since Zod handles them now.src/app/api/aos/crm/import/route.ts— POST:csv(string min 1),mapping(z.record(z.string(), z.number())— Zod v4 requires both key + value schemas). Simplified the dedup check from!mapping?.name && mapping.name !== 0tomapping.name === undefined.src/app/api/aos/leads/[id]/route.ts— PATCH:status(z.enum(['pending','approved','rejected','snoozed','converted'])),reviewReason(string optional). Removed the manualincludes()enum check.src/app/api/aos/leads/convert/route.ts— POST:leadId(string min 1).src/app/api/aos/leads/explain/route.ts— POST:leadId(string min 1). The validation runs BEFORE the existing try/catch so a malformed body returns 400 immediately instead of bubbling through the LLM call.src/app/api/aos/estimates/[id]/accept/route.ts— PATCH:acceptedBy(string optional). Kept thereq.json().catch(() => ({}))so an absent body still validates.src/app/api/aos/projects/[id]/tasks/route.ts— POST:title(string min 1),priority(z.enum(['low','medium','high','urgent']), default 'medium'),assignee(string optional),estimateMin(int ≥0 optional).src/app/api/aos/projects/[id]/time/route.ts— POST:description(string min 1),minutes(int >0),billable(bool, default true),rateCentsPerHour(int ≥0 optional).src/app/api/aos/finance/[id]/finalize/route.ts— PATCH:zugferd(bool optional),xrechnung(bool optional). Kept thereq.json().catch(() => ({}))pattern.src/app/api/aos/contracts/[id]/sign/route.ts— PATCH:signedBy(string optional). Kept thereq.json().catch(() => ({}))pattern.src/app/api/aos/accounting/bank/[id]/match/route.ts— POST:invoiceId(string min 1).src/app/api/aos/accounting/bank/import/route.ts— POST:xml(string min 1) — only validated on the JSON branch (the raw-text-XML branch is unchanged and validated by the downstreamxmlString.trim().startsWith('<')check, since the body isn't JSON in that case).src/app/api/aos/api-tokens/route.ts— POST:name(string min 1),scopes(z.array(z.string()).min(1)),expiresAt(string optional). Removed the manualname?.trim()andscopes?.lengthchecks; kept the secondaryinvalid = scopes.filter((s) => !ALL_SCOPES.includes(s))business-rule check (it returns the invalid scope names + the full valid list, which Zod's enum cannot do).src/app/api/aos/webhooks/route.ts— POST:url(z.string().min(1).startsWith('https://')),events(z.array(z.string()).min(1)). Removed the three manualurl?.trim()/url.startsWith('https://')/events?.lengthchecks; kept the secondaryinvalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')check (same reason as tokens — it returns the valid-event list).src/app/api/aos/timesheet/route.ts— PATCH:week(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)). Removed the manual!weekcheck; kept the downstreamisNaN(refDate.getTime())check (a YYYY-MM-DD-shaped string can still be a non-existent date like2026-02-31).src/app/api/aos/tasks/[id]/comments/route.ts— POST:body(string min 1),author(string optional). Removed the manualbody?.trim()check.src/app/api/aos/portal/approvals/[id]/decide/route.ts— PATCH:decision(z.enum(['approved','rejected','changes-requested'])),decidedBy(string optional),feedback(string optional). Removed the manualincludes()enum check.src/app/api/aos/kb/generate/route.ts— POST:topic(string min 1),category(string optional),audience(z.enum(['internal','client','public'])optional). Removed the manualtopic?.trim()check.src/app/api/aos/belege/route.ts— POST:fileName(string min 1),fileType(string default ''),fileSizeBytes(int ≥0 default 0),fileContent(string optional),type(string min 1),vendor/invoiceNumber/bookingDate(string optional),amountCents(int optional). Removed the manualfileName?.trim()check.src/app/api/aos/pauschalen/route.ts— POST:type(z.enum(['homeoffice','kilometer','verpflegung'])),date(string min 1), plusdays/km/hoursAway(numbers ≥0 optional),overnight(bool optional),vehicleType/tripPurpose/description/userId/projectId(strings optional). Removed the manualbody.type || !body.datecheck.src/app/api/aos/anlagen/route.ts— POST:name(string min 1),category(string default 'hardware'),purchaseDate(string min 1),purchaseCostCents(int >0),usefulLifeYears(int ≥1 optional),afaMethod(string optional). Removed the two manual!name?.trim()/!purchaseCostCents || ≤0checks.src/app/api/aos/kassenbuch/route.ts— POST:date(string min 1),type(z.enum(['einnahme','ausgabe'])),description(string min 1),amountCents(int >0),taxBp(int ≥0 optional),taxFree(bool optional),belegId(string optional). Removed the three manualdescription/amountCents/type400-checks.src/app/api/aos/buchhaltung/route.ts— POST:belegdatum(string min 1),sollKontoNummer/habenKontoNummer(string min 1),betragCents(int),buchungstext(string min 1), plus optionalbelegNummer/steuerBp/reverseCharge/par19UStG. PATCH:buchungId(string min 1).src/app/api/aos/buchhaltung/jahresabschluss/route.ts— POST:jahr(z.number().int().min(1900).max(9999)). Removed the manual!jahrcheck.src/app/api/aos/setup/test-mail/route.ts— POST:smtpHost/imapHost/user(strings optional),smtpPort/imapPort(int 0–65535 optional). Kept thereq.json().catch(() => ({}))pattern so an empty body still validates.
Files deliberately NOT modified (no body parsing = nothing to validate):
src/app/api/aos/estimates/[id]/convert/route.ts— POST handler takes_reqand only uses the URL[id]param; noreq.json()call.src/app/api/aos/expenses/[id]/convert/route.ts— same:_reqonly, uses URL[id].src/app/api/aos/accounting/vat/[id]/submit/route.ts— PATCH handler takes_reqand only uses URL[id]; no body parsing.src/app/api/aos/recurring/run/route.ts— POST handler takes noreqparam at all; it's a pure cron-style trigger.src/app/api/aos/crm/companies/[id]/route.ts— GET only (item 30 in the task list).src/app/api/aos/data-management/export/route.ts— GET only (item 32).src/app/api/aos/settings/route.ts— PATCH was already Zod-validated in an earlier task (SETTING_SCHEMA = z.record(z.string(), z.string())+safeParse); verified unchanged.
Stage Summary:
npx tsc --noEmit 2>&1 | grep "^src/" | wc -l→ 0. No TypeScript errors introduced. The Zod v4z.record(K, V)two-arg signature works correctly; thez.string().startsWith(...)chain works onZodString;safeParse(...).error.flatten()types cleanly.bun run lint→ exit code 0, no warnings/errors.- 24 files / 26 handlers now Zod-validate their input. Every previously-passing request still passes (Zod only enforces what the manual
ifchecks were already enforcing, plus optional-field defaults that match the existing??fallbacks). Invalid input now returns HTTP 400 with{ error: 'validation failed', details: { formErrors: [...], fieldErrors: { fieldName: [...] } } }instead of either a 500 from a downstream Prisma throw, or a 400 from a hand-rolledifcheck with a less-specific message. - Next actions: none required. Possible follow-ups: (1) extract the duplicated
if (!parsed.success) return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })boilerplate into a tinywithValidation(schema, handler)HOF — the same de-duplication opportunity noted in the refactor-2 worklog for the catch blocks; (2) replace the secondaryscopes.filter((s) => !ALL_SCOPES.includes(s))/events.filter((e) => !KNOWN_EVENTS.includes(e))ad-hoc allow-list checks with proper Zodz.enum(ALL_SCOPES)/z.enum(KNOWN_EVENTS)once those arrays are declaredas const— currently they're declared as plainstring[]which doesn't satisfyz.enum's[string, ...string[]]constraint.