Files
AOS/worklog.md
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

191 lines
26 KiB
Markdown
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 file `src/app/api/aos/webhooks/[id]/route.ts`. Removed PATCH from the top-level `route.ts`. This also resolved the cascading `.next/dev/types/validator.ts` RouteHandlerConfig error.
- src/app/api/aos/kb/route.ts: Replaced the broken inline `where` type annotation with `Prisma.ArticleWhereInput` (imported `Prisma` from `@prisma/client`). The previous type declared `OR` as a single object, but the code assigns an array; the Prisma-generated type is the source of truth and accepts the `OR` array of filter objects including `excerpt`.
- src/app/api/aos/setup/test-mail/route.ts (line 57): Changed `banner: banner || null` to `banner: banner || undefined` so the value matches the `banner?: string` property on `MailTestResult` (optional string, not `string | null`).
- src/components/aos/mobile-bottom-nav.tsx: Widened the `items` array type from `{ id: ViewId | 'search' | 'menu'; ... }` to `{ id: string; ... }` so `'notifications'` is allowed. Updated `handleClick` to take `id: string` (still casts to `ViewId` for the real view setter). Updated the `active` computation 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)` to `p.company?.id && onSelectCompany(p.company.id)` since the `Person` type only exposes the `company` relation, not a flat `companyId`.
- src/components/aos/views/estimates.tsx: Added `projectId?: string | null` to the `Estimate` interface (line ~50) so the chain-indicator logic that reads `selected.projectId` type-checks.
- src/components/aos/views/support.tsx: Added `createdAt: string` to the `Ticket` interface (line ~38) so the `t.createdAt` fallback in `formatRelative(...)` 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 no `src/` errors (the only remaining error is in `skills/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.ts` RouteHandlerConfig 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 the `try` block.
- Write handlers updated (14 files, 15 handlers):
1. src/app/api/aos/crm/deals/route.ts — POST
2. src/app/api/aos/estimates/[id]/convert/route.ts — POST
3. src/app/api/aos/accounting/bank/[id]/match/route.ts — POST
4. src/app/api/aos/accounting/vat/[id]/submit/route.ts — PATCH
5. src/app/api/aos/expenses/[id]/convert/route.ts — POST
6. src/app/api/aos/api-tokens/[id]/revoke/route.ts — PATCH
7. src/app/api/aos/api-tokens/route.ts — POST (GET left untouched — simple list)
8. src/app/api/aos/webhooks/route.ts — POST (GET left untouched — simple list)
9. src/app/api/aos/webhooks/[id]/route.ts — PATCH
10. src/app/api/aos/projects/[id]/tasks/route.ts — POST
11. src/app/api/aos/tasks/[id]/comments/route.ts — POST (GET left untouched — simple list)
12. src/app/api/aos/leads/convert/route.ts — POST
13. src/app/api/aos/settings/route.ts — PATCH (GET left untouched — simple list)
14. 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 — no `src/` TypeScript errors introduced. (Pre-existing errors remain only in `examples/websocket/*.tsx` and `skills/*`, both explicitly out of scope per the previous task's worklog.)
- `bun run lint` exits 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. The `err 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 the `try` block. Used `Response.json` in the `/api/v1/*` routes (which already use the global `Response`) and `NextResponse.json` in the `/api/aos/*` routes (which already import `NextResponse`) to keep each file consistent with its existing return style.
- GET handlers updated (17 files, 17 handlers):
1. src/app/api/v1/companies/route.ts — GET (public API: companies list with auth + scope check)
2. src/app/api/v1/invoices/route.ts — GET (public API: invoices list)
3. src/app/api/v1/licenses/route.ts — GET (public API: licenses list)
4. src/app/api/aos/portal/companies/[id]/notifications/route.ts — GET (5-table Promise.all + per-row notif derivation)
5. src/app/api/aos/portal/companies/[id]/route.ts — GET (company dashboard: multi-include + tickets + approvals + KPIs)
6. src/app/api/aos/portal/companies/route.ts — GET (portal-eligible companies list)
7. src/app/api/aos/crm/companies/[id]/route.ts — GET (company detail + audit + timeline + consent)
8. src/app/api/aos/crm/route.ts — GET (3-table Promise.all + pipeline grouping)
9. src/app/api/aos/estimates/route.ts — GET (estimates + secondary company lookup + stats)
10. src/app/api/aos/kb/route.ts — GET (KB articles + filter + stats)
11. src/app/api/aos/finance/route.ts — GET (invoices + e-Rechnung stats)
12. src/app/api/aos/expenses/route.ts — GET (expenses + company/project enrichment + byCategory + 6-month report)
13. src/app/api/aos/tickets/route.ts — GET (ticket inbox + SLA derivation + stats)
14. src/app/api/aos/contracts/route.ts — GET (contracts + company enrichment + expiry stats)
15. src/app/api/aos/search/route.ts — GET (7-table global command-palette search)
16. src/app/api/aos/leads/route.ts — GET (leads review queue + JSON.parse of evidence column)
17. 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) or `src/app/api/route.ts` (static) as instructed.
- src/lib/api-auth.ts line 38: replaced the silent `.catch(() => {})` on the fire-and-forget `db.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 broken `lastUsedAt` write becomes visible in server logs rather than disappearing silently.
Stage Summary:
- `npx tsc --noEmit 2>&1 | grep "^src/"` returns empty — no `src/` TypeScript errors introduced. (Pre-existing errors remain only in `examples/websocket/*.tsx` and `skills/*`, both explicitly out of scope per the refactor-1 worklog.)
- `bun run lint` exits 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 when `JSON.parse(l.evidence)` throws on a malformed row (notably the `leads` route, 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 named `FinanzamtView` and also exported as default. Top-level component renders a `PageHeader` + a controlled `Tabs` (value/onValueChange) with 4 `TabsContent` panels, each mounting its own sub-component that owns its `useFetch` + 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 to `fileContent = fileName` so 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 `useMemo` preview of the computed amount using the `rates` returned 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: undefined` sent 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 use `toast.loading` → `toast.success`/`toast.error` with `{ 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`; `useFetch` from `@/hooks/use-fetch`; `toast` from `sonner`; `formatEuro`/`formatDate` from `@/lib/format`; `cn` from `@/lib/utils`; `PageHeader` from `../primitives`; lucide icons (`FileText`, `Plus`, `Loader2`, `Calculator`, `Banknote`, `Wallet`, `Receipt`, `Home`, `Car`, `UtensilsCrossed`, `Package`, `TrendingDown`, `PiggyBank`, `Lock`).
- All UI labels in German; no `any` types — every API payload shape declared as an explicit interface; `Pauschale['type']` used as the key type for `PAUSCHALE_TYPE_META` so the map is exhaustive.
- Bonus 1-line fix in `src/app/api/aos/belege/route.ts:45`: added parens around the ternary in `setFullYear(getFullYear() + type.includes('rechnung') ? 8 : 6)` → `setFullYear(getFullYear() + (type.includes('rechnung') ? 8 : 6))`. The previous code was a `number + boolean` expression (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 remaining `src/` 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 `FinanzamtView` into 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 `safeParse` pattern at the top of every write handler that parses a request body:
```ts
const 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
```
This returns HTTP 400 with a structured `{ formErrors, fieldErrors }` payload (Zod's `flatten()`) on bad input, while successful parses yield a fully-typed `body`. 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 `const` so the inferred type flows into `body` (no more `as { … }` casts on `req.json()`).
Files modified (24 files, 26 handlers):
1. `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 manual `if (!body.companyId)` / `if (!body.title)` 400-checks since Zod handles them now.
2. `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 !== 0` to `mapping.name === undefined`.
3. `src/app/api/aos/leads/[id]/route.ts` — PATCH: `status` (`z.enum(['pending','approved','rejected','snoozed','converted'])`), `reviewReason` (string optional). Removed the manual `includes()` enum check.
4. `src/app/api/aos/leads/convert/route.ts` — POST: `leadId` (string min 1).
5. `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.
6. `src/app/api/aos/estimates/[id]/accept/route.ts` — PATCH: `acceptedBy` (string optional). Kept the `req.json().catch(() => ({}))` so an absent body still validates.
7. `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).
8. `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).
9. `src/app/api/aos/finance/[id]/finalize/route.ts` — PATCH: `zugferd` (bool optional), `xrechnung` (bool optional). Kept the `req.json().catch(() => ({}))` pattern.
10. `src/app/api/aos/contracts/[id]/sign/route.ts` — PATCH: `signedBy` (string optional). Kept the `req.json().catch(() => ({}))` pattern.
11. `src/app/api/aos/accounting/bank/[id]/match/route.ts` — POST: `invoiceId` (string min 1).
12. `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 downstream `xmlString.trim().startsWith('<')` check, since the body isn't JSON in that case).
13. `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 manual `name?.trim()` and `scopes?.length` checks; kept the secondary `invalid = 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).
14. `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 manual `url?.trim()` / `url.startsWith('https://')` / `events?.length` checks; kept the secondary `invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')` check (same reason as tokens — it returns the valid-event list).
15. `src/app/api/aos/timesheet/route.ts` — PATCH: `week` (`z.string().regex(/^\d{4}-\d{2}-\d{2}$/)`). Removed the manual `!week` check; kept the downstream `isNaN(refDate.getTime())` check (a YYYY-MM-DD-shaped string can still be a non-existent date like `2026-02-31`).
16. `src/app/api/aos/tasks/[id]/comments/route.ts` — POST: `body` (string min 1), `author` (string optional). Removed the manual `body?.trim()` check.
17. `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 manual `includes()` enum check.
18. `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 manual `topic?.trim()` check.
19. `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 manual `fileName?.trim()` check.
20. `src/app/api/aos/pauschalen/route.ts` — POST: `type` (`z.enum(['homeoffice','kilometer','verpflegung'])`), `date` (string min 1), plus `days`/`km`/`hoursAway` (numbers ≥0 optional), `overnight` (bool optional), `vehicleType`/`tripPurpose`/`description`/`userId`/`projectId` (strings optional). Removed the manual `body.type || !body.date` check.
21. `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 || ≤0` checks.
22. `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 manual `description` / `amountCents` / `type` 400-checks.
23. `src/app/api/aos/buchhaltung/route.ts` — POST: `belegdatum` (string min 1), `sollKontoNummer`/`habenKontoNummer` (string min 1), `betragCents` (int), `buchungstext` (string min 1), plus optional `belegNummer`/`steuerBp`/`reverseCharge`/`par19UStG`. PATCH: `buchungId` (string min 1).
24. `src/app/api/aos/buchhaltung/jahresabschluss/route.ts` — POST: `jahr` (`z.number().int().min(1900).max(9999)`). Removed the manual `!jahr` check.
25. `src/app/api/aos/setup/test-mail/route.ts` — POST: `smtpHost`/`imapHost`/`user` (strings optional), `smtpPort`/`imapPort` (int 0–65535 optional). Kept the `req.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 `_req` and only uses the URL `[id]` param; no `req.json()` call.
- `src/app/api/aos/expenses/[id]/convert/route.ts` — same: `_req` only, uses URL `[id]`.
- `src/app/api/aos/accounting/vat/[id]/submit/route.ts` — PATCH handler takes `_req` and only uses URL `[id]`; no body parsing.
- `src/app/api/aos/recurring/run/route.ts` — POST handler takes no `req` param 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 v4 `z.record(K, V)` two-arg signature works correctly; the `z.string().startsWith(...)` chain works on `ZodString`; `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 `if` checks 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-rolled `if` check 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 tiny `withValidation(schema, handler)` HOF — the same de-duplication opportunity noted in the refactor-2 worklog for the catch blocks; (2) replace the secondary `scopes.filter((s) => !ALL_SCOPES.includes(s))` / `events.filter((e) => !KNOWN_EVENTS.includes(e))` ad-hoc allow-list checks with proper Zod `z.enum(ALL_SCOPES)` / `z.enum(KNOWN_EVENTS)` once those arrays are declared `as const` — currently they're declared as plain `string[]` which doesn't satisfy `z.enum`'s `[string, ...string[]]` constraint.