From 801da46f11cb1f81a4548f06d0ff8e9a9c1a7c25 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 20:03:15 +0200 Subject: [PATCH 001/245] fix(realtime): force-destroy pg socket on cleanup timeout (SSE leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three SSE-routes (solo, backlog, notifications) each create a long- running pg.Client that LISTENs on scrum4me_changes. On abrupt close (Fast Refresh, browser refresh, Vercel function recycle) the pgClient.end()-await sometimes hangs silently, leaving the underlying socket connected to Postgres. The connection stays in 'idle' on Neon's side and after ~10-20 reconnects the connection-pool fills up — new SSE connects fail with ERR_INCOMPLETE_CHUNKED_ENCODING in the browser. Fix: shared `closePgClientSafely` helper that races client.end() against a 2 s timeout; on timeout it force-destroys the underlying socket so the OS releases the FD and Postgres notices the disconnect. Validated by direct DB inspection: 18 stale 'idle LISTEN'-connections were piled up before the fix; after manual pg_terminate_backend cleanup the SSE-stream stabilised. This change makes the pile-up impossible going forward. - new lib/realtime/pg-client-cleanup.ts - 3 routes use the helper instead of bare `await pgClient.end()` - 3 unit tests for the helper (timely-end, hang-falls-back-to-destroy, end-rejection-is-swallowed) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/realtime/pg-client-cleanup.test.ts | 66 +++++++++++++++++++ app/api/realtime/backlog/route.ts | 3 +- app/api/realtime/notifications/route.ts | 7 +- app/api/realtime/solo/route.ts | 7 +- lib/realtime/pg-client-cleanup.ts | 55 ++++++++++++++++ 5 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 __tests__/lib/realtime/pg-client-cleanup.test.ts create mode 100644 lib/realtime/pg-client-cleanup.ts diff --git a/__tests__/lib/realtime/pg-client-cleanup.test.ts b/__tests__/lib/realtime/pg-client-cleanup.test.ts new file mode 100644 index 0000000..099032b --- /dev/null +++ b/__tests__/lib/realtime/pg-client-cleanup.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Client } from 'pg' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +function makeFakeClient(opts: { + endResolves?: Promise + destroy?: ReturnType +}): Client { + const handlers = new Map void>>() + const fake = { + end: vi.fn().mockReturnValue(opts.endResolves ?? Promise.resolve()), + on: vi.fn((event: string, fn: (...args: unknown[]) => void) => { + const list = handlers.get(event) ?? [] + list.push(fn) + handlers.set(event, list) + return fake + }), + removeAllListeners: vi.fn((event: string) => { + handlers.delete(event) + return fake + }), + connection: { + stream: { destroy: opts.destroy ?? vi.fn() }, + }, + } + return fake as unknown as Client +} + +describe('closePgClientSafely', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + it('drops listeners and awaits client.end() when it resolves quickly', async () => { + const destroy = vi.fn() + const client = makeFakeClient({ destroy }) + + await closePgClientSafely(client, 'test') + + expect(client.removeAllListeners).toHaveBeenCalledWith('notification') + expect(client.removeAllListeners).toHaveBeenCalledWith('error') + expect(client.end).toHaveBeenCalledOnce() + expect(destroy).not.toHaveBeenCalled() // ended in time + }) + + it('falls back to socket-destroy when client.end() hangs past the timeout', async () => { + const destroy = vi.fn() + // .end() never resolves + const client = makeFakeClient({ endResolves: new Promise(() => {}), destroy }) + + vi.useFakeTimers() + const promise = closePgClientSafely(client, 'test-hang') + await vi.advanceTimersByTimeAsync(2_001) + await promise + + expect(destroy).toHaveBeenCalledOnce() + const arg = destroy.mock.calls[0][0] + expect(arg).toBeInstanceOf(Error) + }) + + it('does not throw when client.end() rejects', async () => { + const client = makeFakeClient({ endResolves: Promise.reject(new Error('boom')) }) + + await expect(closePgClientSafely(client, 'test-reject')).resolves.toBeUndefined() + }) +}) diff --git a/app/api/realtime/backlog/route.ts b/app/api/realtime/backlog/route.ts index 1736710..dfbd835 100644 --- a/app/api/realtime/backlog/route.ts +++ b/app/api/realtime/backlog/route.ts @@ -6,6 +6,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -68,7 +69,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { await pgClient.end() } catch { /* ignore */ } + await closePgClientSafely(pgClient, 'realtime/backlog') try { controller.close() } catch { /* already closed */ } if (process.env.NODE_ENV !== 'production') { console.log(`[realtime/backlog] closed: ${reason}`) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index f31c6d5..907898a 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -16,6 +16,7 @@ import { Client } from 'pg' import { getSession } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -83,11 +84,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/notifications') try { controller.close() } catch { diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 112e0cc..0553cf6 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -145,11 +146,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/solo') try { controller.close() } catch { diff --git a/lib/realtime/pg-client-cleanup.ts b/lib/realtime/pg-client-cleanup.ts new file mode 100644 index 0000000..6021320 --- /dev/null +++ b/lib/realtime/pg-client-cleanup.ts @@ -0,0 +1,55 @@ +// Robust pg.Client cleanup for SSE-routes that hold a long-running LISTEN- +// connection. Without this helper, `pgClient.end()` can hang silently when +// the underlying socket is in a weird state (Fast Refresh, abrupt browser +// close, Vercel function recycle), leaving the connection in 'idle' on the +// Postgres server. After ~10-20 reconnects the Neon connection-pool fills +// up and new SSE-connections fail with ERR_INCOMPLETE_CHUNKED_ENCODING. +// +// Strategy: race `pgClient.end()` against a short timeout; if the timeout +// wins, force-destroy the underlying socket so the OS releases the FD and +// Neon notices the disconnect. + +import type { Client } from 'pg' + +const END_TIMEOUT_MS = 2_000 + +interface PgClientWithStream { + connection?: { stream?: { destroy?: (err?: Error) => void } } +} + +export async function closePgClientSafely( + client: Client, + label: string, +): Promise { + // Drop notification/error handlers so a late event from the dying + // connection cannot trigger downstream cleanup again. + client.removeAllListeners('notification') + client.removeAllListeners('error') + client.on('error', () => { + // Swallow: connection is being torn down on purpose. + }) + + let timer: ReturnType | null = null + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), END_TIMEOUT_MS) + }) + + const result = await Promise.race([ + client.end().then(() => 'ended' as const), + timeout, + ]).catch(() => 'error' as const) + + if (timer) clearTimeout(timer) + + if (result !== 'ended') { + if (process.env.NODE_ENV !== 'production') { + console.warn(`[${label}] pgClient.end() did not finish in ${END_TIMEOUT_MS}ms — forcing socket destroy`) + } + const stream = (client as unknown as PgClientWithStream).connection?.stream + try { + stream?.destroy?.(new Error(`forced socket destroy from ${label}`)) + } catch { + // best-effort + } + } +} From 6c6c8b96b7efff67f07d4339debc46c31e27a186 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 20:04:22 +0200 Subject: [PATCH 002/245] fix(realtime): force-destroy pg socket on cleanup timeout (SSE leak) (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three SSE-routes (solo, backlog, notifications) each create a long- running pg.Client that LISTENs on scrum4me_changes. On abrupt close (Fast Refresh, browser refresh, Vercel function recycle) the pgClient.end()-await sometimes hangs silently, leaving the underlying socket connected to Postgres. The connection stays in 'idle' on Neon's side and after ~10-20 reconnects the connection-pool fills up — new SSE connects fail with ERR_INCOMPLETE_CHUNKED_ENCODING in the browser. Fix: shared `closePgClientSafely` helper that races client.end() against a 2 s timeout; on timeout it force-destroys the underlying socket so the OS releases the FD and Postgres notices the disconnect. Validated by direct DB inspection: 18 stale 'idle LISTEN'-connections were piled up before the fix; after manual pg_terminate_backend cleanup the SSE-stream stabilised. This change makes the pile-up impossible going forward. - new lib/realtime/pg-client-cleanup.ts - 3 routes use the helper instead of bare `await pgClient.end()` - 3 unit tests for the helper (timely-end, hang-falls-back-to-destroy, end-rejection-is-swallowed) Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/realtime/pg-client-cleanup.test.ts | 66 +++++++++++++++++++ app/api/realtime/backlog/route.ts | 3 +- app/api/realtime/notifications/route.ts | 7 +- app/api/realtime/solo/route.ts | 7 +- lib/realtime/pg-client-cleanup.ts | 55 ++++++++++++++++ 5 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 __tests__/lib/realtime/pg-client-cleanup.test.ts create mode 100644 lib/realtime/pg-client-cleanup.ts diff --git a/__tests__/lib/realtime/pg-client-cleanup.test.ts b/__tests__/lib/realtime/pg-client-cleanup.test.ts new file mode 100644 index 0000000..099032b --- /dev/null +++ b/__tests__/lib/realtime/pg-client-cleanup.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Client } from 'pg' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +function makeFakeClient(opts: { + endResolves?: Promise + destroy?: ReturnType +}): Client { + const handlers = new Map void>>() + const fake = { + end: vi.fn().mockReturnValue(opts.endResolves ?? Promise.resolve()), + on: vi.fn((event: string, fn: (...args: unknown[]) => void) => { + const list = handlers.get(event) ?? [] + list.push(fn) + handlers.set(event, list) + return fake + }), + removeAllListeners: vi.fn((event: string) => { + handlers.delete(event) + return fake + }), + connection: { + stream: { destroy: opts.destroy ?? vi.fn() }, + }, + } + return fake as unknown as Client +} + +describe('closePgClientSafely', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + it('drops listeners and awaits client.end() when it resolves quickly', async () => { + const destroy = vi.fn() + const client = makeFakeClient({ destroy }) + + await closePgClientSafely(client, 'test') + + expect(client.removeAllListeners).toHaveBeenCalledWith('notification') + expect(client.removeAllListeners).toHaveBeenCalledWith('error') + expect(client.end).toHaveBeenCalledOnce() + expect(destroy).not.toHaveBeenCalled() // ended in time + }) + + it('falls back to socket-destroy when client.end() hangs past the timeout', async () => { + const destroy = vi.fn() + // .end() never resolves + const client = makeFakeClient({ endResolves: new Promise(() => {}), destroy }) + + vi.useFakeTimers() + const promise = closePgClientSafely(client, 'test-hang') + await vi.advanceTimersByTimeAsync(2_001) + await promise + + expect(destroy).toHaveBeenCalledOnce() + const arg = destroy.mock.calls[0][0] + expect(arg).toBeInstanceOf(Error) + }) + + it('does not throw when client.end() rejects', async () => { + const client = makeFakeClient({ endResolves: Promise.reject(new Error('boom')) }) + + await expect(closePgClientSafely(client, 'test-reject')).resolves.toBeUndefined() + }) +}) diff --git a/app/api/realtime/backlog/route.ts b/app/api/realtime/backlog/route.ts index 1736710..dfbd835 100644 --- a/app/api/realtime/backlog/route.ts +++ b/app/api/realtime/backlog/route.ts @@ -6,6 +6,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -68,7 +69,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { await pgClient.end() } catch { /* ignore */ } + await closePgClientSafely(pgClient, 'realtime/backlog') try { controller.close() } catch { /* already closed */ } if (process.env.NODE_ENV !== 'production') { console.log(`[realtime/backlog] closed: ${reason}`) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index f31c6d5..907898a 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -16,6 +16,7 @@ import { Client } from 'pg' import { getSession } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -83,11 +84,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/notifications') try { controller.close() } catch { diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 112e0cc..0553cf6 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -145,11 +146,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/solo') try { controller.close() } catch { diff --git a/lib/realtime/pg-client-cleanup.ts b/lib/realtime/pg-client-cleanup.ts new file mode 100644 index 0000000..6021320 --- /dev/null +++ b/lib/realtime/pg-client-cleanup.ts @@ -0,0 +1,55 @@ +// Robust pg.Client cleanup for SSE-routes that hold a long-running LISTEN- +// connection. Without this helper, `pgClient.end()` can hang silently when +// the underlying socket is in a weird state (Fast Refresh, abrupt browser +// close, Vercel function recycle), leaving the connection in 'idle' on the +// Postgres server. After ~10-20 reconnects the Neon connection-pool fills +// up and new SSE-connections fail with ERR_INCOMPLETE_CHUNKED_ENCODING. +// +// Strategy: race `pgClient.end()` against a short timeout; if the timeout +// wins, force-destroy the underlying socket so the OS releases the FD and +// Neon notices the disconnect. + +import type { Client } from 'pg' + +const END_TIMEOUT_MS = 2_000 + +interface PgClientWithStream { + connection?: { stream?: { destroy?: (err?: Error) => void } } +} + +export async function closePgClientSafely( + client: Client, + label: string, +): Promise { + // Drop notification/error handlers so a late event from the dying + // connection cannot trigger downstream cleanup again. + client.removeAllListeners('notification') + client.removeAllListeners('error') + client.on('error', () => { + // Swallow: connection is being torn down on purpose. + }) + + let timer: ReturnType | null = null + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), END_TIMEOUT_MS) + }) + + const result = await Promise.race([ + client.end().then(() => 'ended' as const), + timeout, + ]).catch(() => 'error' as const) + + if (timer) clearTimeout(timer) + + if (result !== 'ended') { + if (process.env.NODE_ENV !== 'production') { + console.warn(`[${label}] pgClient.end() did not finish in ${END_TIMEOUT_MS}ms — forcing socket destroy`) + } + const stream = (client as unknown as PgClientWithStream).connection?.stream + try { + stream?.destroy?.(new Error(`forced socket destroy from ${label}`)) + } catch { + // best-effort + } + } +} From 55a1ee035c8e528ee4d3d262d4e5e28b4e9a18a3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 13:09:25 +0200 Subject: [PATCH 003/245] docs: introduce generic entity-dialog pattern + entity-profiles (#45) * docs(dialog-pattern): add generic entity-dialog spec Introduceert docs/patterns/dialog.md als bron-of-truth voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject. Bevat 14 secties: uitgangspunten, stack, component- architectuur, layout, validatie, drielaagse demo-policy, submission, dialog-gedrag, theming, footer, triggers/URL-state, per-entiteit profile-template, out-of-scope, en een verificatie-checklist. Registreert het patroon in CLAUDE.md "Implementatiepatronen"-tabel zodat Claude (en mensen) de spec verplicht raadplegen voor elke nieuwe dialog. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(dialog-pattern): convert task spec + add pbi/story entity-profiles Reduceert docs/scrum4me-task-dialog.md van 507 naar ~140 regels: alle gedeelde regels verhuisd naar docs/patterns/dialog.md, dit document bevat nu alleen Task-specifieke velden, URL-pattern, status-veld, server actions, triggers en bewuste out-of-scope-keuzes. Voegt twee nieuwe entity-profielen toe voor bestaande dialogen: - docs/scrum4me-pbi-dialog.md (PbiDialog: state-based, code+title-rij, PbiStatusSelect, geen delete in v1) - docs/scrum4me-story-dialog.md (StoryDialog: state-based, header met status/priority badges, inline activity-log, demo-readonly-fallback, inline-delete-confirm i.p.v. AlertDialog) Beide profielen documenteren expliciet de "Bekende gaps t.o.v. generieke spec" zodat opvolgende PR's de afwijkingen kunnen rechtzetten of bewust kunnen accorderen. Co-Authored-By: Claude Opus 4.7 (1M context) * Added pdevelopment docs --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- CLAUDE.md | 1 + docs/patterns/dialog.md | 387 ++++++++++++ docs/plans/ST-1114-copilot-reviews.md | 133 +++++ .../Tweede Claude Agent — Planning Agent.md | 346 +++++++++++ docs/scrum4me-pbi-dialog.md | 120 ++++ docs/scrum4me-story-dialog.md | 163 +++++ docs/scrum4me-task-dialog.md | 557 +++--------------- 8 files changed, 1241 insertions(+), 469 deletions(-) create mode 100644 docs/patterns/dialog.md create mode 100644 docs/plans/ST-1114-copilot-reviews.md create mode 100644 docs/plans/Tweede Claude Agent — Planning Agent.md create mode 100644 docs/scrum4me-pbi-dialog.md create mode 100644 docs/scrum4me-story-dialog.md diff --git a/.gitignore b/.gitignore index e3c508b..9c8093c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,5 @@ jp.sh .codex/ # Lokale scratch-bestanden -Brainstro \ No newline at end of file +Brainstro +/graphify-out \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 65be9e6..010083b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven. | Middleware (route protection) | `docs/patterns/middleware.md` | | QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` | | Bidirectionele async-comms MCP-agent ↔ user | `docs/patterns/claude-question-channel.md` | +| **Entity Dialog (verplicht voor élke create/edit/detail-dialog)** | `docs/patterns/dialog.md` — bron-of-truth; per entiteit één profile-doc (bv. `docs/scrum4me-task-dialog.md`) | | Status-enum mapping (DB ↔ API) | `lib/task-status.ts` | | Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten | diff --git a/docs/patterns/dialog.md b/docs/patterns/dialog.md new file mode 100644 index 0000000..9bf4682 --- /dev/null +++ b/docs/patterns/dialog.md @@ -0,0 +1,387 @@ +# Pattern — Entity Dialog + +Deze pagina is **bindend** voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject (PBI, Story, Task, Todo, Sprint, Product, User, of toekomstige entiteiten). Een nieuwe dialog die hier niet aan voldoet, hoort niet gemerged te worden. + +> **Doel:** elke dialog voelt identiek aan voor de gebruiker, hergebruikt dezelfde primitives, en heeft de drielaagse demo-policy + auth-scoping standaard ingebakken. + +Voor entity-specifieke afwijkingen of velden: schrijf één begeleidende doc per entiteit (zie sectie [§ Per-entiteit profile](#per-entiteit-profile-verplicht)). Voorbeeld: `docs/scrum4me-task-dialog.md` is het Task-profiel. + +--- + +## 1 — Verplichte uitgangspunten + +| # | Regel | Bron / waarom | +|---|---|---| +| 1.1 | Bouw op `components/ui/dialog.tsx` (de bestaande shadcn/`@base-ui/react`-wrapper). **Geen** directe imports van dialog-primitives uit `@base-ui/react`. | Voorkomt twee parallelle dialog-implementaties met inconsistente animatie/focus-trap/theming | +| 1.2 | Gebruik composition via de **`render`-prop** (zie `CLAUDE.md` "UI Library Conventions"). Nooit Radix' `asChild`. | Project gebruikt `@base-ui/react`, niet Radix | +| 1.3 | Mode (`create` vs `edit` vs `detail`) wordt afgeleid uit één input — een prop, een `state`-object of een `searchParam`. **Niet** twee aparte componenten. | Voorkomt code-duplicatie en inconsistente labels/footer-layouts | +| 1.4 | Auth-scoping op elke server action via `productAccessFilter(userId)` (of het scope-helper-equivalent). Cross-tenant writes mogen onmogelijk zijn. | `CLAUDE.md` "Toegangsmodel" + `docs/patterns/server-action.md` | +| 1.5 | **Drielaagse demo-policy** (verplicht — zie § 6) op elke write-actie. | `CLAUDE.md` "Demo-check" + `docs/scrum4me-architecture.md#demo-user-policy` | +| 1.6 | Validatie via één gedeeld zod-schema (`lib/schemas/.ts`) — gebruikt door zowel form als server action. | `CLAUDE.md` "Validatie" | +| 1.7 | Foutcodes volgen de project-conventie (§ 5). | `CLAUDE.md` "Foutcodes API" | +| 1.8 | Geen willekeurige Tailwind-kleuren (`bg-blue-500` etc.). Alleen MD3-tokens uit `app/styles/theme.css`. | `docs/scrum4me-styling.md` | + +--- + +## 2 — Stack & dependencies + +Toegestane runtime-deps voor dialog-werk (al aanwezig of standaard pattern): + +| Doel | Voorkeur | Acceptabele alternatief | +|---|---|---| +| Form-state | `react-hook-form` + `@hookform/resolvers/zod` | `useActionState` + `useFormStatus` (Server Actions, native React) | +| Auto-grow textarea | `react-textarea-autosize` | — | +| Markdown-rendering (preview) | `react-markdown` + `remark-gfm` (via bestaande ``-wrapper) | — | +| Toasts | `sonner` | — | +| Iconen | `lucide-react` | — | + +> **Per-dialog mag je kiezen tussen `react-hook-form` of `useActionState`.** Beide patronen draaien al in deze codebase. Kies één per dialog en blijf consistent binnen dat bestand. Mix ze niet. + +Verboden in dialog-context (v1): +- `material-color-utilities` (dynamic color valt buiten v1) +- Nieuwe form-libraries — geen `formik`, `final-form`, etc. + +--- + +## 3 — Component-architectuur + +### 3.1 Reusables (`components/entity-dialog/` of `components/shared/`) + +Deze primitives kennen géén entity-specifieke types en mogen door élke dialog gebruikt worden: + +| Primitive | Locatie | Verantwoordelijkheid | +|---|---|---| +| `` + `` etc. | `components/ui/dialog.tsx` | Shell, motion, focus-trap, backdrop | +| `` / `` | `components/shared/priority-select.tsx` | P1-P4 — identiek over alle entiteiten | +| `` | `components/shared/demo-tooltip.tsx` | Wrapper rond write-knoppen voor demo-modus (laag 3 van 3) | +| Auto-grow textarea | (toe te voegen wanneer nodig in `components/shared/`) | Wrapper rond `react-textarea-autosize` met char-counter + markdown-hint | +| Dirty-close-guard | (gedeelde AlertDialog-flow) | "Wijzigingen niet opgeslagen — weggooien?" | +| `` | `components/markdown.tsx` | `react-markdown` + `remark-gfm` voor description/criteria-preview | + +> Wanneer je een primitive twee keer kopieert tussen entity-dialogs, **promote 'm meteen** naar `components/shared/` (of `components/entity-dialog/`). Drie keer is te laat. + +### 3.2 Entity-specifieke laag (`components//-dialog.tsx`) + +Per entiteit één wrapper-bestand dat: +1. De juiste form/body rendert +2. De juiste server actions koppelt (`saveAction`, `deleteAction`) +3. Entiteit-specifieke labels levert ("Story bewerken", "PBI aanmaken", etc.) + +Een entity-dialog bevat **geen** layout-mechanica, motion-config of dirty-check zelf — die komen uit § 3.1. + +--- + +## 4 — Layout & responsive gedrag + +Identiek voor élke dialog (geen entity-specifieke variaties tenzij expliciet beargumenteerd in het entity-profile): + +| Breakpoint | Breedte | Hoogte | +|---|---|---| +| Mobiel (< 640px) | full-screen | full-screen | +| Tablet (640–1024px) | `90vw` | `max-h-[85vh]` | +| Desktop (≥ 1024px) | `max-w-[50vw]`, `min-w-[480px]` | `max-h-[85vh]` | + +Verplicht: +- Padding `p-6` rondom (24px) +- Veld-spacing in body `space-y-6` (24px) +- **Sticky** header (titel + close) en **sticky** footer (knoppen) +- Body scrollt onafhankelijk; geneste scrolls vermijden +- Footer heeft `border-t` in `outline-variant` + +--- + +## 5 — Validatie & foutcodes + +### 5.1 zod-schema + +Eén `lib/schemas/.ts` per entiteit. Geïmporteerd door zowel form als server action — geen aparte definities. + +### 5.2 Foutcodes (verplicht) + +| Code | Wanneer | UI-respons | +|---|---|---| +| **422** | zod-validatiefout (server-side dubbelcheck) | `fieldErrors` mappen naar `form.setError()`, géén toast | +| **403** | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" of "Geen toegang", form blijft open | +| **400** | malformed JSON-body (`request.json()` faalt) — alleen bij REST-route-handlers | toast "Ongeldige aanvraag" | +| **500** | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden | + +> Field-level errors zijn **alleen** geldig bij `code: 422`. Bij andere codes is `fieldErrors` ongedefinieerd. + +### 5.3 Field-level rendering + +- Errors **onder** het veld, in `text-error`, met `border-error` op het input-element +- Géén toast voor field-level errors +- Submit-knop **blijft enabled** bij errors — klik scrollt naar eerste error-veld + focus +- react-hook-form mode: `onTouched` (eerste validatie bij blur, daarna onChange) + +--- + +## 6 — Drielaagse demo-policy (verplicht voor write-dialogs) + +Elke dialog die schrijft (create / edit / delete) MOET door alle drie de lagen heen: + +1. **Middleware-guard** in `proxy.ts` — blokkeert demo-sessies op write-routes vóór de server action loopt. Returnt **403**. +2. **`session.isDemo`-check** binnen elke `saveAction` / `deleteAction` zelf — defense-in-depth voor het geval een actie buiten een proxy-route loopt. Returnt **403**. +3. **``** rond de submit- en delete-knoppen — UI-laag: knoppen `disabled` met tooltip "Demo-modus: opslaan uitgeschakeld". + +> Eén laag missen = bug. Reviewers moeten alle drie de lagen kunnen aanwijzen in de PR. + +--- + +## 7 — Submission-flow + +### 7.1 Server Action (template) + +```ts +// actions/.ts +'use server' + +export async function saveAction( + input: Input, + context: { /* ids voor revalidatePath en scope */ }, +): PromiseResult> { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + const scope = await productAccessFilter(session.userId) // verplicht + const parsed = Schema.safeParse(input) + if (!parsed.success) { + return { ok: false, code: 422, error: 'validation', fieldErrors: parsed.error.flatten().fieldErrors } + } + // ... Prisma write binnen `scope` ... + // revalidatePath(...) op de context-route + return { ok: true, : row } +} + +type SaveResult = + | { ok: true; : } + | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } +``` + +### 7.2 Revalidation + +`revalidatePath` op de **context-route** waarin de dialog werd geopend, niet op een statisch path. Context wordt door de aanroepende client meegegeven (geen hard-coded paths in de action). + +### 7.3 Submit-flow + +- Synchroon (geen optimistic update in v1, behalve waar het store-patroon `usePlannerStore` al bestaat) +- Tijdens submit: cancel- en submit-knop disabled, spinner of "…" in submit-knop, velden **blijven enabled** +- Server saniteert en valideert opnieuw met hetzelfde zod-schema + +--- + +## 8 — Dialog-gedrag (UX-regels) + +### 8.1 Sluiten met dirty state + +- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten **direct** +- Form `isDirty` → Esc / backdrop-klik / Cancel triggeren `AlertDialog`: *"Wijzigingen niet opgeslagen — weggooien?"* + +### 8.2 Keyboard shortcuts + +| Toets | Actie | +|---|---| +| **Esc** | Sluiten (met dirty-check) | +| **Cmd/Ctrl + Enter** | Submit vanuit elk veld | +| **Enter in ``** | **Geen** submit (alleen Cmd/Ctrl+Enter) | +| **Enter in `