From 7e45bbdbc0d703d728c262ebefb7792c7b814dfd Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 03:21:59 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20AI-optimized=20docs=20restructure=20(Ph?= =?UTF-8?q?ases=201=E2=80=938)=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * docs(plans): add docs-restructure plan for AI-optimized lookup Audit of existing 39 doc files (~10.700 lines) and a phased restructure proposal aimed at minimising the tokens an AI agent has to read to find the right reference. Captures resolved decisions on language (English), ADR template (Nygard default with MADR escape-hatch), index generator (node script), and folder taxonomy. Proposal status — fase 1 to follow. * docs(adr): add ADR scaffolding (templates, README, meta-ADR) Set up docs/adr/ as the canonical home for architecture decisions: - templates/nygard.md — default four-section format (Status, Context, Decision, Consequences) for one-way-door decisions. - templates/madr.md — MADR v4 with YAML front-matter and explicit Considered Options for decisions where rejected alternatives matter. - README.md — naming convention (NNNN-kebab-case), template-selection guidance (Nygard default; MADR for auth, queue mechanics, agent integration), status lifecycle, and ADR roster. - 0000-record-architecture-decisions.md — meta-ADR establishing the practice itself, in Nygard format. Backfilling existing implicit decisions (base-ui-over-radix, float sort_order, demo-user three-layer policy, etc.) is fase 6 of the docs-restructure plan. * feat(docs): add docs index generator + initial INDEX.md scripts/generate-docs-index.mjs walks docs/**/*.md, parses YAML front-matter (or first H1 fallback) and a Nygard-style ## Status section, then writes docs/INDEX.md with grouped tables for ADRs, Specs, Plans (with archive subsection), Patterns, and Other. Pure Node 20 (no external deps); idempotent — running it twice produces byte-identical output. Excludes adr/templates/, the ADR README, INDEX.md itself, and any *_*.md sidecar file. Wire-up: - package.json: docs:index → node scripts/generate-docs-index.mjs Initial run indexed 35 docs across the existing structure; the generated INDEX.md is committed so the table is reviewable in the PR before hooking generation into a pre-commit step. * chore: ignore Obsidian vault and personal sidecar files Add .obsidian/ (Obsidian vault config) and _*.md (personal sidecar notes) to .gitignore so the docs/ tree can serve as canonical source of truth while still being usable as an Obsidian vault for personal authoring. The docs index generator already excludes the same _*.md pattern from INDEX.md. * docs(plans): add PBI bulk-create spec for docs-restructure Machine-parseable spec for an executor that calls the scrum4me MCP (create_pbi → create_story → create_task) to seed the docs-restructure work into the DB. - Section 1 (Context) is the PBI description; serves as task-context via mcp__scrum4me__get_claude_context. - Section 2 lists the 6 resolved decisions (English, MD3+styling merged, solo-paneel merged, .Plans archived, Nygard ADR default, node index script). - Section 3 records what already shipped on this branch so the executor doesn't duplicate the ADR scaffolding or index generator. - Section 4 carries the structured YAML graph: 1 PBI, 8 stories (one per phase), 39 tasks. product_id is REPLACE_ME — fill before running. - YAML validated with PyYAML; field schema sanity-checked. * docs(junk-cleanup): remove stub patterns/test.md * docs(junk-cleanup): archive .Plans/ to docs/plans/archive/ * docs(front-matter): add YAML front-matter to docs/ root * docs(front-matter): add YAML front-matter to patterns/ * docs(front-matter): add YAML front-matter to plans + agent files * docs(index): regenerate INDEX.md after front-matter pass * docs(naming): drop scrum4me- prefix from doc filenames * docs(naming): lowercase API.md and MD3 filenames * docs(naming): rename plan file to kebab-case ASCII * docs(naming): rename middleware.md to proxy.md (next 16) * docs(naming): polish CLAUDE.md doc-index after renames * docs(taxonomy): scaffold topical folders under docs/ * docs(taxonomy): move spec files into docs/specs/ * docs(taxonomy): move design/api/qa/backlog/assets into folders * docs(taxonomy): move agent-instruction-audit into decisions/ * docs(split): break architecture.md into 6 topical files * docs(split): merge solo-paneel-spec into specs/functional.md * docs(split): merge md3-color-scheme into design/styling * docs(trim): extract branch/commit rules into runbook * docs(trim): extract MCP integration into runbook * docs(adr): add 0001-base-ui-over-radix * docs(adr): add 0002-float-sort-order * docs(adr): add 0003-one-branch-per-milestone * docs(adr): add 0004-status-enum-mapping * docs(adr): add 0005-iron-session-over-nextauth * docs(adr): add 0006-demo-user-three-layer-policy * docs(adr): add 0007-claude-question-channel-design * docs(adr): add 0008-agent-instructions-in-claude-md + update README index * docs(index): regenerate after ADR 0001-0008 * docs(glossary): add docs/glossary.md * chore(docs): regenerate INDEX.md in pre-commit hook * docs(readme): link INDEX + glossary + agent instructions * feat(docs): add doc-link checker script * chore(docs): wire docs:check-links and docs npm scripts * ci(docs): block merge on broken doc links * docs(links): fix broken cross-references after restructure --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 9 +- .github/workflows/ci.yml | 3 + .gitignore | 7 +- .husky/pre-commit | 5 + AGENTS.md | 45 +- CLAUDE.md | 396 +--- README.md | 22 +- docs/INDEX.md | 107 ++ .../adr/0000-record-architecture-decisions.md | 66 + docs/adr/0001-base-ui-over-radix.md | 34 + docs/adr/0002-float-sort-order.md | 26 + docs/adr/0003-one-branch-per-milestone.md | 61 + docs/adr/0004-status-enum-mapping.md | 28 + docs/adr/0005-iron-session-over-nextauth.md | 71 + docs/adr/0006-demo-user-three-layer-policy.md | 30 + .../0007-claude-question-channel-design.md | 64 + .../0008-agent-instructions-in-claude-md.md | 41 + docs/adr/README.md | 101 ++ docs/adr/templates/madr.md | 78 + docs/adr/templates/nygard.md | 31 + docs/api.md | 10 +- docs/api/rest-contract.md | 528 ++++++ docs/architecture.md | 1259 +------------ docs/architecture/auth-and-sessions.md | 216 +++ docs/architecture/claude-question-channel.md | 79 + docs/architecture/data-model.md | 460 +++++ docs/architecture/overview.md | 59 + docs/architecture/project-structure.md | 397 ++++ docs/architecture/qr-pairing.md | 88 + docs/{ => assets}/erd.svg | 0 docs/{ => assets}/icons.html | 0 docs/backlog.md | 51 +- docs/backlog/index.md | 784 ++++++++ docs/backlog/product-historical.md | 462 +++++ .../agent-instructions-history.md} | 36 +- docs/design/styling.md | 1601 +++++++++++++++++ docs/functional.md | 785 +++++++- docs/glossary.md | 56 + docs/md3-color-scheme.md | 692 ++++++- docs/obsidian-authoring.md | 202 +++ docs/patterns/claude-question-channel.md | 13 +- docs/patterns/dialog.md | 17 +- docs/patterns/iron-session.md | 9 + docs/patterns/prisma-client.md | 15 +- docs/patterns/proxy.md | 9 + docs/patterns/qr-login.md | 11 +- docs/patterns/route-handler.md | 9 + docs/patterns/server-action.md | 9 + docs/patterns/sort-order.md | 9 + docs/patterns/test.md | 1 - docs/patterns/zustand-optimistic.md | 9 + docs/personas.md | 8 + docs/plans/M10-qr-pairing-login.md | 31 +- docs/plans/M11-claude-questions.md | 47 +- docs/plans/M9-active-product-backlog.md | 13 +- docs/plans/ST-1109-pbi-status.md | 9 + docs/plans/ST-1110-demo-readonly.md | 9 + docs/plans/ST-1111-claude-job-trigger.md | 13 +- docs/plans/ST-1114-copilot-reviews.md | 9 + .../2026-04-27-claude-md-workflow-update.md | 150 ++ .../2026-04-27-insert-milestone-tool.md | 111 ++ .../archive/2026-04-27-m8-realtime-solo.md | 195 ++ docs/plans/docs-restructure-ai-lookup.md | 499 +++++ docs/plans/docs-restructure-pbi-spec.md | 783 ++++++++ docs/plans/tweede-claude-agent-planning.md | 29 +- docs/product-backlog.md | 8 + docs/qa/api-test-plan.md | 462 +++++ docs/runbooks/branch-and-commit.md | 104 ++ docs/runbooks/deploy-vercel.md | 16 + docs/runbooks/mcp-integration.md | 62 + docs/solo-paneel-spec.md | 771 -------- docs/specs/dialogs/pbi.md | 128 ++ docs/specs/dialogs/story.md | 171 ++ docs/specs/dialogs/task.md | 135 ++ docs/specs/functional.md | 1433 +++++++++++++++ docs/specs/personas.md | 146 ++ docs/styling.md | 670 ------- docs/test-plan.md | 8 + package.json | 5 +- scripts/check-doc-links.mjs | 115 ++ scripts/generate-docs-index.mjs | 277 +++ 81 files changed, 12364 insertions(+), 3154 deletions(-) create mode 100644 docs/INDEX.md create mode 100644 docs/adr/0000-record-architecture-decisions.md create mode 100644 docs/adr/0001-base-ui-over-radix.md create mode 100644 docs/adr/0002-float-sort-order.md create mode 100644 docs/adr/0003-one-branch-per-milestone.md create mode 100644 docs/adr/0004-status-enum-mapping.md create mode 100644 docs/adr/0005-iron-session-over-nextauth.md create mode 100644 docs/adr/0006-demo-user-three-layer-policy.md create mode 100644 docs/adr/0007-claude-question-channel-design.md create mode 100644 docs/adr/0008-agent-instructions-in-claude-md.md create mode 100644 docs/adr/README.md create mode 100644 docs/adr/templates/madr.md create mode 100644 docs/adr/templates/nygard.md create mode 100644 docs/api/rest-contract.md create mode 100644 docs/architecture/auth-and-sessions.md create mode 100644 docs/architecture/claude-question-channel.md create mode 100644 docs/architecture/data-model.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/project-structure.md create mode 100644 docs/architecture/qr-pairing.md rename docs/{ => assets}/erd.svg (100%) rename docs/{ => assets}/icons.html (100%) create mode 100644 docs/backlog/index.md create mode 100644 docs/backlog/product-historical.md rename docs/{agent-instruction-audit.md => decisions/agent-instructions-history.md} (90%) create mode 100644 docs/design/styling.md create mode 100644 docs/glossary.md create mode 100644 docs/obsidian-authoring.md delete mode 100644 docs/patterns/test.md create mode 100644 docs/plans/archive/2026-04-27-claude-md-workflow-update.md create mode 100644 docs/plans/archive/2026-04-27-insert-milestone-tool.md create mode 100644 docs/plans/archive/2026-04-27-m8-realtime-solo.md create mode 100644 docs/plans/docs-restructure-ai-lookup.md create mode 100644 docs/plans/docs-restructure-pbi-spec.md create mode 100644 docs/qa/api-test-plan.md create mode 100644 docs/runbooks/branch-and-commit.md create mode 100644 docs/runbooks/deploy-vercel.md create mode 100644 docs/runbooks/mcp-integration.md delete mode 100644 docs/solo-paneel-spec.md create mode 100644 docs/specs/dialogs/pbi.md create mode 100644 docs/specs/dialogs/story.md create mode 100644 docs/specs/dialogs/task.md create mode 100644 docs/specs/functional.md create mode 100644 docs/specs/personas.md delete mode 100644 docs/styling.md create mode 100644 scripts/check-doc-links.mjs create mode 100644 scripts/generate-docs-index.mjs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41bd3a1..3f5b214 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -71,7 +71,14 @@ "Bash(Sort-Object)", "PowerShell(Push-Location \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\"; npx tsc --noEmit; $result = $?; Pop-Location; Write-Output \"typecheck ok: $result\")", "PowerShell(git *)", - "mcp__scrum4me__verify_task_against_plan" + "mcp__scrum4me__verify_task_against_plan", + "Bash(mkdir -p docs/plans/archive)", + "Bash(rmdir .Plans)", + "Bash(mv .Plans/2026-04-27-claude-md-workflow-update.md docs/plans/archive/)", + "Bash(mv .Plans/2026-04-27-insert-milestone-tool.md docs/plans/archive/)", + "Bash(mv .Plans/2026-04-27-m8-realtime-solo.md docs/plans/archive/)", + "Bash(xargs sed *)", + "Bash(python3 *)" ] }, "enableAllProjectMcpServers": true, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8723e0a..e9b47e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Test run: npm test + - name: Check doc links + run: npm run docs:check-links + - name: Build run: npm run build env: diff --git a/.gitignore b/.gitignore index 9c8093c..d20df70 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ next-env.d.ts .claude/settings.local.json # Local plan/scratch files (per-developer, not shared) -.Plans/ # Editor .vscode/ @@ -72,4 +71,8 @@ jp.sh # Lokale scratch-bestanden Brainstro -/graphify-out \ No newline at end of file +/graphify-out + +# Personal Obsidian authoring layer (vault config + sidecar files prefixed `_`) +.obsidian/ +_*.md \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc5..be8c369 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,6 @@ npx lint-staged + +if git diff --cached --name-only | grep -q '^docs/.*\.md$'; then + npm run docs:index + git add docs/INDEX.md +fi diff --git a/AGENTS.md b/AGENTS.md index 186e2d4..da6aa78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,38 +1,13 @@ - -# This is NOT the Next.js you know +--- +title: "AGENTS.md — Scrum4Me agent rules" +status: active +audience: [ai-agent] +language: en +last_updated: 2026-05-03 +--- -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - +# Agent Instructions — Scrum4Me -# Scrum4Me Codex Rules +This file is a redirect stub. All agent instructions live in **[CLAUDE.md](./CLAUDE.md)**. -Read `CLAUDE.md` and the relevant files in `docs/` before changing behavior. The same product and security rules apply to Codex work. - -## Access Control - -- Product-scoped access is owner-or-member: use `productAccessFilter(userId)` from `lib/product-access.ts`. -- Use owner-only `user_id` checks only for actions that truly require ownership, such as product archiving and team management. -- Never trust client-provided IDs by themselves. For reorder, promotion, completion, or bulk updates, fetch the records with both `id in (...)` and the parent scope (`product_id`, `pbi_id`, `sprint_id`, or `story_id`) before writing. -- Reject duplicate IDs in ordered lists or decision payloads. -- Derive denormalized fields from database parents, for example `pbi.product_id`, not from form data or JSON bodies. -- Demo users and demo API tokens must receive 403 on write operations. - -## Documentation Sync - -When changing behavior, API responses, dependencies, environment variables, deployment behavior, or analytics, update the matching docs in the same change: - -- `README.md` for setup, dependencies, deployment, and API overview. -- `docs/functional.md` for user-facing/API requirements. -- `docs/architecture.md` for stack, access model, data model, env vars, and deployment. -- `docs/patterns/` when a reusable implementation rule changes. -- `CLAUDE.md` and this file when an agent instruction would have prevented the issue. - -## Verification - -Before handing work back, run: - -```bash -npm run lint -npm test -npm run build -``` +For Claude Code specifically, CLAUDE.md is loaded automatically. Start there. diff --git a/CLAUDE.md b/CLAUDE.md index b129147..7816e5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,372 +1,114 @@ +--- +title: "CLAUDE.md — Scrum4Me" +status: active +audience: [ai-agent] +language: nl +last_updated: 2026-05-03 +--- + # CLAUDE.md — Scrum4Me -Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt. +Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: product → PBI → story → taak. Zie [README.md](./README.md) voor setup. --- -## Wat is Scrum4Me? +## Orientatie -Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API. - ---- - -## Specificatiedocumenten - -Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements. - -| Document | Gebruik voor | +| Bestand | Waarvoor | |---|---| -| `docs/functional.md` | Acceptatiecriteria, randgevallen, user flows | -| `docs/architecture.md` | Stack, datamodel, Prisma schema, Zustand stores | -| `docs/backlog.md` | Welke task bouwen, volgorde, "done when"-criteria | -| `docs/personas.md` | Lars (primair), Dina, Remi — gebruik bij UI-beslissingen | -| `docs/product-backlog.md` | Historische domein-backlog (referentie); seed wordt sinds ST-004 gegenereerd uit `backlog.md` via `prisma/seed-data/parse-backlog.ts` | -| `docs/api.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls | -| `docs/styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen | -| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen | -| `docs/plans/-*.md` | Implementatieplan per milestone — Bestanden, Stappen, Aandachtspunten, Verificatie. Lees vóór je aan een ST begint. Milestone-key matcht backlog-header (`M9`, `M3.5`, `PBI-9`, …). | -| [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule | +| `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier | +| `docs/specs/functional.md` | Acceptatiecriteria, user flows | +| `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden | +| `docs/backlog/index.md` | Implementatievolgorde, "done when"-criteria | +| `docs/api/rest-contract.md` | REST API contract voor Claude Code | +| `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn | +| `docs/plans/-*.md` | Implementatieplan per milestone | --- -## Waar te beginnen +## Hoe werk vinden -Volg de backlog strikt op volgorde. Start bij **ST-001**. Sla geen milestone over. - -``` -M0 (ST-001–008) → M1 (ST-101–110) → M2 (ST-201–210) -→ M3 (ST-301–312) → M4 (ST-401–410) → M5 (ST-501–506) -→ M6 (ST-601–612) -``` - -Werken aan een task kan via twee tracks. Track A heeft de voorkeur als je in Claude Code zit; Track B is voor Codex of omgevingen zonder MCP. - -### Track A — via Claude Code MCP (aanbevolen) - -1. Roep `mcp__scrum4me__implement_next_story` aan met `product_id` (gebruik `mcp__scrum4me__list_products` als je het id niet weet) -2. De prompt orkestreert: `get_claude_context` → `log_implementation` → per task `update_task_status(in_progress)` → bouw → `update_task_status(done)` → `log_test_result` → `log_commit` -3. Bouw de tasks in volgorde van `sort_order`; lees per task de relevante pattern-doc en styling +**Track A — MCP (aanbevolen):** +1. `mcp__scrum4me__get_claude_context` → pak de next story +2. Voer taken uit in `sort_order`; update status per taak +3. Lees het relevante patroon en styling vóór je begint 4. Verifieer: `npm run lint && npm test && npm run build` -5. Commit per laag (zie Commit Strategy) +5. Commit per laag — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) -### Track B — manueel (Codex of zonder MCP) +**Track B — manueel:** +1. Lees taak in `docs/backlog/index.md` +2. Zoek spec in `docs/specs/functional.md` +3. Lees patroon + styling → bouw → verifieer → vraag bevestiging → commit -1. Lees de task in `backlog.md` -2. Zoek de bijbehorende feature-spec in `functional.md` -3. Lees het relevante patroon in `docs/patterns/` en styling in `docs/styling.md` als dat van toepassing is -4. Bouw — test — verifieer de "Done when"-criteria -5. Vraag of de code correct is -6. Commit (zie Commit Strategy hieronder) -7. Vraag of de volgende taak gedaan moet worden +Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md) --- -## Tech stack +## Hardstop regels -``` -Next.js 16 (App Router) + React 19 -TypeScript strict -Tailwind CSS + shadcn/ui -MD3 kleurensysteem via app/styles/theme.css -Zustand (client state) -dnd-kit (drag-and-drop) -Prisma v7 + PostgreSQL (Neon) -iron-session (auth cookies) -bcryptjs + Zod + Sonner -Sharp (avatarverwerking) -Vercel Analytics (@vercel/analytics/next) -``` - -> ⚠️ **Stylingregel:** Gebruik **nooit** `bg-blue-500` of willekeurige Tailwind-kleuren. -> Gebruik altijd semantische MD3-tokens: `bg-primary`, `bg-status-done`, `bg-priority-critical`. -> Zie `styling.md` voor alle patronen. - -> ⚠️ **Next.js-versie:** Lees `node_modules/next/dist/docs/` bij twijfel — API's kunnen afwijken van trainingsdata. +- **Styling:** nooit `bg-blue-500`; altijd MD3-tokens (`bg-primary`, `bg-status-done`, …) +- **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild` +- **Push:** nooit pushen zonder expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md) +- **Demo:** drie lagen — proxy.ts + server action + UI disabled knop +- **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts` +- **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token +- **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component +- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR --- -## UI Library Conventions +## Stack -- Dit project gebruikt **`@base-ui/react`**, *niet* Radix UI — ondanks dat shadcn-componenten visueel-identiek zijn -- Composition gebeurt via de **`render`-prop**, niet via Radix's `asChild`: - - ✅ `}>...` - - ❌ `` — geeft TS-errors -- Vóór je een nieuwe shadcn-/UI-primitive gebruikt: grep eerst de codebase voor bestaand gebruik en volg dat patroon (`grep -rn "PrimitiveTrigger" components/`) -- shadcn-componenten in `components/ui/` zijn dunne wrappers rond `@base-ui/react`-primitives; lees die voor de exacte prop-API +| Laag | Technologie | +|---|---| +| Framework | Next.js 16 (App Router) + React 19 | +| Taal | TypeScript strict | +| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | +| State | Zustand + dnd-kit | +| DB | Prisma v7 + PostgreSQL (Neon) | +| Auth | iron-session + bcryptjs | +| Utilities | Zod, Sonner, Sharp, Vercel Analytics | --- -## Implementatiepatronen - -Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven. +## Patterns quickref | Patroon | Bestand | |---|---| -| iron-session (auth cookies) | `docs/patterns/iron-session.md` | -| Prisma Client singleton | `docs/patterns/prisma-client.md` | -| Server Action (met auth + Zod) | `docs/patterns/server-action.md` | -| Route Handler (REST API) | `docs/patterns/route-handler.md` | -| Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` | -| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` | -| Proxy middleware (route protection) | `docs/patterns/proxy.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/task-dialog.md`) | -| **Story met UI-component (verplicht 3-task-patroon: Helper / Component / Integration)** | `docs/patterns/story-with-ui-component.md` — elke story met een `*-component.tsx` vereist een afsluitende Integration-task die de component in `page.tsx` wirt | -| 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 | - ---- - -## Integration-task verificatie (smoke-test) - -Voor stories met `*-component.tsx`: de Integration-task moet vóór -`update_job_status(done)` een smoke-test draaien op de daadwerkelijke -HTML-render: - -```bash -# In de worktree — pas ROUTE en SECTIONS aan per story -ROUTE="/insights" -SECTIONS=("Sprint Health" "Plan-quality" "Agent throughput" "Velocity" "Backlog health") - -npm run dev > /tmp/dev.log 2>&1 & -DEV_PID=$! -sleep 8 # wacht tot Next.js compiled - -curl -s http://localhost:3000${ROUTE} > /tmp/page.html - -SMOKE_FAIL= -for section in "${SECTIONS[@]}"; do - grep -q "$section" /tmp/page.html || { echo "MISSING: $section"; SMOKE_FAIL=1; } -done - -kill $DEV_PID -[ -z "$SMOKE_FAIL" ] # exit-code 1 als iets miste -``` - -Als de smoke-test faalt: pas `page.tsx` aan zodat alle secties renderen, herhaal. -Markeer Integration-task DONE pas wanneer alle verwachte sections in de HTML zitten. +| iron-session | `docs/patterns/iron-session.md` | +| Prisma singleton | `docs/patterns/prisma-client.md` | +| Server Action (auth + Zod) | `docs/patterns/server-action.md` | +| Route Handler (REST) | `docs/patterns/route-handler.md` | +| Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | +| Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | +| Proxy / route protection | `docs/patterns/proxy.md` | +| QR-pairing | `docs/patterns/qr-login.md` | +| Claude ↔ user vraagkanaal | `docs/patterns/claude-question-channel.md` | +| Entity Dialog (verplicht) | `docs/patterns/dialog.md` | --- ## Env vars ```bash -DATABASE_URL="" # postgresql://... (verplicht) -DIRECT_URL="" # postgresql://... — pooler-bypass voor LISTEN/NOTIFY (Neon/cloud) -SESSION_SECRET="" # min 32 chars; openssl rand -base64 32 -CRON_SECRET="" # M11 — Bearer-secret voor /api/cron/*; verplicht in productie, optioneel lokaal (genereer met openssl rand -base64 32) +DATABASE_URL="" # postgresql://... +DIRECT_URL="" # pooler-bypass voor LISTEN/NOTIFY +SESSION_SECRET="" # min 32 chars +CRON_SECRET="" # Bearer-secret /api/cron/* ``` -Volledige Zod-schema in `lib/env.ts`. `.env.example` is de canonieke lijst voor nieuwe checkouts. +Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`. --- -## Conventies - -- **Branches:** `feat/ST-001-scaffolding` -- **Server Actions:** altijd in `actions/[domein].ts`, nooit inline in page.tsx -- **Validatie:** altijd Zod, nooit handmatige checks -- **Toegangsmodel:** product-scoped resources gebruiken `productAccessFilter(userId)` tenzij het expliciet een eigenaarsactie is -- **Bulk-ID's:** reorder- en beslissingsacties valideren dat alle meegegeven IDs binnen dezelfde parent-scope vallen voordat er geschreven wordt -- **Foreign keys:** denormalized keys zoals `story.product_id` worden afgeleid uit de database-parent (`pbi.product_id`), nooit uit client-input -- **Demo-check (drie lagen — ST-1110):** write-acties zijn drielaags afgedekt: (1) middleware-guard in `proxy.ts` blokkeert non-GET op `/api/*` voor demo; (2) elke Server Action / Route Handler controleert `session.isDemo` vóór schrijven; (3) write-knoppen in UI zijn `disabled` met ``. Zie `docs/architecture.md#demo-user-policy` en `docs/plans/ST-1110-demo-readonly.md` -- **Foutberichten:** Nederlands voor eindgebruikers — comments in code: Engels -- **Dependencies:** elke geïmporteerde runtime package staat direct in `dependencies`, niet alleen transitief in `package-lock.json` -- **Docs-sync:** elke gedrags-, dependency-, API- of deploymentwijziging werkt README, relevante docs en patterns bij in dezelfde change -- **Entity codes:** gebruik product/PBI/story-codes in commit-titles wanneer aanwezig (`feat(ST-356.2): ...`); branchnaam blijft `feat/ST-XXX-slug` -- **Status-enums op API:** lowercase (`todo|in_progress|review|done`, `open|in_sprint|done`); DB houdt UPPER_SNAKE; conversie uitsluitend via `lib/task-status.ts`-mappers — nooit ad-hoc `.toLowerCase()` elders -- **Foutcodes API:** `400` alleen voor malformed JSON-body (parse-fout via `request.json()`); `422` voor zod-validatie en well-formed-maar-niet-acceptabel; `403` voor demo-tokens. Documenteer per endpoint in `docs/api.md` -- **Tests volgen contract:** bij een API-contract-wijziging (status, foutcode, response-shape) MOET in dezelfde commit ook `__tests__/api/` mee — een test die rood gaat omdat de oude waarde wordt verwacht is een onvolledige wijziging, niet een "kapotte test" -- **Dev port:** `npm run dev` draait altijd op **3000**. Een `predev`-hook killt vooraf elk proces op 3000 (stale Next.js dev-server, vorige sessie) zodat sessies, cookies en MCP-config consistent op één poort werken. Wijk hier niet van af — geen `-p 3001` o.i.d. tenzij je expliciet twee dev-servers naast elkaar wil draaien - ---- - -## Branch & PR Strategy (STRICT — kostenbeheersing) - -> **Core rule: één branch per milestone, PR alleen na gebruikerstest** - -Elke `git push` naar een feature-branch triggert een Vercel preview-deployment. Op het huidige Hobby-account zijn die schaars en kosten geld; we minimaliseren preview-builds tot er werkelijk iets te reviewen valt. - -### Wel doen - -- Eén branch voor de hele milestone — `feat/M{N}-{slug}` (bv. `feat/M10-qr-login`); voor losse stories zonder milestone blijft `feat/ST-XXX-{slug}` geldig -- Commits accumuleren lokaal volgens de Commit Strategy hieronder — één commit per stap, ST-code in de titel -- Pushen + PR openen **pas nadat de gebruiker de milestone handmatig heeft getest en goedgekeurd** — vraag expliciet om bevestiging vóór `git push` -- Tussentijdse "klaar voor jouw test"-momenten markeren met een lokale tag of een berichtje in chat, niet met een push - -### Niet doen - -- Pushen na elke story of commit -- Een PR per story openen tijdens de implementatie -- "Just-in-case" pushen om backup te hebben — gebruik `git stash`, een lokale tag, of meerdere lokale branches -- `--force-push` om eerdere preview-builds "weg te toveren" (kost dezelfde build opnieuw bij hercreatie) -- **Direct pushen naar `main`** — die branch heeft protection rules; gebruik altijd een PR - -### Wanneer wel commit-zonder-vragen, wanneer niet - -- **Tijdens een directed sprint-flow** (Track A: `mcp__scrum4me__implement_next_story` of een expliciete *"implementeer M{N}"*-opdracht): commit-per-laag conform de Commit Strategy hieronder is impliciet geautoriseerd — niet per commit vragen -- **Bij ad-hoc / out-of-band werk** (bug-fix tussendoor, refactor, kleine wijziging op verzoek): toon de diff + voorgestelde commit-message en wacht op `"commit it"` voordat je `git commit` draait -- **`git push` is altijd expliciet** — de scope van de policy gaat over preview-builds, dus push gebeurt alleen na gebruiker-test, ongeacht commit-context - -### Uitzonderingen op de push-regel - -- Een **planning-PR** zonder code-wijzigingen (alleen docs in `docs/plans/` of `docs/`) mag direct gepusht worden — die triggert geen functional regressie en is goedkoop te bouwen -- Een **bugfix-hotfix** op `main` met aantoonbare productie-impact mag direct gepusht worden (via een PR — zie boven) - -### Wanneer aanpassen - -Zodra het Vercel-account naar Pro (of andere omgeving zonder per-build-kosten) gaat: vervang deze regel door "branch + PR per story" zoals oorspronkelijk in dit document stond. Werk deze sectie bij én documenteer de wijziging in `docs/agent-instruction-audit.md`. - ---- - -## Plan Mode - -- Voor simpele, goed-afgebakende file-edits: **niet** in plan mode gaan — gewoon de wijziging maken -- Reserveer plan mode voor multi-step refactors, ambigue verzoeken, of milestone-planning waarbij design-keuzes vooraf bevestigd moeten worden -- Plannen die uit plan mode komen: opslaan als `docs/plans/M{N}-{slug}.md` (zie memory `feedback_plan_location`), niet als ephemeral systeem-bestand - ---- - -## Commit Strategy (STRICT) - -> **Core rule: één commit = één verantwoordelijkheid** - -### Nooit doen - -- Database + API + UI in één commit mengen -- Feature + documentatie combineren -- Grote "alles gewijzigd" commits -- Vage berichten zoals "update stuff" - -### Verplichte structuur - -Splits werk op in logische lagen: - -1. Database / Prisma -2. API / server actions -3. UI / components -4. Config / infra -5. Documentatie - -### Commit-formaat - -``` -feat(ST-XXX): korte beschrijving -fix(ST-XXX): korte beschrijving -chore(ST-XXX): korte beschrijving -docs(ST-XXX): korte beschrijving -``` - -### Voorbeeld (verplicht patroon) - -In plaats van: - -```bash -feat: add profile system -``` - -Splits altijd op in: - -```bash -feat(ST-XXX): add user profile fields to Prisma schema -feat(ST-XXX): add avatar upload endpoint -feat(ST-XXX): add profile editor component -chore(ST-XXX): configure sharp for avatar processing -docs(ST-XXX): document profile feature -``` - ---- - - - ## Scrum-terminologie -| Correct | Niet gebruiken | -|---|---| -| Product Backlog Item (PBI) | Feature, Epic, Issue | -| Story | User Story, Ticket | -| Sprint Goal | Sprint Objective | -| Scrum Team | Team | +PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective) --- -## MCP-integratie +## Verificatie -Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd. - -### Tools beschikbaar in Claude Code (18) - -**Read / context:** -- `mcp__scrum4me__health` — service + DB ping -- `mcp__scrum4me__list_products` — producten waar de tokengebruiker toegang tot heeft -- `mcp__scrum4me__get_claude_context` — bundled product / actieve sprint / next story (met tasks) / open todos - -**Authoring (PBI/Story/Task aanmaken):** -- `mcp__scrum4me__create_pbi` — `{ product_id, title, description?, priority, sort_order? }`; auto sort_order = last+1 binnen prio-groep -- `mcp__scrum4me__create_story` — `{ pbi_id, title, description?, acceptance_criteria?, priority, sort_order? }`; product_id afgeleid uit PBI; status=OPEN -- `mcp__scrum4me__create_task` — `{ story_id, title, description?, implementation_plan?, priority, sort_order? }`; sprint_id geërfd van story; status=TO_DO -- `mcp__scrum4me__create_todo` — losse todo (optioneel product-scoped) - -**Task / story writes:** -- `mcp__scrum4me__update_task_status`, `mcp__scrum4me__update_task_plan` -- `mcp__scrum4me__log_implementation`, `mcp__scrum4me__log_test_result`, `mcp__scrum4me__log_commit` - -**Vraag-antwoord-kanaal (M11):** -- `mcp__scrum4me__ask_user_question` — post een vraag over een story; optionele `wait_seconds` (max 600) polt voor het antwoord -- `mcp__scrum4me__get_question_answer` — huidige status + antwoord (voor latere session-pickup) -- `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst -- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag - -**Job queue — agent worker mode (M13):** -- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg. -- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. - -**Batch-loop (verplichte agent-flow):** - -Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop: - -1. `wait_for_job` aanroepen. -2. Job uitvoeren volgens het meegegeven `implementation_plan`. -3. `update_job_status('done'|'failed')` aanroepen. -4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen. -5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap. - -Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. - -**Code koppelen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`. - - -### Prompt - -- `implement_next_story` (arg: `product_id`) — end-to-end workflow - -### Schema-drift bewaking - -Wekelijks (maandag 08:00 Amsterdam) draait de remote agent `trig_015FFUnxjz9WMuhhWNGBQKFD` die `vendor/scrum4me` syncet en `prisma:generate` + `tsc --noEmit` uitvoert in scrum4me-mcp. Als die agent drift rapporteert, hoort dat **vóór** een Scrum4Me-PR met schema-wijziging gemerged kan worden — anders breekt de MCP-server stilletjes op runtime. - ---- - -## Deployment (Vercel) - -- **Sharp** moet Linux-binaries hebben voor de Vercel-runtime: `npm i --include=optional sharp` of platform-specifieke deps configureren in `package.json` -- **Externe image hostnames** in `next.config.js` `images.remotePatterns` configureren *vóór* `next/image` op die hosts wijst — anders 500 in productie -- **Vercel cron**: Hobby-plan staat alleen daily crons toe (max 1×/dag); Pro ondersteunt fijnmaziger. Bij wijziging van `vercel.json` `crons` ook `docs/api.md` + relevante pattern-docs updaten -- **`CRON_SECRET`** moet als env-var op de Vercel-project-omgeving staan vóór de eerste cron-run, anders 401 op `/api/cron/*`-endpoints -- **Preflight** vóór deploy: `npm run lint && npm test && npm run build` — falende build laat een PR niet door (CI blokkeert merge per ST-610) - ---- - -## Definition of Done (MVP) - -M7 (MCP-server) is post-MVP en heeft eigen acceptatie in `docs/backlog.md`. - -- [ ] Alle 62 tasks (ST-001 t/m ST-612) afgerond -- [ ] Volledige Lars-flow zonder fouten (ST-612) -- [ ] Alle gedocumenteerde API-endpoints werken via curl (zie `docs/api.md`) -- [ ] Demo-gebruiker heeft geen schrijfrechten -- [ ] App opzetbaar via README zonder extra hulp -- [ ] CI/CD actief — falende build blokkeert merge -- [ ] Beveiligingsreview API geslaagd (cross-user toegang onmogelijk) -- [ ] Documentatie is bijgewerkt voor gewijzigde API's, dependencies, deployment en agent-instructies +```bash +npm run lint && npm test && npm run build +``` diff --git a/README.md b/README.md index a24dc15..1f2da30 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ Scrum4Me biedt een lichtgewicht, web-based oplossing voor het beheren van sprint - Vercel hosting - GitHub Actions / CI-CD +## Documentation + +- [docs/INDEX.md](docs/INDEX.md) — generated index of all docs (front-matter driven) +- [docs/glossary.md](docs/glossary.md) — domain terms (PBI, Story, MCP-job, etc.) +- [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — agent instructions + ## Architectuur (kort) - Frontend en backend via Next.js App Router @@ -122,7 +128,7 @@ npx prisma db push npm run db:erd ``` -Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/erd.svg` opnieuw opgebouwd. +Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/assets/erd.svg` opnieuw opgebouwd. In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd. @@ -155,11 +161,11 @@ Verwacht: alle 69 tests slagen, 0 failures. bash scripts/test-api.sh ``` -De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/test-plan.md` voor het volledige testplan. +De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/qa/api-test-plan.md` voor het volledige testplan. ## Database -![ERD](./docs/erd.svg) +![ERD](./docs/assets/erd.svg) De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`. @@ -169,7 +175,7 @@ Handmatige generatie: npm run db:erd ``` -Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/erd.svg` automatisch opnieuw gegenereerd. +Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/assets/erd.svg` automatisch opnieuw gegenereerd. Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`. @@ -182,7 +188,7 @@ npm run dev # lokale development server npm run lint # ESLint npm test # Vitest test suite npm run build # productiebuild zoals Vercel die verwacht -npm run db:erd # Prisma Client + docs/erd.svg genereren +npm run db:erd # Prisma Client + docs/assets/erd.svg genereren ``` ### Environment variables @@ -279,7 +285,7 @@ De productieomgeving is gericht op Vercel + Neon. ### Documentatie -- [Functionele specificatie](docs/functional.md) +- [Functionele specificatie](docs/specs/functional.md) - [Technische architectuur](docs/architecture.md) -- [Backlog](docs/backlog.md) -- [Agent-instructie audit](docs/agent-instruction-audit.md) +- [Backlog](docs/backlog/index.md) +- [Agent-instructie audit](docs/decisions/agent-instructions-history.md) diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..54ab159 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,107 @@ + + +# Documentation Index + +Auto-generated on 2026-05-03 from front-matter and headings. + +## Architecture Decision Records + +| # | Title | Status | +|---|---|---| +| 0000 | [ADR-0000: Record architecture decisions](./adr/0000-record-architecture-decisions.md) | accepted | +| 0001 | [ADR-0001: Use @base-ui/react instead of Radix UI](./adr/0001-base-ui-over-radix.md) | accepted | +| 0002 | [ADR-0002: Use float sort_order for drag-and-drop ordering](./adr/0002-float-sort-order.md) | accepted | +| 0003 | [ADR-0003: One branch per milestone, push only after user test](./adr/0003-one-branch-per-milestone.md) | accepted | +| 0004 | [ADR-0004: DB enums UPPER_SNAKE, API enums lowercase, mapped exclusively via lib/task-status.ts](./adr/0004-status-enum-mapping.md) | accepted | +| 0005 | [ADR-0005: Use iron-session for authentication instead of NextAuth/Clerk/Supabase Auth](./adr/0005-iron-session-over-nextauth.md) | accepted | +| 0006 | [ADR-0006: Demo-user write protection enforced in three layers](./adr/0006-demo-user-three-layer-policy.md) | accepted | +| 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted | +| 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted | +| 0009 | [ADR-0009: Three-phase agent pipeline for feature ideation → plan → implementation](./adr/0009-three-phase-feature-pipeline.md) | proposed | + +## Specifications + +| Title | Status | Updated | +|---|---|---| +| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-03 | +| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 | +| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 | +| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 | +| [DevPlanner — User Personas](./specs/personas.md) | active | 2026-05-03 | + +## Plans + +| Title | Status | Updated | +|---|---|---| +| [Docs-restructuur — geoptimaliseerd voor AI-lookup](./plans/docs-restructure-ai-lookup.md) | proposal | 2026-05-02 | +| [PBI Bulk-Create Spec — Docs-Restructure for AI-Optimized Lookup](./plans/docs-restructure-pbi-spec.md) | — | — | +| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 | +| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 | +| [M12 — Drie-fase agent-pipeline voor feature-ideatie](./plans/M12-three-phase-feature-pipeline.md) | proposal | 2026-05-03 | +| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | +| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | +| [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 | +| [ST-1111 — Voer uit-knop met Claude Code job queue](./plans/ST-1111-claude-job-trigger.md) | active | 2026-05-03 | +| [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 | +| [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | + +### Archive + +| Title | Updated | +|---|---| +| [CLAUDE.md workflow-update na M7 + ST-509/511/512/513](./plans/archive/2026-04-27-claude-md-workflow-update.md) | 2026-05-03 | +| [Herbruikbaar scripts/insert-milestone.ts](./plans/archive/2026-04-27-insert-milestone-tool.md) | 2026-05-03 | +| [Realtime updates voor Solo Paneel (M8)](./plans/archive/2026-04-27-m8-realtime-solo.md) | 2026-05-03 | + +## Patterns + +| Title | Status | Updated | +|---|---|---| +| [Bidirectionele async-comms MCP-agent ↔ user](./patterns/claude-question-channel.md) | active | 2026-05-03 | +| [Entity Dialog](./patterns/dialog.md) | active | 2026-05-03 | +| [iron-session](./patterns/iron-session.md) | active | 2026-05-03 | +| [Prisma Client singleton](./patterns/prisma-client.md) | active | 2026-05-03 | +| [Proxy (route protection)](./patterns/proxy.md) | active | 2026-05-03 | +| [QR-pairing via unauth-SSE + pre-auth cookie](./patterns/qr-login.md) | active | 2026-05-03 | +| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-03 | +| [Server Action](./patterns/server-action.md) | active | 2026-05-03 | +| [Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.md) | active | 2026-05-03 | +| [Patroon: Story met UI-component](./patterns/story-with-ui-component.md) | — | — | +| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 | + +## Other Docs + +| Title | Path | Status | Updated | +|---|---|---|---| +| [Scrum4Me REST API](./api.md) | `api.md` | active | 2026-05-03 | +| [Scrum4Me REST API](./api/rest-contract.md) | `api/rest-contract.md` | active | 2026-05-03 | +| [route-handlers](./app/getting-started/route-handlers.md) | `app/getting-started/route-handlers.md` | — | — | +| [Scrum4Me — Technische Architectuur (breadcrumb)](./architecture.md) | `architecture.md` | active | 2026-05-03 | +| [Authentication, Sessions & Demo Policy](./architecture/auth-and-sessions.md) | `architecture/auth-and-sessions.md` | active | 2026-05-03 | +| [Claude ↔ User Question Channel](./architecture/claude-question-channel.md) | `architecture/claude-question-channel.md` | active | 2026-05-03 | +| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-03 | +| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-03 | +| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-03 | +| [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 | +| [Scrum4Me — Implementatie Backlog](./backlog.md) | `backlog.md` | active | 2026-05-03 | +| [Scrum4Me — Implementatie Backlog](./backlog/index.md) | `backlog/index.md` | active | 2026-05-03 | +| [DevPlanner — Product Backlog](./backlog/product-historical.md) | `backlog/product-historical.md` | active | 2026-05-03 | +| [Agent Instruction Audit](./decisions/agent-instructions-history.md) | `decisions/agent-instructions-history.md` | active | 2026-05-03 | +| [Scrum4Me — Styling & Design System](./design/styling.md) | `design/styling.md` | active | 2026-05-03 | +| [Docker smoke test — task 1](./docker-smoke/2-mei-task-1.md) | `docker-smoke/2-mei-task-1.md` | — | — | +| [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | — | — | +| [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 | +| [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-03 | +| [Scrum4Me — Styling & Design System](./md3-color-scheme.md) | `md3-color-scheme.md` | active | 2026-05-03 | +| [Obsidian as Personal Authoring Layer](./obsidian-authoring.md) | `obsidian-authoring.md` | active | 2026-05-02 | +| [PbiDialog Profiel](./pbi-dialog.md) | `pbi-dialog.md` | — | — | +| [DevPlanner — User Personas](./personas.md) | `personas.md` | active | 2026-05-03 | +| [DevPlanner — Product Backlog](./product-backlog.md) | `product-backlog.md` | active | 2026-05-03 | +| [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | +| [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | — | — | +| [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | +| [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 | +| [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-03 | +| [StoryDialog Profiel](./story-dialog.md) | `story-dialog.md` | — | — | +| [TaskDialog Profiel](./task-dialog.md) | `task-dialog.md` | — | — | +| [Scrum4Me — API Test Plan](./test-plan.md) | `test-plan.md` | active | 2026-05-03 | diff --git a/docs/adr/0000-record-architecture-decisions.md b/docs/adr/0000-record-architecture-decisions.md new file mode 100644 index 0000000..12e6dd7 --- /dev/null +++ b/docs/adr/0000-record-architecture-decisions.md @@ -0,0 +1,66 @@ +# ADR-0000: Record architecture decisions + +## Status + +accepted + +## Context + +Scrum4Me makes several non-obvious architectural choices that aren't visible +from the code alone — for example, why we use `@base-ui/react` rather than +Radix, why drag-and-drop ordering uses float `sort_order` instead of integer +positions, why authentication runs on iron-session rather than NextAuth, +and why the demo-user policy is enforced in three layers. These decisions +are scattered across `CLAUDE.md`, individual pattern docs, plan files, and +commit messages. New contributors and AI agents working on the codebase +have no fast path to the *why*, which leads to one of two failure modes: +they either re-litigate decisions that were already settled, or they make +changes that violate constraints they didn't know about. + +We want a single, predictable place to record significant architectural +choices, with enough context that a reader six months from now can decide +whether the decision still holds. + +## Decision + +We adopt Architecture Decision Records (ADRs) as the canonical format for +documenting significant architectural choices in this codebase. ADRs live +in `docs/adr/`, are numbered sequentially with a four-digit prefix +(`0001-...md`, `0002-...md`, …), and follow one of two templates: + +- **Nygard** ([`templates/nygard.md`](./templates/nygard.md)) — default, + for one-way-door decisions with a clear motivating context. +- **MADR v4** ([`templates/madr.md`](./templates/madr.md)) — for + decisions where weighing multiple alternatives is part of the value + the record provides (auth, queue mechanics, agent integration). + +The full conventions — file naming, status lifecycle, template selection +guidance — are documented in [`README.md`](./README.md). + +ADRs are immutable once accepted: course corrections create a new ADR +that supersedes the old one rather than editing the original. + +## Consequences + +### Positive + +- Architectural choices have a single, predictable home that an AI agent + or new contributor can find with one `ls docs/adr/`. +- The "why" of each decision is captured at the moment it's made, when + the context is fresh, rather than reconstructed later from commits. +- Superseded decisions remain readable, so future contributors can see the + history of a choice without git archaeology. +- The format scales: writing an ADR is a 15-minute activity for the + default Nygard template, low enough overhead to be worth doing every + time. + +### Negative + +- Adds a small ritual to every significant architectural decision — easy + to skip when moving fast, leading to a stale or incomplete record if + not enforced through review. +- Backfilling existing decisions requires writing 5–8 retrospective ADRs + for choices that were never recorded (planned in fase 6 of + [`../plans/docs-restructure-ai-lookup.md`](../plans/docs-restructure-ai-lookup.md)). +- Two templates means a per-decision choice about which to use. Mitigated + by making Nygard the explicit default in `README.md`. diff --git a/docs/adr/0001-base-ui-over-radix.md b/docs/adr/0001-base-ui-over-radix.md new file mode 100644 index 0000000..def03fa --- /dev/null +++ b/docs/adr/0001-base-ui-over-radix.md @@ -0,0 +1,34 @@ +# ADR-0001: Use @base-ui/react instead of Radix UI + +## Status + +accepted + +## Context + +shadcn/ui ships visual components that are typically built on Radix UI primitives. When we bootstrapped Scrum4Me with shadcn, the component wrappers in `components/ui/` were adapted to use `@base-ui/react` instead of the Radix packages. `@base-ui/react` exposes the same accessibility primitives but uses a `render` prop for composition instead of Radix's `asChild` pattern. Attempting to use `asChild` in our TypeScript-strict setup produced type errors because the prop is not declared in `@base-ui/react`'s API surface. + +## Decision + +We use `@base-ui/react` exclusively. No Radix UI package (`@radix-ui/*`) is imported anywhere in the codebase. Composition always uses the `render` prop: + +```tsx +// ✅ correct +}>… + +// ❌ wrong — asChild does not exist on @base-ui/react primitives + +``` + +## Consequences + +### Positive + +- TypeScript stays clean; no `any` casts or `asChild` workarounds. +- `@base-ui/react` is actively maintained by the MUI team with React 19 support. +- Composition pattern is explicit and grep-friendly. + +### Negative + +- AI agents trained on Radix-based shadcn documentation will default to `asChild` — they must be reminded of the `render`-prop pattern (this ADR exists for that reason). +- shadcn CLI-generated components may need manual adjustment when installed. diff --git a/docs/adr/0002-float-sort-order.md b/docs/adr/0002-float-sort-order.md new file mode 100644 index 0000000..775259e --- /dev/null +++ b/docs/adr/0002-float-sort-order.md @@ -0,0 +1,26 @@ +# ADR-0002: Use float sort_order for drag-and-drop ordering + +## Status + +accepted + +## Context + +The planning screens (PBI list, Story list, Task list, Solo board) all support drag-and-drop reordering. With integer positions, inserting an item between positions 3 and 4 requires renumbering every subsequent row — an O(N) write for every drag operation. At the scale of a sprint board this is tolerable, but it causes unnecessary lock contention and makes optimistic UI rollback harder. See `docs/patterns/sort-order.md` for the full implementation pattern. + +## Decision + +Every ordered collection uses a `Float` column named `sort_order`. Inserting between two items sets `sort_order = (prev + next) / 2`. New items appended to the end get `last + 1.0`. + +## Consequences + +### Positive + +- Reorder writes are O(1) — only the moved item's row is updated. +- Optimistic UI updates map directly to the same midpoint calculation. +- No lock contention on adjacent rows during concurrent drags. + +### Negative + +- Repeated insertions between the same two items cause float precision drift. Mitigation: a periodic compaction job normalizes `sort_order` values to integers × 1000 when the gap drops below `0.001`. This is a known trade-off; compaction has not been needed in practice yet. +- Queries that return items in `sort_order` order must always include `ORDER BY sort_order` — there is no implicit ordering. diff --git a/docs/adr/0003-one-branch-per-milestone.md b/docs/adr/0003-one-branch-per-milestone.md new file mode 100644 index 0000000..c8f6772 --- /dev/null +++ b/docs/adr/0003-one-branch-per-milestone.md @@ -0,0 +1,61 @@ +--- +status: accepted +date: 2026-05-03 +decision-makers: [janpetervisser] +--- + +# ADR-0003: One branch per milestone, push only after user test + +## Context and Problem Statement + +Every `git push` to a feature branch triggers a Vercel preview deployment. On the Hobby plan, preview builds are limited and cost money. How should we structure branches and pushes to minimize preview-build spend while still supporting a fast AI-driven development loop? + +## Decision Drivers + +- Vercel Hobby plan: preview builds are finite and billed per deployment. +- Small team (primarily solo developer + AI agent): branch overhead should be minimal. +- AI-driven flow: the agent commits frequently in small logical layers; we don't want a push per commit. +- User acceptance is done interactively per milestone, not per story. + +## Considered Options + +- **Branch per story** — one branch per story, PR per story. +- **Branch per milestone** — one branch for all stories in a milestone, single PR after user test. +- **Trunk-based development** — commit directly to `main` with feature flags. + +## Decision Outcome + +Chosen option: **Branch per milestone**, because it is the only option that keeps preview-build count proportional to milestones (not stories), while still enabling isolated review via a single PR. + +### Consequences + +- Good, because preview deployments are rare — only one per milestone reaching review. +- Good, because PR history maps to milestones, not micro-stories. +- Bad, because branches live longer; merge conflicts are larger but less frequent. +- Bad, because a single failed story blocks the milestone PR. + +### Confirmation + +Before pushing, the developer/agent must confirm explicitly. `git push` is never automated. See `docs/runbooks/branch-and-commit.md`. + +## Pros and Cons of the Options + +### Branch per story + +- Good, because small, focused PRs are easy to review. +- Bad, because each push triggers a preview build — N stories = N builds per milestone. + +### Branch per milestone + +- Good, because minimal preview builds. +- Good, because the PR represents a coherent feature set. +- Bad, because long-lived branches. + +### Trunk-based development + +- Good, because no branch management overhead. +- Bad, because requires feature flags to hide incomplete work — too much infrastructure for this scale. + +## More Information + +Revisit this decision if/when the Vercel account upgrades to Pro (unlimited preview builds). At that point, branch-per-story is the preferred default. Update `docs/runbooks/branch-and-commit.md` and this ADR when that happens. diff --git a/docs/adr/0004-status-enum-mapping.md b/docs/adr/0004-status-enum-mapping.md new file mode 100644 index 0000000..541adbb --- /dev/null +++ b/docs/adr/0004-status-enum-mapping.md @@ -0,0 +1,28 @@ +# ADR-0004: DB enums UPPER_SNAKE, API enums lowercase, mapped exclusively via lib/task-status.ts + +## Status + +accepted + +## Context + +Prisma generates TypeScript types from PostgreSQL enum values verbatim. Our DB enums use `UPPER_SNAKE_CASE` (e.g. `TO_DO`, `IN_PROGRESS`, `DONE`) because that is the PostgreSQL convention and it keeps Prisma-generated code readable. However, REST API consumers — including Claude Code via MCP and frontend fetch calls — expect lowercase, underscore-separated values (`todo`, `in_progress`, `done`). Without a single conversion boundary, ad-hoc `.toLowerCase()` calls scattered across route handlers and actions introduce silent mapping bugs when enum values change. + +## Decision + +- The database retains `UPPER_SNAKE_CASE` enum values. Prisma schema is the source of truth. +- The REST API (route handlers) and MCP server always expose and accept **lowercase** enum strings. +- All conversion happens exclusively in `lib/task-status.ts` via named mapper functions. No `.toLowerCase()`, `.toUpperCase()`, or inline string mapping anywhere else. + +## Consequences + +### Positive + +- A single file to audit when enum values change. +- TypeScript types catch missing branches in mapper exhaustive checks. +- API contract is stable and grep-friendly. + +### Negative + +- Every developer (and AI agent) must know to use the mappers rather than string coercion. Violations compile fine but break the API contract at runtime. +- Mitigated by an ESLint rule that flags direct `.toLowerCase()` on known enum types (pending implementation). diff --git a/docs/adr/0005-iron-session-over-nextauth.md b/docs/adr/0005-iron-session-over-nextauth.md new file mode 100644 index 0000000..42c69fb --- /dev/null +++ b/docs/adr/0005-iron-session-over-nextauth.md @@ -0,0 +1,71 @@ +--- +status: accepted +date: 2026-05-03 +decision-makers: [janpetervisser] +--- + +# ADR-0005: Use iron-session for authentication instead of NextAuth/Clerk/Supabase Auth + +## Context and Problem Statement + +Scrum4Me requires username/password login without email verification, a synchronous demo-user check on every request, and full control over the session cookie shape (including an `isDemo` flag). Which authentication solution fits these constraints at minimal complexity? + +## Decision Drivers + +- No email required — username/password only. +- Demo-user policy (ADR-0006) requires a synchronous `isDemo` check in both middleware and server actions. +- No third-party redirect chain — auth must stay in-process. +- Solo-developer project: minimal external dependencies preferred. + +## Considered Options + +- **NextAuth / Auth.js v5** +- **Clerk** +- **Supabase Auth** +- **iron-session + bcryptjs** + +## Decision Outcome + +Chosen option: **iron-session + bcryptjs**, because it is the only option that gives us full control over cookie contents, has zero external redirect dependency, and lets us embed `isDemo` directly in the session payload. + +### Consequences + +- Good, because session structure is fully controlled — we add any field we need. +- Good, because no external service dependency for auth; works offline and in CI. +- Good, because synchronous cookie read in `proxy.ts` middleware is trivial. +- Bad, because we own the password hashing, session rotation, and CSRF protection. +- Bad, because no OAuth/social login without building it ourselves. + +### Confirmation + +`lib/session.ts` defines the session type. `docs/patterns/iron-session.md` documents the pattern. Any new field on the session object must be added to the type there. + +## Pros and Cons of the Options + +### NextAuth / Auth.js v5 + +- Good, because OAuth, email magic links, and credentials all in one library. +- Bad, because credentials provider is discouraged in v5; session shape is opaque. +- Bad, because adding `isDemo` to the JWT requires custom callbacks. + +### Clerk + +- Good, because fully managed, beautiful UI, no session code to maintain. +- Bad, because requires third-party redirect; adds external dependency. +- Bad, because demo-user policy would require custom session metadata sync. + +### Supabase Auth + +- Good, because integrates with Supabase storage (but we use Neon). +- Bad, because username/password without email is not the primary use case. +- Bad, because adds a second database dependency just for auth. + +### iron-session + bcryptjs + +- Good, because minimal, explicit, and TypeScript-native. +- Good, because session payload is a plain object we fully control. +- Neutral, because we write our own password logic (bcrypt makes it safe). + +## More Information + +See `docs/patterns/iron-session.md` for implementation details. Revisit if multi-tenant or SSO requirements emerge. diff --git a/docs/adr/0006-demo-user-three-layer-policy.md b/docs/adr/0006-demo-user-three-layer-policy.md new file mode 100644 index 0000000..cbcbc85 --- /dev/null +++ b/docs/adr/0006-demo-user-three-layer-policy.md @@ -0,0 +1,30 @@ +# ADR-0006: Demo-user write protection enforced in three layers + +## Status + +accepted + +## Context + +Scrum4Me has a demo account that allows prospective users to explore the app without signing up. The demo user must never be able to create, update, or delete any data. A single guard at one layer is insufficient: a bug or a missing check in any one layer would expose a write path. See `docs/architecture/auth-and-sessions.md` and `docs/plans/ST-1110-demo-readonly.md` for implementation details. + +## Decision + +Write protection for the demo user is enforced at **three independent layers**: + +1. **Network — `proxy.ts`:** The Next.js proxy middleware rejects all non-GET requests from demo sessions before they reach any route handler or server action. +2. **Server — every Server Action and Route Handler:** Each write endpoint checks `session.isDemo` and returns `403` immediately if true. +3. **UI — disabled buttons + ``:** Write controls (create, edit, delete, reorder) are rendered as `disabled` with a tooltip explaining the demo restriction. No write request is ever sent. + +## Consequences + +### Positive + +- Defense-in-depth: any single layer can fail independently without exposing a write path. +- Clear user feedback at the UI layer without relying on error responses. +- Straightforward to audit: search for `isDemo` to find all enforcement points. + +### Negative + +- Three enforcement sites for every new write operation — easy to miss one when adding a new feature. +- Mitigation: the `DemoTooltip` pattern is documented in `docs/patterns/` and enforced in code review. diff --git a/docs/adr/0007-claude-question-channel-design.md b/docs/adr/0007-claude-question-channel-design.md new file mode 100644 index 0000000..5e77f7c --- /dev/null +++ b/docs/adr/0007-claude-question-channel-design.md @@ -0,0 +1,64 @@ +--- +status: accepted +date: 2026-05-03 +decision-makers: [janpetervisser] +--- + +# ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY + +## Context and Problem Statement + +When Claude Code is executing a task and needs human input, it must be able to pause, post a question, and receive an answer — potentially across separate sessions. The app must notify an active user that a question is waiting. How should this async communication channel be designed? + +## Decision Drivers + +- Questions must survive agent session restarts (persistent, not in-memory). +- The app user needs a real-time notification without polling from the client. +- The infrastructure already includes PostgreSQL with LISTEN/NOTIFY (used for M8 realtime updates). +- Answers must be readable by the agent in a future session without the original connection. + +## Considered Options + +- **Synchronous polling only** — agent polls an endpoint every N seconds. +- **Push via SSE without persistence** — agent opens SSE connection, user pushes answer over it. +- **Persistent `claude_questions` table + PostgreSQL LISTEN/NOTIFY** + +## Decision Outcome + +Chosen option: **Persistent table + LISTEN/NOTIFY**, because it is the only option that survives session restarts on both ends and reuses existing infrastructure. + +### Consequences + +- Good, because questions survive agent and user session restarts. +- Good, because reuses the `scrum4me_changes` LISTEN/NOTIFY channel already in place. +- Good, because any product member with access can answer, not just the original session. +- Bad, because adds a `claude_questions` table and trigger to the schema. +- Bad, because LISTEN/NOTIFY requires a persistent DB connection (`DIRECT_URL` env var). + +### Confirmation + +`docs/patterns/claude-question-channel.md` documents the full implementation. MCP tools: `ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`. + +## Pros and Cons of the Options + +### Synchronous polling only + +- Good, because simple — no extra infrastructure. +- Bad, because agent blocks a CPU slot while polling; question lost if agent restarts. +- Bad, because no real-time notification to the user. + +### Push via SSE without persistence + +- Good, because low latency. +- Bad, because agent-side SSE connection is fragile across restarts. +- Bad, because no persistence — if the user isn't connected at the moment the question is posted, it is lost. + +### Persistent table + LISTEN/NOTIFY + +- Good, because fully durable. +- Good, because real-time notification to the user reuses existing M8 infrastructure. +- Neutral, because requires `DIRECT_URL` for a persistent PostgreSQL connection (already required for M8). + +## More Information + +See `docs/plans/M11-claude-questions.md` and `docs/patterns/claude-question-channel.md`. diff --git a/docs/adr/0008-agent-instructions-in-claude-md.md b/docs/adr/0008-agent-instructions-in-claude-md.md new file mode 100644 index 0000000..904d09a --- /dev/null +++ b/docs/adr/0008-agent-instructions-in-claude-md.md @@ -0,0 +1,41 @@ +--- +title: "ADR-0008: Agent instructions in CLAUDE.md + topical runbooks" +status: accepted +date: 2026-05-03 +--- + +# ADR-0008: Agent instructions in CLAUDE.md + topical runbooks + +## Status + +Accepted + +## Context + +Claude Code reads `CLAUDE.md` at session start and injects it verbatim into the system prompt. As the project grew, the file expanded to ~350 lines covering stack, patterns, MCP tools, commit strategy, branch policy, Vercel deployment, and demo-user rules. At that length the token cost was significant and the file was hard to navigate — both for humans and for AI agents skimming for a specific rule. + +A separate `docs/agent-instruction-audit.md` tracked *why* each rule existed, but that meta-history was buried and rarely consulted. The result: rules were added but never removed, and the file drifted toward being a reference dump rather than a decision-forcing instrument. + +## Decision + +Split `CLAUDE.md` into a short ≤150-line orientation hub that hard-links to topical runbooks: + +- `docs/runbooks/branch-and-commit.md` — branch policy, plan mode, commit strategy +- `docs/runbooks/mcp-integration.md` — all 18 MCP tools + batch-loop invariants +- `docs/runbooks/deploy-vercel.md` — Sharp, cron, env-var, preflight rules + +`CLAUDE.md` retains only the rules an agent **must** have in active context on every turn: the orientation table, the 8 hardstop rules, stack table, patterns quickref, and env vars. Everything else is a hyperlink. + +`AGENTS.md` (Codex entry-point) becomes a 10-line redirect stub pointing at `CLAUDE.md`. + +## Consequences + +**Positive** +- Active-context token cost drops ~60 % — the hub is ~114 lines vs 350. +- Each runbook is standalone and audience-tagged (`audience: [ai-agent]` etc.), so a future agent can fetch exactly the doc it needs. +- The split gives a natural place to add rules without polluting the root file. +- `docs/decisions/agent-instructions-history.md` (renamed from `agent-instruction-audit.md`) documents the *why* behind each rule as persistent institutional memory. + +**Negative** +- An agent that reads only `CLAUDE.md` will miss branch/MCP/deploy rules unless it follows the links. Mitigation: critical one-liners (e.g., "PR only after user test") are kept as hardstops in the hub. +- The split increases the number of files to maintain; runbooks can drift from `CLAUDE.md` if authors forget to update both. Mitigation: docs-sync convention in `CLAUDE.md` Conventions section. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..44b3b71 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,101 @@ +--- +title: Architecture Decision Records +status: active +audience: ai-agent, maintainer, contributor +language: en +last_updated: 2026-05-02 +--- + +# Architecture Decision Records + +This directory contains the Architecture Decision Records (ADRs) for Scrum4Me. + +## What is an ADR + +An ADR is a short document that captures a single significant architectural +decision: the context that forced the decision, the choice we made, and the +consequences of that choice. ADRs are immutable once accepted — if a later +decision changes course, we write a new ADR that supersedes the old one. + +We use ADRs because the project mixes several non-obvious choices (Next.js 16 +specifics, `@base-ui/react` over Radix, float `sort_order` for drag-and-drop, +iron-session over NextAuth, demo-user three-layer policy, MCP integration +patterns) and an AI agent reading the codebase six months from now needs to +find the *why* without spelunking through commit history. + +## File naming + +``` +NNNN-kebab-case-title.md +``` + +- `NNNN` — four-digit zero-padded sequential number, starting at `0001` + (`0000` is reserved for the meta-ADR that introduces the practice). +- `kebab-case-title` — lowercase, hyphen-separated, short noun phrase + echoing the decision (`base-ui-over-radix`, not `decided-to-use-baseui`). +- Always `.md`. + +## Choosing a template + +Two templates live in [`templates/`](./templates/). Default to Nygard. + +### Nygard (default — [`templates/nygard.md`](./templates/nygard.md)) + +Use Nygard for the common case: a decision that is essentially a one-way +door with a clear motivating context and one obvious choice. Four sections: +**Title, Status, Context, Decision, Consequences (Positive / Negative)**. + +Aim: ≤60 lines. Reads in under a minute. + +### MADR v4 (when alternatives matter — [`templates/madr.md`](./templates/madr.md)) + +Use MADR when the decision involves weighing multiple alternatives that a +future reader would otherwise re-litigate. Triggers: + +- **Authentication / session strategy** (NextAuth vs iron-session vs Clerk). +- **Queue / messaging mechanics** (LISTEN/NOTIFY vs Redis vs SQS). +- **Agent integration patterns** (REST polling vs MCP vs SSE channel). +- **Schema or data-model choices with non-trivial migration cost.** +- Any decision where you want to record the *rejected* options so future + contributors don't propose them again. + +MADR adds: YAML front-matter (status, date, decision-makers, consulted, +informed), explicit Decision Drivers, Considered Options, Pros and Cons of +each option, Confirmation, and More Information. + +## Status lifecycle + +``` +proposed → accepted → (optionally) superseded by NNNN + ↘ (optionally) deprecated +``` + +- **proposed** — drafted, awaiting decision-maker sign-off. +- **accepted** — current binding decision; the codebase reflects this. +- **superseded by ADR-NNNN** — replaced. Keep the file; never edit the + Decision section. Add a one-line "Superseded by …" note at the top of + the Status section and link to the new ADR. +- **deprecated** — still current but no longer recommended; usually a + precursor to a future supersession. + +Once an ADR is accepted, it is immutable except for the Status field and +typo fixes. Course corrections always create a new ADR. + +## Index of ADRs + +| # | Title | Status | Template | +|---|---|---|---| +| [0000](./0000-record-architecture-decisions.md) | Record architecture decisions | accepted | Nygard | +| [0001](./0001-base-ui-over-radix.md) | @base-ui/react over Radix UI | accepted | Nygard | +| [0002](./0002-float-sort-order.md) | Float sort_order for drag-and-drop reorder | accepted | Nygard | +| [0003](./0003-one-branch-per-milestone.md) | One branch per milestone (Hobby plan) | accepted | MADR | +| [0004](./0004-status-enum-mapping.md) | Status enum mapping via lib/task-status.ts | accepted | Nygard | +| [0005](./0005-iron-session-over-nextauth.md) | iron-session over NextAuth/Clerk | accepted | MADR | +| [0006](./0006-demo-user-three-layer-policy.md) | Demo-user three-layer write guard | accepted | Nygard | +| [0007](./0007-claude-question-channel-design.md) | Agent ↔ user question channel via persistent table + LISTEN/NOTIFY | accepted | MADR | +| [0008](./0008-agent-instructions-in-claude-md.md) | Agent instructions in CLAUDE.md + topical runbooks | accepted | Nygard | + +When new ADRs are added, the docs index generator (`npm run docs:index`) +will list them in [`../INDEX.md`](../INDEX.md). Update this table by hand +when you add or supersede an ADR — the script aggregates across the whole +docs tree, this README is the canonical ADR-only roster. diff --git a/docs/adr/templates/madr.md b/docs/adr/templates/madr.md new file mode 100644 index 0000000..c7ce450 --- /dev/null +++ b/docs/adr/templates/madr.md @@ -0,0 +1,78 @@ +--- +status: {{proposed | rejected | accepted | deprecated | superseded by ADR-NNNN}} +date: {{YYYY-MM-DD when the decision was last updated}} +decision-makers: {{list everyone who participated in the decision}} +consulted: {{list everyone whose opinions were sought (typically subject-matter experts), and with whom there was a two-way communication}} +informed: {{list everyone who is kept up-to-date on progress, and with whom there is one-way communication}} +--- + +# ADR-{{NNNN}}: {{short title, representative of solved problem and found solution}} + +## Context and Problem Statement + +{{Describe the context and problem statement, e.g., in free form using two +to three sentences or in the form of an illustrative story. You may want +to articulate the problem in form of a question and add links to +collaboration boards or issue management systems.}} + +## Decision Drivers + +- {{decision driver 1, e.g., a force, facing concern, …}} +- {{decision driver 2, e.g., a force, facing concern, …}} + +## Considered Options + +- {{title of option 1}} +- {{title of option 2}} +- {{title of option 3}} + +## Decision Outcome + +Chosen option: "{{title of option 1}}", because {{justification — e.g., only +option which meets a knock-out criterion / which resolves a force / … +turned out best (see "Pros and Cons of the Options" below)}}. + +### Consequences + +- Good, because {{positive consequence, e.g., improvement of one or more + desired qualities, …}} +- Bad, because {{negative consequence, e.g., compromising one or more + desired qualities, …}} + +### Confirmation + +{{Describe how the implementation of/compliance with the ADR can be +confirmed. E.g., a test, a peer review, a runtime check.}} + +## Pros and Cons of the Options + +### {{title of option 1}} + +{{example | description | pointer to more information | …}} + +- Good, because {{argument a}} +- Good, because {{argument b}} +- Neutral, because {{argument c}} +- Bad, because {{argument d}} + +### {{title of option 2}} + +{{example | description | pointer to more information | …}} + +- Good, because {{argument a}} +- Bad, because {{argument b}} + +### {{title of option 3}} + +{{example | description | pointer to more information | …}} + +- Good, because {{argument a}} +- Bad, because {{argument b}} + +## More Information + +{{You might want to provide additional evidence/confidence for the decision +outcome here and/or document the team agreement on the decision and/or +define when this decision the decision should be realized and if/when it +should be re-visited. Links to other decisions and resources might appear +here as well.}} diff --git a/docs/adr/templates/nygard.md b/docs/adr/templates/nygard.md new file mode 100644 index 0000000..7e3cc64 --- /dev/null +++ b/docs/adr/templates/nygard.md @@ -0,0 +1,31 @@ +# ADR-{{NNNN}}: {{Short noun phrase describing the decision}} + +## Status + +{{proposed | accepted | superseded by ADR-NNNN | deprecated}} + +## Context + +{{What is the issue we're seeing that motivates this decision? Describe the +forces at play — technical, organizational, business — that make this choice +necessary now. State facts, not opinions. Keep it short: one or two +paragraphs is usually enough. If a reader needs background that lives +elsewhere, link to it instead of duplicating.}} + +## Decision + +{{The choice we've made, written in present tense as a declarative statement. +"We will use X." "We adopt Y." Avoid hedging language. One paragraph.}} + +## Consequences + +### Positive + +- {{What becomes easier or possible because of this decision?}} +- {{What problem is no longer relevant?}} + +### Negative + +- {{What becomes harder, slower, or more expensive?}} +- {{What did we accept as a trade-off?}} +- {{What new risks does this introduce, and how do we mitigate them?}} diff --git a/docs/api.md b/docs/api.md index 8fccadb..4065a47 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,11 @@ +--- +title: "Scrum4Me REST API" +status: active +audience: [ai-agent, contributor] +language: en +last_updated: 2026-05-03 +--- + # Scrum4Me REST API REST-API contract voor Claude Code en andere clients. @@ -419,7 +427,7 @@ curl -i -X POST -b /tmp/jar -c /tmp/jar \ ## Notifications — Vraag-antwoord-kanaal (M11) -Endpoints voor de Claude vraag-antwoord-flow. De **MCP-tools** in de scrum4me-mcp-repo (`ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`) zijn de primaire schrijf-interface; de endpoints hieronder zijn voor de browser-UI en cron. +Endpoints voor de Claude vraag-antwoord-flow. De **MCP-tools** in de mcp-repo (`ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`) zijn de primaire schrijf-interface; de endpoints hieronder zijn voor de browser-UI en cron. ### `GET /api/realtime/notifications` diff --git a/docs/api/rest-contract.md b/docs/api/rest-contract.md new file mode 100644 index 0000000..4065a47 --- /dev/null +++ b/docs/api/rest-contract.md @@ -0,0 +1,528 @@ +--- +title: "Scrum4Me REST API" +status: active +audience: [ai-agent, contributor] +language: en +last_updated: 2026-05-03 +--- + +# Scrum4Me REST API + +REST-API contract voor Claude Code en andere clients. + +## Authenticatie + +Alle endpoints behalve `GET /api/health` vereisen een Bearer-token: + +``` +Authorization: Bearer +``` + +Tokens beheer je via Instellingen → Tokens (`/settings/tokens`). Een token is gekoppeld aan één gebruiker; een demo-account-token kan lezen maar niet schrijven (`403`). + +## Status-enums + +De API gebruikt **lowercase** statussen. De database gebruikt UPPER_SNAKE; de vertaling gebeurt op de boundary. + +| Entiteit | Waarden | +|---|---| +| Task status | `todo`, `in_progress`, `review`, `done` | +| Story status | `open`, `in_sprint`, `done` | + +## Foutcodes + +| Code | Betekenis | +|---|---| +| `200` | OK | +| `201` | Created | +| `400` | Malformed body (bv. ongeldige JSON) | +| `401` | Token ontbreekt of ongeldig | +| `403` | Token heeft geen toegang (demo-account, geen lid van product) | +| `404` | Resource niet gevonden | +| `422` | Validatiefout — body is wel-gevormd maar niet acceptabel | +| `500` | Onverwachte serverfout | + +--- + +## Endpoints + +### `GET /api/health` + +Health-probe. Geen authenticatie vereist. + +**Query params:** `?db=1` voegt een DB-ping toe. + +**Response (200):** +```json +{ "status": "ok", "version": "0.3.x", "time": "2026-04-26T20:00:00Z" } +``` + +Met `?db=1`: +```json +{ "status": "ok", "version": "0.3.x", "time": "...", "database": "ok" } +``` + +`database` is `"ok"` of `"down"`. De endpoint zelf retourneert altijd `200`. + +```bash +curl https://scrum4me.app/api/health?db=1 +``` + +--- + +### `GET /api/products` + +Lijst van actieve producten waar de tokengebruiker eigenaar of lid van is. + +**Response (200):** +```json +[ + { + "id": "cmofu...", + "code": "SCRUM4ME", + "name": "Scrum4Me", + "description": "...", + "repo_url": "https://github.com/...", + "definition_of_done": "..." + } +] +``` + +```bash +curl -H "Authorization: Bearer $TOKEN" https://scrum4me.app/api/products +``` + +--- + +### `GET /api/products/:id/claude-context` + +Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open todos van de tokengebruiker — in één call. + +**Response (200):** +```json +{ + "product": { "id", "code", "name", "description", "repo_url", "definition_of_done" }, + "active_sprint": { "id": "...", "sprint_goal": "...", "status": "ACTIVE" } | null, + "next_story": { + "id", "code", "title", "description", "acceptance_criteria", + "priority", "status", + "tasks": [ + { "id", "code", "title", "description", "implementation_plan", + "priority", "sort_order", "status" } + ] + } | null, + "open_todos": [ + { "id", "title", "description", "created_at" } + ] +} +``` + +`open_todos` is gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + https://scrum4me.app/api/products/$PRODUCT_ID/claude-context +``` + +--- + +### `GET /api/products/:id/next-story` + +Hoogst geprioriteerde open story in de actieve sprint. + +**Response (200):** +```json +{ + "id": "...", + "code": "ST-356", + "title": "Solo Kanban-bord met DnD en Zustand", + "description": "...", + "acceptance_criteria": "...", + "status": "in_sprint", + "tasks": [ + { + "id": "...", + "code": "ST-356.1", + "title": "Store stores/solo-store.ts", + "description": "...", + "implementation_plan": null, + "priority": 2, + "sort_order": 1, + "status": "todo" + } + ] +} +``` + +**Foutcodes:** `404` als geen actieve sprint of geen open stories. + +--- + +### `GET /api/sprints/:id/tasks` + +Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.sort_order)`. + +**Query params:** `?limit=N` (default 10, max 50) + +**Response (200):** +```json +[ + { + "id": "...", + "code": "ST-356.1", + "title": "...", + "description": "...", + "implementation_plan": null, + "story_id": "...", + "story_code": "ST-356", + "priority": 2, + "sort_order": 1, + "status": "todo" + } +] +``` + +--- + +### `PATCH /api/stories/:id/tasks/reorder` + +Volgorde van taken binnen een story aanpassen. + +**Body:** +```json +{ "task_ids": ["task-id-a", "task-id-b", "task-id-c"] } +``` + +Alle IDs moeten bij de story horen. **Foutcodes:** `422` bij Zod-fouten of als een task_id niet tot de story behoort. + +--- + +### `PATCH /api/tasks/:id` + +Status of implementation_plan bijwerken. Minstens één van beide is verplicht. +Toegestane status-waarden zijn `todo`, `in_progress` en `done`. `review` +wordt door deze endpoint geweigerd zolang de sprint-UI die state niet +rendert — gebruik de Kanban-board voor REVIEW-overgangen. + +**Body:** +```json +{ "status": "in_progress", "implementation_plan": "..." } +``` + +**Response (200):** +```json +{ + "id": "...", + "status": "in_progress", + "implementation_plan": "..." +} +``` + +**Foutcodes:** `422` bij ongeldige body of onbekende status. `403` bij demo-token. + +--- + +### `POST /api/stories/:id/log` + +Activiteit vastleggen op een story. + +**Body — IMPLEMENTATION_PLAN:** +```json +{ + "type": "IMPLEMENTATION_PLAN", + "content": "Plan: ...", + "metadata": { "branch": "feat/x" } +} +``` + +**Body — TEST_RESULT:** +```json +{ + "type": "TEST_RESULT", + "content": "Alle tests groen", + "status": "PASSED", + "metadata": { "ci_run": "..." } +} +``` + +**Body — COMMIT:** +```json +{ + "type": "COMMIT", + "content": "Werk afgerond", + "commit_hash": "abc123", + "commit_message": "feat(ST-XXX): ...", + "metadata": { "branch": "feat/x" } +} +``` + +`metadata` is optioneel, vrij JSON-object. **Response (201):** +```json +{ "id": "...", "created_at": "..." } +``` + +--- + +### `POST /api/todos` + +Nieuwe todo voor de tokengebruiker. + +**Body:** +```json +{ + "title": "Een ding doen", + "description": "Optionele uitleg, max 2000 tekens", + "product_id": "cmof..." +} +``` + +**Response (201):** +```json +{ "id": "...", "title": "...", "description": "...", "created_at": "..." } +``` + +--- + +### `GET /api/realtime/solo?product_id=...` + +Server-Sent Events stream voor het Solo Paneel. Wordt gebruikt door de browser-UI (`useSoloRealtime`); voor Claude Code zelden relevant, maar gedocumenteerd voor volledigheid. + +**Auth:** iron-session cookie of Bearer-token. Demo-tokens mogen lezen. +**Query params:** `product_id` (verplicht). +**Response:** `text/event-stream`. Stream blijft open tot de client sluit of de server na 240s een hard-close doet (client herconnect dan transparant). + +**Events:** +- `event: ready` — eenmalig direct na connect, met `{ product_id, sprint_id }` als payload. +- `event: error` — bij interne fouten (pg connect mislukt e.d.). +- `data: {...}` — task/story mutaties die binnen scope vallen (zie hieronder). Payload-shape: + + ```json + { + "op": "I" | "U" | "D", + "entity": "task" | "story", + "id": "cmof...", + "story_id": "cmof...", + "product_id": "cmof...", + "sprint_id": "cmog..." , + "assignee_id": "cmof..." , + "task_status": "TO_DO" | "IN_PROGRESS" | "REVIEW" | "DONE", + "task_title": "...", + "task_sort_order": 1, + "changed_fields": ["status", "updated_at"] + } + ``` + + Niet alle velden zijn altijd aanwezig — `task_*` alleen voor `entity: "task"`, idem `story_*`. `task_status` gebruikt de **DB-enum** (UPPER_SNAKE), niet de lowercase API-vorm. + +- `: heartbeat` — SSE-comment elke 25s, om proxies keep-alive te houden. Kan genegeerd worden. + +**Server-side filter:** +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product +- `assignee_id` is gelijk aan de ingelogde user (of `null` voor unassigned-story claims) + +Niet-matchende events worden gedropt — clients ontvangen geen irrelevante data. + +**Voorbeeld (browser):** +```js +const source = new EventSource('/api/realtime/solo?product_id=cmof...') +source.onmessage = (e) => console.log(JSON.parse(e.data)) +``` + +--- + +## Auth — QR-pairing (M10) + +Drie anonieme/cookie-geauthenticeerde endpoints voor de password-loze inlog +via QR-pairing. Worden door de browser gebruikt (niet door Claude Code) — +gedocumenteerd voor volledigheid en voor handmatige curl-tests. + +**Cookie-mechaniek:** `pair/start` zet een korte `s4m_pair`-HttpOnly-cookie +(`Path=/api/auth/pair`, `Max-Age=300`, `SameSite=Lax`, `Secure` in productie). +`pair/stream` en `pair/claim` authenticeren tegen die cookie. Geheim materiaal +zit nooit in URL-paden of querystrings — `mobileSecret` reist alleen via QR- +fragment (`#s=…`) en POST-body, `desktopToken` alleen via cookie. + +### `POST /api/auth/pair/start` + +Anon. Maakt een nieuwe `LoginPairing` aan en zet de pre-auth cookie. + +**Auth:** geen. +**Body:** geen. +**Rate-limit:** 10 per IP per minuut (zelfde patroon als `/login`). + +**Response 200:** +```json +{ + "pairingId": "cmoh...", + "mobileSecret": "<43-char base64url>", + "expiresAt": "2026-04-27T20:30:00.000Z", + "qrUrl": "https://.../m/pair#id=cmoh...&s=" +} +``` +Plus `Set-Cookie: s4m_pair=; HttpOnly; Path=/api/auth/pair; Max-Age=300; SameSite=Lax`. + +**Foutcodes:** `429` bij rate-limit overschreden. + +**Voorbeeld:** +```bash +curl -i -X POST -c /tmp/jar http://localhost:3000/api/auth/pair/start +``` + +--- + +### `GET /api/auth/pair/stream/:pairingId` + +Server-Sent Events stream die de desktop opent direct na `pair/start` om op +de approve-bevestiging van de mobiel te wachten. + +**Auth:** `s4m_pair`-cookie. Werkt vanuit `EventSource` met `withCredentials: true`. +**Path:** `pairingId` is niet vertrouwelijk; cookie is het bewijs. +**Stream-duur:** maximaal 240s (Vercel-buffer onder de 300s `maxDuration`); sluit +zodra status `consumed` of `cancelled` doorkomt. + +**Events:** +- `event: state` — eenmalig direct na connect, met `{ pairing_id, status }` (status van pairing op moment van connecten — voorkomt race wanneer approve net vóór SSE-open landt). +- `data: {...}` — bij elke status-overgang. Payload: + ```json + { "op": "I" | "U", "pairing_id": "cmoh...", "status": "pending" | "approved" | "consumed" | "cancelled" } + ``` +- `: heartbeat` — SSE-comment elke 25s. + +**Foutcodes:** `401` zonder/foute cookie, `404` als pairing onbekend, `410` als pairing verlopen. + +**Voorbeeld:** +```bash +curl -N -i -b /tmp/jar http://localhost:3000/api/auth/pair/stream/ +``` + +--- + +### `POST /api/auth/pair/claim` + +Cookie-auth. Atomisch consume van een approved pairing → schrijft de echte +`session` cookie zodat de desktop is ingelogd. + +**Auth:** `s4m_pair`-cookie. +**Body:** `{ "pairingId": "cmoh..." }`. + +**Response 200:** `{ "ok": true }` plus +- `Set-Cookie: session=...; HttpOnly; SameSite=Lax` — paired-sessie met `paired: true` en `pairedExpiresAt = now + 8h` payload-velden. +- `Set-Cookie: s4m_pair=...; Max-Age=0` — pre-auth cookie wordt gewist. + +**Foutcodes:** +- `400` bij ontbrekende of malformed body +- `401` zonder cookie of bij hash-mismatch (cookie matcht geen pairing) +- `410` als pairing al consumed/cancelled is (replay) of verlopen + +**Voorbeeld:** +```bash +curl -i -X POST -b /tmp/jar -c /tmp/jar \ + -H "Content-Type: application/json" \ + -d '{"pairingId":""}' \ + http://localhost:3000/api/auth/pair/claim +``` + +--- + +## Notifications — Vraag-antwoord-kanaal (M11) + +Endpoints voor de Claude vraag-antwoord-flow. De **MCP-tools** in de mcp-repo (`ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`) zijn de primaire schrijf-interface; de endpoints hieronder zijn voor de browser-UI en cron. + +### `GET /api/realtime/notifications` + +Server-Sent Events stream voor de notifications-bell in de NavBar. **User-scoped** — geen `product_id`-param; filtert server-side op alle producten waar de gebruiker eigenaar of teamlid is. + +**Auth:** iron-session cookie. Demo-gebruikers mogen lezen. +**Response:** `text/event-stream`. Stream blijft open tot client sluit of server na 240s een hard-close doet (client herconnect). + +**Events:** +- `event: state` — eenmalig direct na connect, met `{ questions: [...] }` als payload (zelfde shape als de live updates). +- `data: {...}` — bij elke status-overgang in `claude_questions`. Payload-shape: + ```json + { + "op": "I" | "U", + "entity": "question", + "id": "cmoh...", + "product_id": "cmoh...", + "story_id": "cmoh...", + "task_id": "cmoh..." | null, + "assignee_id": "cmoh..." | null, + "status": "open" | "answered" | "cancelled" | "expired" + } + ``` + Het is een delta — voor de volledige vraag-tekst en options reconnect de client (initial-state-event levert ze opnieuw). +- `: heartbeat` — SSE-comment elke 25s. + +**Server-side filter:** +- `payload.entity === 'question'` (`task` en `story` events horen op `/api/realtime/solo`) +- `payload.product_id` zit in de set producten met user-access (productAccessFilter) + +**Voorbeeld:** +```js +const source = new EventSource('/api/realtime/notifications', { withCredentials: true }) +``` + +--- + +## Cron — Expire questions + +### `POST /api/cron/expire-questions` + +Vercel cron handler die dagelijks draait. Markeert verlopen open vragen als `expired` en verlopen pending login_pairings als `cancelled`. + +**Auth:** `Authorization: Bearer ${CRON_SECRET}` — header die Vercel automatisch injecteert wanneer de env-var op de project-omgeving staat. Zonder secret of bij mismatch: 401. + +**Schedule:** `0 4 * * *` (dagelijks om 04:00 UTC; Vercel Hobby-plan staat alleen daily crons toe — Pro ondersteunt fijnmazigere schedules). + +**Response 200:** +```json +{ + "expired_questions": 0, + "expired_pairings": 0, + "ran_at": "2026-04-28T00:00:00.000Z" +} +``` + +**Voorbeeld (handmatige trigger):** +```bash +curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + https://your-app.vercel.app/api/cron/expire-questions +``` + +--- + +## Cron — Cleanup agent artifacts + +### `POST /api/cron/cleanup-agent-artifacts` + +Vercel cron handler die dagelijks draait. Verwijdert `FAILED` en `CANCELLED` claude_jobs waarvan `finished_at` ouder is dan 7 dagen. Hard-delete — geen historische waarde; audit-trail zit in git-commits. + +**Auth:** `Authorization: Bearer ${CRON_SECRET}` — zelfde mechanisme als `/api/cron/expire-questions`. Zonder secret of bij mismatch: 401. + +**Schedule:** `0 3 * * *` (dagelijks om 03:00 UTC). + +**Response 200:** +```json +{ + "deleted": 3, + "ran_at": "2026-05-01T03:00:00.000Z" +} +``` + +**Voorbeeld (handmatige trigger):** +```bash +curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + https://your-app.vercel.app/api/cron/cleanup-agent-artifacts +``` + +--- + +## Voorbeeldworkflow voor Claude Code + +1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. +2. **Context:** `GET /api/products/$ID/claude-context` — haal product, sprint, volgende story en todos op in één call. +3. **Plan vastleggen:** `POST /api/stories/$STORY_ID/log` met `type: IMPLEMENTATION_PLAN`. +4. **Per task:** `PATCH /api/tasks/$TASK_ID` met `status: "in_progress"`, daarna met `status: "done"` plus eventueel `implementation_plan`. +5. **Test:** `POST /api/stories/$STORY_ID/log` met `type: TEST_RESULT` en `status: PASSED|FAILED`. +6. **Commit:** `POST /api/stories/$STORY_ID/log` met `type: COMMIT`, `commit_hash`, `commit_message`, optioneel `metadata: { branch }`. diff --git a/docs/architecture.md b/docs/architecture.md index be21af7..8ada2a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,1247 +1,20 @@ +--- +title: "Scrum4Me — Technische Architectuur (breadcrumb)" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +--- + # Scrum4Me — Technische Architectuur -**Versie:** 0.1 — april 2026 -**Volgt op:** Functionele Specificatie v0.2 +> Dit bestand is een breadcrumb. De inhoud is opgesplitst in topische bestanden. ---- - -## Architectuursamenvatting - -Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. - ---- - -## Stack - -| Laag | Technologie | Rationale | -|---|---|---| -| Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management | -| UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions | -| Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg | -| Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict | -| Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund | -| Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in | -| ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch | -| Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side | -| Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers | -| REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel | -| Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL | -| Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` | -| Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 | -| CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af | - ---- - -## Wat we NIET gebruiken (en waarom) - -| Technologie | Afgewezen omdat | +| Onderwerp | Bestand | |---|---| -| Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt | -| NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten | -| Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code | -| Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | -| React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | -| Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | -| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | -| Redis | Geen caching- of queuerequirements op deze schaal | -| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | -| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | -| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | - ---- - -## Datamodel - -### `users` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | Gegenereerd door Prisma | -| username | String | unique, not null, min 3 | Inlognaam | -| password_hash | String | not null | bcrypt hash (cost factor 12) | -| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten | -| bio | String | nullable, max 160 | Korte profielomschrijving | -| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving | -| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL | - -**Indexes:** `username` (unique lookup bij inloggen) - ---- - -### `user_roles` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | | - -**Indexes:** `(user_id)` — meerdere rollen per gebruiker -**Constraint:** unique `(user_id, role)` - ---- - -### `api_tokens` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| token_hash | String | not null | SHA-256 hash van het token | -| label | String | nullable | Bijv. "Claude Code — laptop" | -| created_at | DateTime | default now() | | -| revoked_at | DateTime | nullable | Null = actief | - -**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn) - ---- - -### `products` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| name | String | not null, max 200 | Uniek per gebruiker | -| description | String | nullable, max 1000 | | -| repo_url | String | nullable | Gevalideerde URL | -| definition_of_done | String | not null, max 500 | Vaste instelling per product | -| archived | Boolean | default false | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten -**Constraint:** unique `(user_id, name)` - ---- - -### `pbis` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| code | String | nullable, max 30 | Auto-gegenereerd of handmatig | -| title | String | not null, max 200 | | -| description | String | nullable, max 2000 | | -| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag | -| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering | -| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog - -**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. - ---- - -### `stories` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| pbi_id | String | FK → pbis (cascade delete) | | -| product_id | String | FK → products | Denormalisatie voor snellere queries | -| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog | -| title | String | not null, max 200 | | -| description | String | nullable, max 2000 | | -| acceptance_criteria | String | nullable, max 2000 | | -| priority | Int | 1–4, not null | | -| sort_order | Float | not null | | -| status | Enum | OPEN \| IN_SPRINT \| DONE | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` - -**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). - ---- - -### `story_logs` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| story_id | String | FK → stories (cascade delete) | | -| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | | -| content | String | not null | Tekst van plan of testuitvoer | -| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT | -| commit_hash | String | nullable | Alleen bij type COMMIT | -| commit_message | String | nullable | Alleen bij type COMMIT | -| created_at | DateTime | default now() | | - -**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI - ---- - -### `sprints` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| sprint_goal | String | not null, max 500 | | -| status | Enum | ACTIVE \| COMPLETED | | -| created_at | DateTime | default now() | | -| completed_at | DateTime | nullable | | - -**Indexes:** `(product_id, status)` — query voor actieve Sprint per product -**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag) - ---- - -### `tasks` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| story_id | String | FK → stories (cascade delete) | | -| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries | -| title | String | not null, max 200 | | -| description | String | nullable, max 1000 | | -| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | -| priority | Int | 1–4, not null | | -| sort_order | Float | not null | | -| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` - ---- - -### `todos` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | -| title | String | not null | | -| done | Boolean | default false | | -| archived | Boolean | default false | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product - ---- - -### `product_members` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| user_id | String | FK → users (cascade delete) | | -| created_at | DateTime | default now() | | - -**Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is -**Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product - -Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. - ---- - -## Toegangsmodel en schrijfbeveiliging - -Producttoegang is centraal gedefinieerd als: - -- eigenaar: `products.user_id === gebruiker.id` -- teamlid: `product_members` bevat `(product_id, user_id)` - -Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. - -Schrijfoperaties volgen deze invarianten: - -- Controleer eerst authenticatie en `session.isDemo`. -- Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging. -- Controleer de parent-resource met `productAccessFilter`. -- Vertrouw bulk-ID's nooit los: haal de records eerst op met `id in (...)` plus de parent-scope (`product_id`, `pbi_id`, `sprint_id` of `story_id`) en weiger de operatie als aantallen niet exact overeenkomen. -- Weiger dubbele IDs in reorder- en beslissingslijsten. -- Leid denormalized foreign keys af van de database-parent (`pbi.product_id`, `sprint.product_id`) en niet van form-data of JSON body. -- Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped `deleteMany`/`updateMany` wanneer een unique `delete` anders onveilig zou zijn. - ---- - -## Prisma Schema (excerpt) - -```prisma -// prisma/schema.prisma - -generator client { - provider = "prisma-client-js" -} - -// Database wordt bepaald via prisma.config.ts — niet hier - -enum Role { - PRODUCT_OWNER - SCRUM_MASTER - DEVELOPER -} - -enum StoryStatus { - OPEN - IN_SPRINT - DONE -} - -enum PbiStatus { - READY - BLOCKED - DONE -} - -enum TaskStatus { - TO_DO - IN_PROGRESS - REVIEW - DONE -} - -enum LogType { - IMPLEMENTATION_PLAN - TEST_RESULT - COMMIT -} - -enum TestStatus { - PASSED - FAILED -} - -enum SprintStatus { - ACTIVE - COMPLETED -} - -model User { - id String @id @default(cuid()) - username String @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - avatar_data Bytes? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] -} - -model UserRole { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - role Role - - @@unique([user_id, role]) -} - -model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? - - @@index([token_hash]) -} - -model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - name String - description String? - repo_url String? - definition_of_done String - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - pbis Pbi[] - sprints Sprint[] - stories Story[] - todos Todo[] - members ProductMember[] - - @@unique([user_id, name]) - @@index([user_id, archived]) -} - -model Pbi { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - code String? @db.VarChar(30) - title String - description String? - priority Int - sort_order Float - status PbiStatus @default(READY) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - stories Story[] - - @@unique([product_id, code]) - @@index([product_id, priority, sort_order]) - @@index([product_id, status]) -} - -model Story { - id String @id @default(cuid()) - pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) - pbi_id String - product Product @relation(fields: [product_id], references: [id]) - product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - title String - description String? - acceptance_criteria String? - priority Int - sort_order Float - status StoryStatus @default(OPEN) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - logs StoryLog[] - tasks Task[] - - @@index([pbi_id, priority, sort_order]) - @@index([sprint_id, sort_order]) - @@index([product_id, status]) -} - -model StoryLog { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - type LogType - content String - status TestStatus? - commit_hash String? - commit_message String? - created_at DateTime @default(now()) - - @@index([story_id, created_at]) -} - -model Sprint { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - sprint_goal String - status SprintStatus @default(ACTIVE) - created_at DateTime @default(now()) - completed_at DateTime? - stories Story[] - tasks Task[] - - @@index([product_id, status]) -} - -model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - title String - description String? - implementation_plan String? - priority Int - sort_order Float - status TaskStatus @default(TO_DO) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@index([story_id, priority, sort_order]) - @@index([sprint_id, status]) -} - -model Todo { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - title String - done Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@index([user_id, done, archived]) - @@index([user_id, product_id]) -} - -model ProductMember { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - created_at DateTime @default(now()) - - @@unique([product_id, user_id]) - @@index([user_id]) - @@map("product_members") -} -``` - ---- - -## Authenticatieflow - -``` -Registratie: - POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB - → iron-session cookie zetten → redirect /dashboard - -Inloggen: - POST /login → gebruiker ophalen op username → bcrypt vergelijken - → bij match: iron-session cookie zetten → redirect /dashboard - → bij mismatch: generieke foutmelding (geen onderscheid) - -Sessie per request: - proxy.ts → sessiecookie-aanwezigheid controleren - → beschermde routes: redirect /login als geen sessiecookie aanwezig is - → app layout valideert de volledige sessie server-side - -API-aanroepen (Claude Code): - Authorization: Bearer header → SHA-256 hash → opzoeken in api_tokens - → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten - -Uitloggen: - Server Action → iron-session vernietigen → redirect /login -``` - ---- - -## QR-pairing flow (M10) - -Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt -door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke -toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- -sessie heeft eigen kortere TTL (8 u) + `paired`-vlag. - -### Sequence - -```mermaid -sequenceDiagram - participant D as Desktop (anon) - participant S as Server - participant M as Mobiel (ingelogd) - - D->>S: POST /api/auth/pair/start - S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min } - S-->>D: 200 { pairingId, mobileSecret, qrUrl }
Set-Cookie: s4m_pair=desktopToken - D->>D: render QR met qrUrl (#id=…&s=mobileSecret) - D->>S: GET /api/auth/pair/stream/[pairingId]
Cookie: s4m_pair - S->>S: LISTEN scrum4me_pairing - S-->>D: event: state { status: 'pending' } - - Note over M: Gebruiker scant QR - M->>M: location.hash → mobileSecret - M->>S: getPairingForApproval(pairingId, mobileSecret) - S-->>M: { desktop_ua, desktop_ip, username } - M->>M: toont bevestigingskaart - Note over M: Tap "Bevestig" - M->>S: approvePairing(pairingId, mobileSecret) - S->>S: status pending→approved, expires +5min
pg_notify scrum4me_pairing - S-->>D: data { status: 'approved' } - - D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } - S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed - S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } - S-->>D: 200, Set-Cookie: session
+ s4m_pair cleared - D->>D: redirect /dashboard -``` - -### Threat-model - -| Aanval | Mitigatie | -|---|---| -| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 | -| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart | -| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` | -| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) | -| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) | -| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke | - -### TTL-rationale - -- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft. -- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft. -- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen. - -### Waarom geen secret in URL - -Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access -logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een -geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit: - -1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door - browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client - Component leest `window.location.hash` en POST't de waarde in een body — - ook niet in een URL. -2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET - in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien - `Path=/api/auth/pair`-scoped, dus verlaat die route nooit. - -Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` -voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch -de andere kant compromitteert. - -Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`. - ---- - -## Vraag-antwoord-kanaal Claude ↔ user (M11) - -Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. -Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een -gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het -**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`. -De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert, -filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere -gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele -emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in -een latere sessie via `get_question_answer`) en gaat door. - -### Sequence - -```mermaid -sequenceDiagram - participant C as Claude (MCP) - participant DB as Postgres - participant SC as scrum4me_changes channel - participant SSE as /api/realtime/notifications - participant U as Scrum4Me UI (browser) - - C->>DB: INSERT claude_questions (status=open) - DB->>SC: pg_notify {entity:'question', op:'I', id, ...} - SC->>SSE: notification (filter: question + product-access) - SSE->>U: data event → Zustand store upsert → bell badge - - Note over U: Gebruiker klikt bell → Sheet → Modal - U->>DB: answerQuestion(questionId, answer)
Server Action: atomic updateMany WHERE status='open' - DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'} - SC->>SSE: notification - SSE->>U: data event → store remove → bell badge -1 - - Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s - C->>DB: SELECT status FROM claude_questions WHERE id=... - DB-->>C: status='answered', answer='...' - C->>C: gaat door met implementatie -``` - -### Threat-model - -| Aanval | Mitigatie | -|---|---| -| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst | -| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal | -| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) | -| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) | -| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 4 * * *` (dagelijks; Hobby-plan-limiet) markeert `status='expired'` → uit notifications-bell | -| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst | - -### Waarom hergebruik scrum4me_changes-kanaal - -In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van -de bestaande realtime-infra. Voordelen: - -- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties -- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key -- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen - nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen - -Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie: -expliciet `if (payload.entity === 'X') return false` in elke SSE-route die -betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'` -weert). - -Dit patroon (notification-channel via een bestaande pg_notify-stream) is -herbruikbaar — zie `docs/patterns/claude-question-channel.md`. - ---- - -## Projectstructuur - -``` -scrum4me/ -├── app/ -│ ├── (auth)/ -│ │ ├── login/page.tsx -│ │ └── register/page.tsx -│ ├── (app)/ # Beschermde routes -│ │ ├── layout.tsx # Auth-check + navigatie -│ │ ├── dashboard/page.tsx # Productenlijst -│ │ ├── products/ -│ │ │ ├── new/page.tsx -│ │ │ └── [id]/ -│ │ │ ├── layout.tsx # Zet actief product in Zustand store -│ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) -│ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) -│ │ │ ├── sprint/ -│ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) -│ │ │ │ └── planning/page.tsx # Redirect → /sprint -│ │ ├── todos/page.tsx -│ │ └── settings/ -│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens -│ │ └── tokens/page.tsx -│ ├── api/ # REST API voor Claude Code -│ │ ├── products/ -│ │ │ └── [id]/ -│ │ │ └── next-story/route.ts -│ │ ├── profile/ -│ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto -│ │ ├── sprints/ -│ │ │ └── [id]/ -│ │ │ └── tasks/route.ts -│ │ ├── stories/ -│ │ │ └── [id]/ -│ │ │ ├── log/route.ts -│ │ │ └── tasks/reorder/route.ts -│ │ ├── tasks/ -│ │ │ └── [id]/route.ts -│ │ └── todos/route.ts -├── components/ -│ ├── ui/ # shadcn/ui primitieven -│ ├── split-pane/ # Gesplitst scherm component -│ ├── backlog/ # PBI- en story-componenten -│ ├── sprint/ # Sprint-componenten -│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton -│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton -│ └── dnd/ # dnd-kit wrappers -├── lib/ -│ ├── prisma.ts # Prisma Client singleton -│ ├── session.ts # iron-session configuratie -│ ├── auth.ts # login/register/token helpers -│ ├── api-auth.ts # Bearer token middleware voor API -│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) -│ └── env.ts # Zod-gevalideerde env vars -├── stores/ # Zustand stores -│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) -│ ├── planner-store.ts # Optimistische drag-and-drop volgorde -│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) -│ ├── sprint-store.ts # Sprint Backlog taakvolgordes -│ ├── solo-store.ts # Solo board optimistische taakstatus -│ └── product-store.ts # Actief product (naam + id) voor navbar -├── prisma/ -│ ├── schema.prisma -│ ├── migrations/ -│ └── seed.ts # Testdata uit Product Backlog document -├── proxy.ts # Next.js 16 proxy voor route protection -├── prisma.config.ts # Prisma v7 config (DATABASE_URL) -└── .env.example -``` - ---- - -## Sleutelarchitectuurbeslissingen - -### Beslissing: iron-session in plaats van Auth.js / Supabase Auth -**Keuze:** iron-session voor versleutelde server-side sessiecookies -**Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. -**Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. - -### Beslissing: Route Handlers naast Server Actions -**Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API -**Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. -**Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. - -### Beslissing: Float voor sort_order -**Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken -**Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). -**Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. - -### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` -**Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` -**Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. -**Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. - -### Beslissing: Zustand voor client-side state management -**Keuze:** Vijf Zustand-stores naast Server Components -**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. -**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. - ---- - -## Zustand stores - -### `usePlannerStore` — optimistische drag-and-drop volgorde - -Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency. - -```ts -// stores/planner-store.ts -import { create } from 'zustand' - -interface PlannerStore { - // Optimistische volgorde per container (id-arrays) - pbiOrder: Record // productId → pbi-ids - storyOrder: Record // pbiId → story-ids - taskOrder: Record // storyId → taak-ids - - // Initialiseren vanuit server-data (bij mount) - initPbis: (productId: string, ids: string[]) => void - initStories: (pbiId: string, ids: string[]) => void - initTasks: (storyId: string, ids: string[]) => void - - // Optimistisch updaten (vóór server-bevestiging) - reorderPbis: (productId: string, newOrder: string[]) => void - reorderStories: (pbiId: string, newOrder: string[]) => void - reorderTasks: (storyId: string, newOrder: string[]) => void - - // Terugdraaien bij server-fout - rollbackPbis: (productId: string, prevOrder: string[]) => void - rollbackStories: (pbiId: string, prevOrder: string[]) => void - rollbackTasks: (storyId: string, prevOrder: string[]) => void -} -``` - -**Gebruikspatroon:** -```ts -// 1. Server Component geeft ids door -// app/(app)/products/[id]/page.tsx -const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] }) -return p.id)} pbis={pbis} /> - -// 2. Client Component hydrateert store -// components/backlog/backlog-panel.tsx -'use client' -const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore() -useEffect(() => { initPbis(productId, initialPbiIds) }, []) - -// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action -const prevOrder = usePlannerStore(s => s.pbiOrder[productId]) -reorderPbis(productId, newOrder) -const result = await reorderPbisAction(productId, newOrder) -if (!result.success) rollbackPbis(productId, prevOrder) -``` - ---- - -### `useSelectionStore` — navigatieselectie - -Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling. - -```ts -// stores/selection-store.ts -interface SelectionStore { - selectedPbiId: string | null - selectedStoryId: string | null - selectPbi: (id: string | null) => void - selectStory: (id: string | null) => void - clearSelection: () => void -} -``` - ---- - -### `useSprintStore` — Sprint Backlog interacties - -Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen. - -```ts -// stores/sprint-store.ts -interface SprintStore { - // Stories per Sprint (optimistisch, op volgorde) - sprintStoryIds: Record // sprintId → story-ids - - initSprint: (sprintId: string, ids: string[]) => void - addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void - removeStoryFromSprint: (sprintId: string, storyId: string) => void - reorderSprintStories: (sprintId: string, newOrder: string[]) => void - rollbackSprint: (sprintId: string, prevIds: string[]) => void -} -``` - ---- - -### `useSoloStore` — Solo board optimistische taakstatus - -Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout. - -```ts -// stores/solo-store.ts -interface SoloStore { - tasks: Record - initTasks: (tasks: SoloTask[]) => void - optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null - rollback: (taskId: string, prevStatus: TaskStatus) => void - updatePlan: (taskId: string, plan: string | null) => void -} -``` - ---- - -### `useProductStore` — Actief product voor navbar - -Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie. - -```ts -// stores/product-store.ts -interface ProductStore { - currentProduct: { id: string; name: string } | null - setCurrentProduct: (id: string, name: string) => void - clearCurrentProduct: () => void -} -``` - ---- - -## Data flow architectuur - -``` -┌─────────────────────────────────────────┐ -│ Server Component (page.tsx) │ -│ Prisma query → initiële data + ids │ -│ → props naar Client Component │ -└──────────────────┬──────────────────────┘ - │ initialIds, initialData - ▼ -┌─────────────────────────────────────────┐ -│ Client Component (panel.tsx) │ -│ useEffect → store.init(ids) │ -│ dnd-kit drag → store.reorder() │ -│ → Server Action (async) │ -│ → bij fout: store.rollback()│ -└──────────────────┬──────────────────────┘ - │ selecteert state via selector - ▼ -┌─────────────────────────────────────────┐ -│ Zustand Stores │ -│ usePlannerStore useSelectionStore │ -│ useSprintStore │ -│ │ -│ Alleen ephemere UI-staat │ -│ Nooit server-data of business logic │ -└─────────────────────────────────────────┘ -``` -**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel -**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. -**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken. - ---- - -## Realtime updates (M8) - -Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: - -``` -┌─────────────────────────┐ -│ Mutatie (Prisma write) │ PATCH /api/tasks/:id -└────────────┬────────────┘ Server Action, MCP, etc. - ▼ -┌─────────────────────────┐ -│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE -│ scrum4me_notify_change()│ bouwt JSON payload -└────────────┬────────────┘ - ▼ pg_notify('scrum4me_changes', json) -┌─────────────────────────┐ -│ /api/realtime/solo │ Node runtime, dedicated pg.Client -│ LISTEN scrum4me_changes │ filtert op product + sprint + assignee -└────────────┬────────────┘ - ▼ text/event-stream -┌─────────────────────────┐ -│ EventSource (browser) │ beheerd door useSoloRealtime -│ → solo-store.handleEvent│ via flushSync + startViewTransition -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ SoloBoard re-render │ kanban-kaartje animeert naar -│ (View Transitions API) │ zijn nieuwe kolom -└─────────────────────────┘ -``` - -**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). -**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. -**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. - -### Mutaties die NOTIFY triggeren - -De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. - -### Server-side filter - -`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: -- `product_id` matcht de query-param -- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) -- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) - -Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. - -### Connection lifecycle - -- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount. -- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). -- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. -- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. -- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. - -**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. - -### Animatie - -Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. - -### Auth - -Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. - ---- - -## Realtime — Backlog SSE (ST-1115) - -De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. - -``` -┌─────────────────────────┐ -│ Mutatie (Prisma write) │ Server Action, MCP, etc. -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE -│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' -└────────────┬────────────┘ - ▼ pg_notify('scrum4me_changes', json) -┌─────────────────────────┐ -│ /api/realtime/backlog │ Node runtime, dedicated pg.Client -│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} -│ │ én product_id matcht query-param -└────────────┬────────────┘ - ▼ text/event-stream -┌─────────────────────────┐ -│ EventSource (browser) │ beheerd door useBacklogRealtime -│ → backlog-store.apply │ via applyChange(entity, op, data) -│ Change(entity,op,data)│ -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ PbiList / StoryPanel / │ re-render op basis van Zustand state -│ TaskPanel re-render │ -└─────────────────────────┘ -``` - -### Hydration en SSE-mount - -De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: -1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). -2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. - -Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. - -### backlog-store en applyChange - -```ts -// stores/backlog-store.ts -applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) -``` - -- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array -- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) -- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload - -### Server-side filter (backlog) - -`/api/realtime/backlog?product_id=...` filtert op: -- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd -- `product_id` matcht de query-param - -Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. - ---- - -## Demo-user policy (ST-1110) - -Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: - -### Laag 1 — Middleware-guard (proxy.ts) - -`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`. - -```ts -// Whitelist: paden die demo mag aanroepen ondanks non-GET -const DEMO_WRITE_ALLOWLIST = [ - '/api/cron/', // machine-auth, irrelevant voor demo -] -// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2 -``` - -### Laag 2 — Per-route guards (Server Actions & Route Handlers) - -Elke schrijfactie controleert `session.isDemo` vóór DB-toegang: - -```ts -if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } -``` - -**QR-pairing (M10):** -- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops -- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd -- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110 - -**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth). - -### Laag 3 — UI-laag (DemoTooltip) - -Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`. - -Patroon: -```tsx - - - -``` - -**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. - ---- - -## Claude job queue (M13 — ST-1111) - -Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status. - -### State machine - -``` -QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE - → FAILED - → CANCELLED (door user) -CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist) -QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed) -``` - -**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd. - -### ClaudeJob model - -``` -claude_jobs - id, user_id, product_id, task_id - status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) - claimed_by_token_id (FK → api_tokens, nullable) - claimed_at, started_at, finished_at - plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim - branch, pushed_at, summary, error - verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT) - @@index([user_id, status]) - @@index([task_id, status]) - @@index([status, claimed_at]) — voor stale-claim cleanup -``` - -**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`: - -| Waarde | Betekenis | -|---|---| -| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie | -| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker | -| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft | -| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig | - -**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen. - -**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte. - -### NOTIFY/LISTEN flow - -``` -UI klikt 'Voer uit' - → enqueueClaudeJobAction() Server Action - → prisma.claudeJob.create(QUEUED) - → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...}) - → /api/realtime/solo SSE server-side filter: user_id + product_id - → EventSource.onmessage browser: handleJobEvent() - → useSoloStore.claudeJobsByTaskId map - → SoloTaskCard pill + dialog-footer update -``` - -### Idempotency - -`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken. - -### Auto-promote task-status op job-overgangen - -Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat: - -- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan. -- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is. - -De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig. - -### Hybride-ready - -De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd. - -## Environment variables - -| Variabele | Doel | Waar te vinden | -|---|---|---| -| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) | -| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) | -| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` | -| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node | - -`.env.example`: -```bash -# Database -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" -DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require" - -# Sessie -SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output" - -# Optioneel -NODE_ENV="development" -``` - ---- - -## Deployment - -**Hosting:** Vercel (Hobby — gratis voor v1) -**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` -**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap -**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema -**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/erd.svg` -**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document - -### Deployment checklist (pre-launch) - -- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings) -- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens) -- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase -- [ ] Demo-gebruiker aangemaakt via seed of handmatig -- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products` -- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek -- [ ] Vercel preview-deployments getest op een PR -- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten - ---- - -## Kostenscattting - -| Service | Plan | Maandelijkse kosten | -|---|---|---| -| Vercel | Hobby | Gratis | -| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis | -| GitHub | Free | Gratis | -| Domein | Eigen domein (optioneel) | ~€1–2/maand | -| **Totaal** | | **€0–2/maand** | - -> Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog. +| Overzicht, Stack, Keuzes | [architecture/overview.md](./architecture/overview.md) | +| Datamodel, Prisma Schema | [architecture/data-model.md](./architecture/data-model.md) | +| Authenticatie, Sessions, Demo-policy | [architecture/auth-and-sessions.md](./architecture/auth-and-sessions.md) | +| QR-pairing login flow | [architecture/qr-pairing.md](./architecture/qr-pairing.md) | +| Claude ↔ User vraag-kanaal | [architecture/claude-question-channel.md](./architecture/claude-question-channel.md) | +| Projectstructuur, Stores, Realtime, Job queue | [architecture/project-structure.md](./architecture/project-structure.md) | diff --git a/docs/architecture/auth-and-sessions.md b/docs/architecture/auth-and-sessions.md new file mode 100644 index 0000000..4d633c3 --- /dev/null +++ b/docs/architecture/auth-and-sessions.md @@ -0,0 +1,216 @@ +--- +title: "Authentication, Sessions & Demo Policy" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [qr-pairing.md](./qr-pairing.md) +--- + +## Authenticatieflow + +``` +Registratie: + POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB + → iron-session cookie zetten → redirect /dashboard + +Inloggen: + POST /login → gebruiker ophalen op username → bcrypt vergelijken + → bij match: iron-session cookie zetten → redirect /dashboard + → bij mismatch: generieke foutmelding (geen onderscheid) + +Sessie per request: + proxy.ts → sessiecookie-aanwezigheid controleren + → beschermde routes: redirect /login als geen sessiecookie aanwezig is + → app layout valideert de volledige sessie server-side + +API-aanroepen (Claude Code): + Authorization: Bearer header → SHA-256 hash → opzoeken in api_tokens + → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten + +Uitloggen: + Server Action → iron-session vernietigen → redirect /login +``` + +--- + +## Demo-user policy (ST-1110) + +Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: + +### Laag 1 — Middleware-guard (proxy.ts) + +`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`. + +```ts +// Whitelist: paden die demo mag aanroepen ondanks non-GET +const DEMO_WRITE_ALLOWLIST = [ + '/api/cron/', // machine-auth, irrelevant voor demo +] +// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2 +``` + +### Laag 2 — Per-route guards (Server Actions & Route Handlers) + +Elke schrijfactie controleert `session.isDemo` vóór DB-toegang: + +```ts +if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } +``` + +**QR-pairing (M10):** +- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops +- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd +- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110 + +**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth). + +### Laag 3 — UI-laag (DemoTooltip) + +Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`. + +Patroon: +```tsx + + + +``` + +**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. + +--- + +## Claude job queue (M13 — ST-1111) + +Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status. + +### State machine + +``` +QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE + → FAILED + → CANCELLED (door user) +CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist) +QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed) +``` + +**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd. + +### ClaudeJob model + +``` +claude_jobs + id, user_id, product_id, task_id + status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) + claimed_by_token_id (FK → api_tokens, nullable) + claimed_at, started_at, finished_at + plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim + branch, pushed_at, summary, error + verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT) + @@index([user_id, status]) + @@index([task_id, status]) + @@index([status, claimed_at]) — voor stale-claim cleanup +``` + +**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`: + +| Waarde | Betekenis | +|---|---| +| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie | +| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker | +| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft | +| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig | + +**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen. + +**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte. + +### NOTIFY/LISTEN flow + +``` +UI klikt 'Voer uit' + → enqueueClaudeJobAction() Server Action + → prisma.claudeJob.create(QUEUED) + → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...}) + → /api/realtime/solo SSE server-side filter: user_id + product_id + → EventSource.onmessage browser: handleJobEvent() + → useSoloStore.claudeJobsByTaskId map + → SoloTaskCard pill + dialog-footer update +``` + +### Idempotency + +`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken. + +### Auto-promote task-status op job-overgangen + +Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat: + +- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan. +- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is. + +De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig. + +### Hybride-ready + +De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd. + +## Environment variables + +| Variabele | Doel | Waar te vinden | +|---|---|---| +| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) | +| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) | +| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` | +| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node | + +`.env.example`: +```bash +# Database +DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" +DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require" + +# Sessie +SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output" + +# Optioneel +NODE_ENV="development" +``` + +--- + +## Deployment + +**Hosting:** Vercel (Hobby — gratis voor v1) +**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` +**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap +**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema +**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/assets/erd.svg` +**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document + +### Deployment checklist (pre-launch) + +- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings) +- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens) +- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase +- [ ] Demo-gebruiker aangemaakt via seed of handmatig +- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products` +- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek +- [ ] Vercel preview-deployments getest op een PR +- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten + +--- + +## Kostenscattting + +| Service | Plan | Maandelijkse kosten | +|---|---|---| +| Vercel | Hobby | Gratis | +| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis | +| GitHub | Free | Gratis | +| Domein | Eigen domein (optioneel) | ~€1–2/maand | +| **Totaal** | | **€0–2/maand** | + +> Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog. diff --git a/docs/architecture/claude-question-channel.md b/docs/architecture/claude-question-channel.md new file mode 100644 index 0000000..d4fc05a --- /dev/null +++ b/docs/architecture/claude-question-channel.md @@ -0,0 +1,79 @@ +--- +title: "Claude ↔ User Question Channel" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [project-structure.md](./project-structure.md) +--- + +## Vraag-antwoord-kanaal Claude ↔ user (M11) + +Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. +Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een +gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het +**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`. +De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert, +filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere +gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele +emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in +een latere sessie via `get_question_answer`) en gaat door. + +### Sequence + +```mermaid +sequenceDiagram + participant C as Claude (MCP) + participant DB as Postgres + participant SC as scrum4me_changes channel + participant SSE as /api/realtime/notifications + participant U as Scrum4Me UI (browser) + + C->>DB: INSERT claude_questions (status=open) + DB->>SC: pg_notify {entity:'question', op:'I', id, ...} + SC->>SSE: notification (filter: question + product-access) + SSE->>U: data event → Zustand store upsert → bell badge + + Note over U: Gebruiker klikt bell → Sheet → Modal + U->>DB: answerQuestion(questionId, answer)
Server Action: atomic updateMany WHERE status='open' + DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'} + SC->>SSE: notification + SSE->>U: data event → store remove → bell badge -1 + + Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s + C->>DB: SELECT status FROM claude_questions WHERE id=... + DB-->>C: status='answered', answer='...' + C->>C: gaat door met implementatie +``` + +### Threat-model + +| Aanval | Mitigatie | +|---|---| +| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst | +| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal | +| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) | +| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) | +| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 4 * * *` (dagelijks; Hobby-plan-limiet) markeert `status='expired'` → uit notifications-bell | +| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst | + +### Waarom hergebruik scrum4me_changes-kanaal + +In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van +de bestaande realtime-infra. Voordelen: + +- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties +- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key +- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen + nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen + +Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie: +expliciet `if (payload.entity === 'X') return false` in elke SSE-route die +betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'` +weert). + +Dit patroon (notification-channel via een bestaande pg_notify-stream) is +herbruikbaar — zie `docs/patterns/claude-question-channel.md`. + +--- + diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 0000000..7051a38 --- /dev/null +++ b/docs/architecture/data-model.md @@ -0,0 +1,460 @@ +--- +title: "Data Model & Prisma Schema" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [auth-and-sessions.md](./auth-and-sessions.md) +--- + +## Datamodel + +### `users` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | Gegenereerd door Prisma | +| username | String | unique, not null, min 3 | Inlognaam | +| password_hash | String | not null | bcrypt hash (cost factor 12) | +| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten | +| bio | String | nullable, max 160 | Korte profielomschrijving | +| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving | +| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL | + +**Indexes:** `username` (unique lookup bij inloggen) + +--- + +### `user_roles` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | | + +**Indexes:** `(user_id)` — meerdere rollen per gebruiker +**Constraint:** unique `(user_id, role)` + +--- + +### `api_tokens` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| token_hash | String | not null | SHA-256 hash van het token | +| label | String | nullable | Bijv. "Claude Code — laptop" | +| created_at | DateTime | default now() | | +| revoked_at | DateTime | nullable | Null = actief | + +**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn) + +--- + +### `products` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| name | String | not null, max 200 | Uniek per gebruiker | +| description | String | nullable, max 1000 | | +| repo_url | String | nullable | Gevalideerde URL | +| definition_of_done | String | not null, max 500 | Vaste instelling per product | +| archived | Boolean | default false | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten +**Constraint:** unique `(user_id, name)` + +--- + +### `pbis` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| code | String | nullable, max 30 | Auto-gegenereerd of handmatig | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag | +| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering | +| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog + +**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. + +--- + +### `stories` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| pbi_id | String | FK → pbis (cascade delete) | | +| product_id | String | FK → products | Denormalisatie voor snellere queries | +| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| acceptance_criteria | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | | +| sort_order | Float | not null | | +| status | Enum | OPEN \| IN_SPRINT \| DONE | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` + +**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). + +--- + +### `story_logs` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| story_id | String | FK → stories (cascade delete) | | +| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | | +| content | String | not null | Tekst van plan of testuitvoer | +| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT | +| commit_hash | String | nullable | Alleen bij type COMMIT | +| commit_message | String | nullable | Alleen bij type COMMIT | +| created_at | DateTime | default now() | | + +**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI + +--- + +### `sprints` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| sprint_goal | String | not null, max 500 | | +| status | Enum | ACTIVE \| COMPLETED | | +| created_at | DateTime | default now() | | +| completed_at | DateTime | nullable | | + +**Indexes:** `(product_id, status)` — query voor actieve Sprint per product +**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag) + +--- + +### `tasks` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| story_id | String | FK → stories (cascade delete) | | +| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries | +| title | String | not null, max 200 | | +| description | String | nullable, max 1000 | | +| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | +| priority | Int | 1–4, not null | | +| sort_order | Float | not null | | +| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` + +--- + +### `todos` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | +| title | String | not null | | +| done | Boolean | default false | | +| archived | Boolean | default false | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product + +--- + +### `product_members` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| user_id | String | FK → users (cascade delete) | | +| created_at | DateTime | default now() | | + +**Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is +**Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product + +Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. + +--- + +## Toegangsmodel en schrijfbeveiliging + +Producttoegang is centraal gedefinieerd als: + +- eigenaar: `products.user_id === gebruiker.id` +- teamlid: `product_members` bevat `(product_id, user_id)` + +Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. + +Schrijfoperaties volgen deze invarianten: + +- Controleer eerst authenticatie en `session.isDemo`. +- Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging. +- Controleer de parent-resource met `productAccessFilter`. +- Vertrouw bulk-ID's nooit los: haal de records eerst op met `id in (...)` plus de parent-scope (`product_id`, `pbi_id`, `sprint_id` of `story_id`) en weiger de operatie als aantallen niet exact overeenkomen. +- Weiger dubbele IDs in reorder- en beslissingslijsten. +- Leid denormalized foreign keys af van de database-parent (`pbi.product_id`, `sprint.product_id`) en niet van form-data of JSON body. +- Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped `deleteMany`/`updateMany` wanneer een unique `delete` anders onveilig zou zijn. + +--- + +## Prisma Schema (excerpt) + +```prisma +// prisma/schema.prisma + +generator client { + provider = "prisma-client-js" +} + +// Database wordt bepaald via prisma.config.ts — niet hier + +enum Role { + PRODUCT_OWNER + SCRUM_MASTER + DEVELOPER +} + +enum StoryStatus { + OPEN + IN_SPRINT + DONE +} + +enum PbiStatus { + READY + BLOCKED + DONE +} + +enum TaskStatus { + TO_DO + IN_PROGRESS + REVIEW + DONE +} + +enum LogType { + IMPLEMENTATION_PLAN + TEST_RESULT + COMMIT +} + +enum TestStatus { + PASSED + FAILED +} + +enum SprintStatus { + ACTIVE + COMPLETED +} + +model User { + id String @id @default(cuid()) + username String @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] +} + +model UserRole { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + role Role + + @@unique([user_id, role]) +} + +model ApiToken { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + + @@index([token_hash]) +} + +model Product { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + name String + description String? + repo_url String? + definition_of_done String + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + pbis Pbi[] + sprints Sprint[] + stories Story[] + todos Todo[] + members ProductMember[] + + @@unique([user_id, name]) + @@index([user_id, archived]) +} + +model Pbi { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String? @db.VarChar(30) + title String + description String? + priority Int + sort_order Float + status PbiStatus @default(READY) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + stories Story[] + + @@unique([product_id, code]) + @@index([product_id, priority, sort_order]) + @@index([product_id, status]) +} + +model Story { + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + pbi_id String + product Product @relation(fields: [product_id], references: [id]) + product_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + acceptance_criteria String? + priority Int + sort_order Float + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + logs StoryLog[] + tasks Task[] + + @@index([pbi_id, priority, sort_order]) + @@index([sprint_id, sort_order]) + @@index([product_id, status]) +} + +model StoryLog { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + type LogType + content String + status TestStatus? + commit_hash String? + commit_message String? + created_at DateTime @default(now()) + + @@index([story_id, created_at]) +} + +model Sprint { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + sprint_goal String + status SprintStatus @default(ACTIVE) + created_at DateTime @default(now()) + completed_at DateTime? + stories Story[] + tasks Task[] + + @@index([product_id, status]) +} + +model Task { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([story_id, priority, sort_order]) + @@index([sprint_id, status]) +} + +model Todo { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + title String + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, done, archived]) + @@index([user_id, product_id]) +} + +model ProductMember { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + created_at DateTime @default(now()) + + @@unique([product_id, user_id]) + @@index([user_id]) + @@map("product_members") +} +``` + +--- + diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..7a4fa5b --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,59 @@ +--- +title: "Scrum4Me — Architecture Overview" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [data-model.md](./data-model.md), [project-structure.md](./project-structure.md) +--- + +**Versie:** 0.1 — april 2026 +**Volgt op:** Functionele Specificatie v0.2 + +--- + +## Architectuursamenvatting + +Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. + +--- + +## Stack + +| Laag | Technologie | Rationale | +|---|---|---| +| Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management | +| UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions | +| Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg | +| Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict | +| Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund | +| Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in | +| ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch | +| Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side | +| Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers | +| REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel | +| Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL | +| Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` | +| Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 | +| CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af | + +--- + +## Wat we NIET gebruiken (en waarom) + +| Technologie | Afgewezen omdat | +|---|---| +| Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt | +| NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten | +| Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code | +| Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | +| React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | +| Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | +| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | +| Redis | Geen caching- of queuerequirements op deze schaal | +| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | +| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | +| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | + +--- + diff --git a/docs/architecture/project-structure.md b/docs/architecture/project-structure.md new file mode 100644 index 0000000..bce5d7a --- /dev/null +++ b/docs/architecture/project-structure.md @@ -0,0 +1,397 @@ +--- +title: "Project Structure, Stores, Realtime & Job Queue" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [data-model.md](./data-model.md) +--- + +## Projectstructuur + +``` +scrum4me/ +├── app/ +│ ├── (auth)/ +│ │ ├── login/page.tsx +│ │ └── register/page.tsx +│ ├── (app)/ # Beschermde routes +│ │ ├── layout.tsx # Auth-check + navigatie +│ │ ├── dashboard/page.tsx # Productenlijst +│ │ ├── products/ +│ │ │ ├── new/page.tsx +│ │ │ └── [id]/ +│ │ │ ├── layout.tsx # Zet actief product in Zustand store +│ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) +│ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) +│ │ │ ├── sprint/ +│ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) +│ │ │ │ └── planning/page.tsx # Redirect → /sprint +│ │ ├── todos/page.tsx +│ │ └── settings/ +│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens +│ │ └── tokens/page.tsx +│ ├── api/ # REST API voor Claude Code +│ │ ├── products/ +│ │ │ └── [id]/ +│ │ │ └── next-story/route.ts +│ │ ├── profile/ +│ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto +│ │ ├── sprints/ +│ │ │ └── [id]/ +│ │ │ └── tasks/route.ts +│ │ ├── stories/ +│ │ │ └── [id]/ +│ │ │ ├── log/route.ts +│ │ │ └── tasks/reorder/route.ts +│ │ ├── tasks/ +│ │ │ └── [id]/route.ts +│ │ └── todos/route.ts +├── components/ +│ ├── ui/ # shadcn/ui primitieven +│ ├── split-pane/ # Gesplitst scherm component +│ ├── backlog/ # PBI- en story-componenten +│ ├── sprint/ # Sprint-componenten +│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton +│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton +│ └── dnd/ # dnd-kit wrappers +├── lib/ +│ ├── prisma.ts # Prisma Client singleton +│ ├── session.ts # iron-session configuratie +│ ├── auth.ts # login/register/token helpers +│ ├── api-auth.ts # Bearer token middleware voor API +│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) +│ └── env.ts # Zod-gevalideerde env vars +├── stores/ # Zustand stores +│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) +│ ├── planner-store.ts # Optimistische drag-and-drop volgorde +│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) +│ ├── sprint-store.ts # Sprint Backlog taakvolgordes +│ ├── solo-store.ts # Solo board optimistische taakstatus +│ └── product-store.ts # Actief product (naam + id) voor navbar +├── prisma/ +│ ├── schema.prisma +│ ├── migrations/ +│ └── seed.ts # Testdata uit Product Backlog document +├── proxy.ts # Next.js 16 proxy voor route protection +├── prisma.config.ts # Prisma v7 config (DATABASE_URL) +└── .env.example +``` + +--- + +## Sleutelarchitectuurbeslissingen + +### Beslissing: iron-session in plaats van Auth.js / Supabase Auth +**Keuze:** iron-session voor versleutelde server-side sessiecookies +**Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. +**Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. + +### Beslissing: Route Handlers naast Server Actions +**Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API +**Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. +**Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. + +### Beslissing: Float voor sort_order +**Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken +**Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). +**Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. + +### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` +**Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` +**Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. +**Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. + +### Beslissing: Zustand voor client-side state management +**Keuze:** Vijf Zustand-stores naast Server Components +**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. +**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. + +--- + +## Zustand stores + +### `usePlannerStore` — optimistische drag-and-drop volgorde + +Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency. + +```ts +// stores/planner-store.ts +import { create } from 'zustand' + +interface PlannerStore { + // Optimistische volgorde per container (id-arrays) + pbiOrder: Record // productId → pbi-ids + storyOrder: Record // pbiId → story-ids + taskOrder: Record // storyId → taak-ids + + // Initialiseren vanuit server-data (bij mount) + initPbis: (productId: string, ids: string[]) => void + initStories: (pbiId: string, ids: string[]) => void + initTasks: (storyId: string, ids: string[]) => void + + // Optimistisch updaten (vóór server-bevestiging) + reorderPbis: (productId: string, newOrder: string[]) => void + reorderStories: (pbiId: string, newOrder: string[]) => void + reorderTasks: (storyId: string, newOrder: string[]) => void + + // Terugdraaien bij server-fout + rollbackPbis: (productId: string, prevOrder: string[]) => void + rollbackStories: (pbiId: string, prevOrder: string[]) => void + rollbackTasks: (storyId: string, prevOrder: string[]) => void +} +``` + +**Gebruikspatroon:** +```ts +// 1. Server Component geeft ids door +// app/(app)/products/[id]/page.tsx +const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] }) +return p.id)} pbis={pbis} /> + +// 2. Client Component hydrateert store +// components/backlog/backlog-panel.tsx +'use client' +const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore() +useEffect(() => { initPbis(productId, initialPbiIds) }, []) + +// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action +const prevOrder = usePlannerStore(s => s.pbiOrder[productId]) +reorderPbis(productId, newOrder) +const result = await reorderPbisAction(productId, newOrder) +if (!result.success) rollbackPbis(productId, prevOrder) +``` + +--- + +### `useSelectionStore` — navigatieselectie + +Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling. + +```ts +// stores/selection-store.ts +interface SelectionStore { + selectedPbiId: string | null + selectedStoryId: string | null + selectPbi: (id: string | null) => void + selectStory: (id: string | null) => void + clearSelection: () => void +} +``` + +--- + +### `useSprintStore` — Sprint Backlog interacties + +Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen. + +```ts +// stores/sprint-store.ts +interface SprintStore { + // Stories per Sprint (optimistisch, op volgorde) + sprintStoryIds: Record // sprintId → story-ids + + initSprint: (sprintId: string, ids: string[]) => void + addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void + removeStoryFromSprint: (sprintId: string, storyId: string) => void + reorderSprintStories: (sprintId: string, newOrder: string[]) => void + rollbackSprint: (sprintId: string, prevIds: string[]) => void +} +``` + +--- + +### `useSoloStore` — Solo board optimistische taakstatus + +Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout. + +```ts +// stores/solo-store.ts +interface SoloStore { + tasks: Record + initTasks: (tasks: SoloTask[]) => void + optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null + rollback: (taskId: string, prevStatus: TaskStatus) => void + updatePlan: (taskId: string, plan: string | null) => void +} +``` + +--- + +### `useProductStore` — Actief product voor navbar + +Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie. + +```ts +// stores/product-store.ts +interface ProductStore { + currentProduct: { id: string; name: string } | null + setCurrentProduct: (id: string, name: string) => void + clearCurrentProduct: () => void +} +``` + +--- + +## Data flow architectuur + +``` +┌─────────────────────────────────────────┐ +│ Server Component (page.tsx) │ +│ Prisma query → initiële data + ids │ +│ → props naar Client Component │ +└──────────────────┬──────────────────────┘ + │ initialIds, initialData + ▼ +┌─────────────────────────────────────────┐ +│ Client Component (panel.tsx) │ +│ useEffect → store.init(ids) │ +│ dnd-kit drag → store.reorder() │ +│ → Server Action (async) │ +│ → bij fout: store.rollback()│ +└──────────────────┬──────────────────────┘ + │ selecteert state via selector + ▼ +┌─────────────────────────────────────────┐ +│ Zustand Stores │ +│ usePlannerStore useSelectionStore │ +│ useSprintStore │ +│ │ +│ Alleen ephemere UI-staat │ +│ Nooit server-data of business logic │ +└─────────────────────────────────────────┘ +``` +**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel +**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. +**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken. + +--- + +## Realtime updates (M8) + +Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ PATCH /api/tasks/:id +└────────────┬────────────┘ Server Action, MCP, etc. + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ bouwt JSON payload +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/solo │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op product + sprint + assignee +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useSoloRealtime +│ → solo-store.handleEvent│ via flushSync + startViewTransition +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ SoloBoard re-render │ kanban-kaartje animeert naar +│ (View Transitions API) │ zijn nieuwe kolom +└─────────────────────────┘ +``` + +**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). +**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. +**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. + +### Mutaties die NOTIFY triggeren + +De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. + +### Server-side filter + +`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) +- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) + +Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. + +### Connection lifecycle + +- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount. +- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). +- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. +- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. +- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. + +**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. + +### Animatie + +Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. + +### Auth + +Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. + +--- + +## Realtime — Backlog SSE (ST-1115) + +De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ Server Action, MCP, etc. +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/backlog │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} +│ │ én product_id matcht query-param +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useBacklogRealtime +│ → backlog-store.apply │ via applyChange(entity, op, data) +│ Change(entity,op,data)│ +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ PbiList / StoryPanel / │ re-render op basis van Zustand state +│ TaskPanel re-render │ +└─────────────────────────┘ +``` + +### Hydration en SSE-mount + +De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: +1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). +2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. + +Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. + +### backlog-store en applyChange + +```ts +// stores/backlog-store.ts +applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) +``` + +- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array +- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) +- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload + +### Server-side filter (backlog) + +`/api/realtime/backlog?product_id=...` filtert op: +- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd +- `product_id` matcht de query-param + +Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. + +--- + diff --git a/docs/architecture/qr-pairing.md b/docs/architecture/qr-pairing.md new file mode 100644 index 0000000..61a30ea --- /dev/null +++ b/docs/architecture/qr-pairing.md @@ -0,0 +1,88 @@ +--- +title: "QR-pairing Login Flow" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [auth-and-sessions.md](./auth-and-sessions.md) +--- + +## QR-pairing flow (M10) + +Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt +door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke +toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- +sessie heeft eigen kortere TTL (8 u) + `paired`-vlag. + +### Sequence + +```mermaid +sequenceDiagram + participant D as Desktop (anon) + participant S as Server + participant M as Mobiel (ingelogd) + + D->>S: POST /api/auth/pair/start + S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min } + S-->>D: 200 { pairingId, mobileSecret, qrUrl }
Set-Cookie: s4m_pair=desktopToken + D->>D: render QR met qrUrl (#id=…&s=mobileSecret) + D->>S: GET /api/auth/pair/stream/[pairingId]
Cookie: s4m_pair + S->>S: LISTEN scrum4me_pairing + S-->>D: event: state { status: 'pending' } + + Note over M: Gebruiker scant QR + M->>M: location.hash → mobileSecret + M->>S: getPairingForApproval(pairingId, mobileSecret) + S-->>M: { desktop_ua, desktop_ip, username } + M->>M: toont bevestigingskaart + Note over M: Tap "Bevestig" + M->>S: approvePairing(pairingId, mobileSecret) + S->>S: status pending→approved, expires +5min
pg_notify scrum4me_pairing + S-->>D: data { status: 'approved' } + + D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } + S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed + S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } + S-->>D: 200, Set-Cookie: session
+ s4m_pair cleared + D->>D: redirect /dashboard +``` + +### Threat-model + +| Aanval | Mitigatie | +|---|---| +| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 | +| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart | +| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` | +| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) | +| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) | +| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke | + +### TTL-rationale + +- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft. +- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft. +- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen. + +### Waarom geen secret in URL + +Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access +logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een +geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit: + +1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door + browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client + Component leest `window.location.hash` en POST't de waarde in een body — + ook niet in een URL. +2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET + in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien + `Path=/api/auth/pair`-scoped, dus verlaat die route nooit. + +Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` +voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch +de andere kant compromitteert. + +Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`. + +--- + diff --git a/docs/erd.svg b/docs/assets/erd.svg similarity index 100% rename from docs/erd.svg rename to docs/assets/erd.svg diff --git a/docs/icons.html b/docs/assets/icons.html similarity index 100% rename from docs/icons.html rename to docs/assets/icons.html diff --git a/docs/backlog.md b/docs/backlog.md index 8424cc0..3891334 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1,3 +1,11 @@ +--- +title: "Scrum4Me — Implementatie Backlog" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +--- + # Scrum4Me — Implementatie Backlog **Versie:** 0.1 — april 2026 @@ -23,7 +31,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan | M4: Claude Code REST API | Alle endpoints, tokenbeheer | ST-401 – ST-410 | | M5: Todo-lijst | Todo CRUD, promotie naar PBI/story; Data Table + detail-kaart | ST-501 – ST-506, ST-509 – ST-510 | | M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 | -| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 | +| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `mcp`) | ST-701 – ST-710 | | M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 | | M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | | M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 | @@ -223,7 +231,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan ### M3.5: Solo Paneel & Story Assignment -> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `solo-paneel-spec.md`. +> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `specs/functional.md#solo-panel`. - [x] **ST-350** Story.assignee_id schema-migratie + auth-helpers - **Schema:** voeg `assignee_id String?` + `assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)` toe aan `Story`; voeg `assigned_stories Story[] @relation("StoryAssignee")` toe aan `User`; voeg index `@@index([sprint_id, assignee_id])` toe; migratie via `prisma migrate dev --name add_story_assignee` @@ -409,8 +417,13 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan - **`PATCH /api/tasks/:id`:** accepteert lowercase `status` via mapper; retourneert lowercase - **Story-log metadata:** nieuwe optionele `metadata Json?` kolom op `StoryLog`; `POST /api/stories/:id/log` accepteert per type een optioneel `metadata`-veld (bv. `{ branch: 'feat/x' }`); bestaande velden ongewijzigd → backwards-compatible - **Foutcodes:** Zod-validatie geeft `422` (was `400`); `400` blijft voor malformed body; `401`/`403`/`404`/`500` ongewijzigd +<<<<<<<< HEAD:docs/backlog/index.md + - **API-documentatie:** nieuwe `docs/api/rest-contract.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar + - Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api/rest-contract.md` is gepubliceerd +======== - **API-documentatie:** nieuwe `docs/api.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar - Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api.md` is gepubliceerd +>>>>>>>> origin/main:docs/backlog.md - **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done` - **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe - **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`) @@ -475,7 +488,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan Aparte repo: [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp). Native Prisma-toegang (geen REST-tussenlaag), stdio-transport, Scrum4Me-schema gevendord als git submodule. Tokens hergebruikt uit `api_tokens`. v1 is alleen dev-flow tools — geen PBI/sprint-creatie of profielbeheer. -- [x] **ST-701** Repo-skeleton scrum4me-mcp +- [x] **ST-701** Repo-skeleton mcp - npm init, tsconfig strict, .gitignore, MCP SDK 1.29, Prisma 7, zod, tsx; lege `src/index.ts` die op stdio start - Done when: `npx tsx src/index.ts` print `running on stdio` zonder crash; `tsc --noEmit` slaagt @@ -547,20 +560,24 @@ Filtering server-side: alleen events binnen de actieve sprint van een product wa - Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh - [x] **ST-806** Documentatie + acceptatietest +<<<<<<<< HEAD:docs/backlog/index.md + - Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api/rest-contract.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap) +======== - Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap) +>>>>>>>> origin/main:docs/backlog.md - Done when: alle scenario's lopen door zonder onverwachte gedragingen Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit). ### M9: Actief Product Backlog -**Implementatieplan:** [docs/plans/M9-active-product-backlog.md](plans/M9-active-product-backlog.md) +**Implementatieplan:** [docs/plans/M9-active-product-backlog.md](../plans/M9-active-product-backlog.md) Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar wordt gesplitst in **Producten** (lijst) en **Product Backlog** (PB-view van actief PB), met **Sprint** en **Solo** als aparte tabs die op het actieve PB werken. Geen actief PB → die drie tabs zijn disabled. Vervangt de bestaande `last_product`-cookieflow. - [x] **ST-901** Database — `user.active_product_id` - Voeg `active_product_id String? @db.Uuid` toe aan `User` met FK naar `Product.id` en `onDelete: SetNull`; migratie `add_user_active_product_id`; index op `active_product_id` voor join-performance - - Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in scrum4me-mcp draait `prisma generate` + `tsc --noEmit` zonder fouten + - Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in mcp draait `prisma generate` + `tsc --noEmit` zonder fouten - [x] **ST-902** Server Actions — actief product zetten en wissen - `actions/active-product.ts` met `setActiveProduct(productId)` en `clearActiveProduct()`; Zod + auth + `productAccessFilter`; demo-gebruikers mogen wisselen (sessie-effect alleen, geen DB-write); `archiveProduct` en `leaveProduct` zetten `active_product_id` op `null` als het hetzelfde product betreft @@ -584,13 +601,13 @@ Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar word - [x] **ST-907** Documentatie en tests - Functional spec: nieuw hoofdstuk "Actief Product Backlog" (concept, menugedrag, edge cases); README: navigatie-screenshot bijwerken; `docs/patterns/` indien nieuwe patroon (n.v.t. tenzij dropdown-switcher een herbruikbaar component wordt); jest-tests in `__tests__/actions/active-product.test.ts` voor setActive (toegang, demo, archived); Playwright/manueel scenario: log in → activeer PB → wissel via dropdown → archiveer → verifieer auto-clear - - Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in scrum4me-mcp gesynced + - Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in mcp gesynced --- ### M10: Password-loze inlog via QR-pairing -**Implementatieplan:** [docs/plans/M10-qr-pairing-login.md](plans/M10-qr-pairing-login.md) +**Implementatieplan:** [docs/plans/M10-qr-pairing-login.md](../plans/M10-qr-pairing-login.md) Inloggen op een (publieke) desktop zonder wachtwoord: de desktop toont een QR-code, de gebruiker scant met een telefoon waar hij al ingelogd is, bevestigt expliciet, en de desktop is binnen 1–2 seconden ingelogd. Bouwt voort op de Postgres LISTEN/NOTIFY-infra van M8 (eigen kanaal `scrum4me_pairing`). Geen wachtwoord ingetypt op het publieke apparaat, geen credentials op de draad, demo-accounts geblokkeerd, paired-sessie heeft eigen kortere TTL (8 u) + `paired`-vlag voor toekomstige remote-revoke. @@ -641,12 +658,20 @@ Volledige flow + threat-model: `docs/patterns/qr-login.md` (op te leveren in ST- - [ ] **ST-1007** Desktop UI: QR-render + SSE-listener op `/login` - **Dependency:** `qrcode.react` (client SVG; mobileSecret blijft op desktop in JS-geheugen) - **`app/login/qr-login-button.tsx`:** Client Component; klik → POST `pair/start` (`credentials: 'same-origin'` zodat `s4m_pair`-cookie wordt geaccepteerd) → render QR met `qrUrl` (fragment-URL) → open `EventSource('/api/auth/pair/stream/', { withCredentials: true })` → bij `approved` event POST `pair/claim` (cookie-only) → bij succes `router.push('/dashboard')`; aftellende timer (2 min); bij timeout "Vernieuwen"-knop; cleanup bij unmount/redirect +<<<<<<<< HEAD:docs/backlog/index.md + - **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/design/styling.md`) +======== - **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/styling.md`) +>>>>>>>> origin/main:docs/backlog.md - A11y: QR heeft alt-tekst met de URL voor screenreaders/copy-paste (de hash-suffix is onderdeel van die alt-tekst, niet van de page-URL die in browsergeschiedenis komt) - Done when: end-to-end happy path werkt op localhost (twee browsers): A toont QR → B scant + bevestigt → A redirect naar `/dashboard` met `session.paired === true`; QR vernieuwt na expiry; geen secret zichtbaar in DevTools Network-tab onder URL-kolommen - [ ] **ST-1008** Documentatie + acceptatietest +<<<<<<<< HEAD:docs/backlog/index.md + - **`docs/api/rest-contract.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar` +======== - **`docs/api.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar` +>>>>>>>> origin/main:docs/backlog.md - **`docs/architecture.md`:** sectie "QR-pairing flow" met sequence-diagram + threat-model; expliciete subsectie *"Waarom geen secret in URL"* — fragments worden niet naar server gestuurd; SSE/claim authenticeren via HttpOnly cookie zodat secret-materiaal niet in access logs / reverse-proxy logs / observability-tools / browsergeschiedenis kan belanden - **`docs/patterns/qr-login.md`:** nieuw pattern-doc voor toekomstige features die hetzelfde unauth-SSE-via-pre-auth-cookie-patroon willen hergebruiken - **`CLAUDE.md`:** verwijzing naar het nieuwe pattern-doc in de patterns-tabel @@ -657,7 +682,7 @@ Volledige flow + threat-model: `docs/patterns/qr-login.md` (op te leveren in ST- ### M11: Claude vraagt, gebruiker antwoordt -**Implementatieplan:** [docs/plans/M11-claude-questions.md](plans/M11-claude-questions.md) +**Implementatieplan:** [docs/plans/M11-claude-questions.md](../plans/M11-claude-questions.md) Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. Claude schrijft een vraag naar `claude_questions` als hij vastloopt op een keuze; een Postgres-trigger emit op het bestaande `scrum4me_changes`-kanaal (uitgebreid met `entity: 'question'`); de Scrum4Me-app toont een notificatie-badge in de NavBar; iedereen met product-toegang kan antwoorden; Claude leest het antwoord (sync via polling met `wait_seconds`, of in een latere sessie via `get_question_answer`) en gaat door. Eerste concrete uitwerking van de AI-driven dev-flow-richting. @@ -669,7 +694,7 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru - **Migratie:** `prisma migrate dev --name add_claude_questions` - Done when: migratie slaagt; `psql LISTEN scrum4me_changes` toont nieuwe `entity: 'question'`-payload bij INSERT; bestaande solo-realtime-flow ongewijzigd; submodule sync na merge -- [ ] **ST-1102** MCP-tools voor Claude (in scrum4me-mcp-repo) +- [ ] **ST-1102** MCP-tools voor Claude (in mcp-repo) - **`ask_user_question`** (write): input `{ story_id, question, options?, task_id?, wait_seconds? }`; insert pairing + optioneel pollen tot `wait_seconds` (max 600); demo-blok via `requireWriteAccess`; access-check via `userCanAccessProduct(story.product_id, ...)` - **`get_question_answer`** (read): haalt status + antwoord op een specifieke vraag op - **`list_open_questions`** (read): lijst van eigen vragen (laatste 50, status open of answered) @@ -694,7 +719,11 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru - **`stores/notifications-store.ts`** — Zustand store volgens `solo-store.ts`-patroon: `init`, `add`, `update`, `remove`, `optimisticAnswer`, `rollbackAnswer`; selectors `openCount`, `forYouCount` - **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`; EventSource op `/api/realtime/notifications` met reconnect-backoff - **`components/notifications/notifications-bridge.tsx`** — Server Component die initial-data fetcht en aan store geeft; mount in `app/(app)/layout.tsx` naast `` +<<<<<<<< HEAD:docs/backlog/index.md + - **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/design/styling.md` +======== - **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/styling.md` +>>>>>>>> origin/main:docs/backlog.md - **`components/notifications/notifications-sheet.tsx`** — shadcn Sheet van rechts; lijst gegroepeerd per product; story-assignee krijgt visuele *"wacht op jou"*-emphase - **`components/notifications/answer-modal.tsx`** — shadcn Dialog; story-context-link, vraag-tekst, RadioGroup (als options) of Textarea (free-text), submit via `useTransition` + Server Action; demo-blok met tooltip - Done when: bell + badge zichtbaar; klik opent Sheet met items; submit verwijdert item optimistisch; tweede tab van zelfde user ziet nieuwe vraag binnen 1-2s; demo-modus rendert maar Verstuur disabled @@ -713,7 +742,11 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru - Done when: handmatige `curl -X POST` met secret expireert oude rijen; Vercel-dashboard toont cron-config na deploy; onbevoegde call → 401 - [ ] **ST-1108** Documentatie + acceptatietest +<<<<<<<< HEAD:docs/backlog/index.md + - **`docs/api/rest-contract.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden +======== - **`docs/api.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden +>>>>>>>> origin/main:docs/backlog.md - **`docs/architecture.md`:** sectie "Vraag-antwoord-kanaal Claude ↔ user" met Mermaid sequence-diagram + threat-model + "Waarom hergebruik scrum4me_changes-kanaal" - **`docs/patterns/claude-question-channel.md`:** nieuw herbruikbaar pattern-doc voor toekomstige bidirectionele async-communicatie tussen MCP-agents en interactieve users - **`CLAUDE.md`:** rij in Implementatiepatronen-tabel voor het nieuwe pattern diff --git a/docs/backlog/index.md b/docs/backlog/index.md new file mode 100644 index 0000000..3891334 --- /dev/null +++ b/docs/backlog/index.md @@ -0,0 +1,784 @@ +--- +title: "Scrum4Me — Implementatie Backlog" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +--- + +# Scrum4Me — Implementatie Backlog + +**Versie:** 0.1 — april 2026 +**Volgt op:** Functionele Specificatie v0.2, Architectuur v0.1 + +--- + +## MVP-definitie + +De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan doorlopen: een product aanmaken, een Product Backlog opbouwen met PBI's en stories, een Sprint plannen, taken aanmaken, en Claude Code de volgende story laten ophalen, implementeren en vastleggen — allemaal zonder hulp of handleiding. De app draait stabiel op Vercel en is volledig lokaal opzetbaar via één README. + +--- + +## Milestone-overzicht + +| Milestone | Doel | Tasks | +|---|---|---| +| M0: Foundation | Project, database, auth, navigatieshell | ST-001 – ST-008 | +| M1: Producten & Product Backlog | Producten, PBI's, gesplitst scherm | ST-101 – ST-110 | +| M2: Stories & Drag-and-drop | Stories als blokken, dnd-kit, Zustand | ST-201 – ST-210 | +| M3: Sprint Backlog & Sprint Planning | Sprint aanmaken, stories slepen, taken | ST-301 – ST-313 | +| M3.5: Solo Paneel & Story Assignment | Story-claim, persoonlijk Kanban-bord per product | ST-350 – ST-360 | +| M4: Claude Code REST API | Alle endpoints, tokenbeheer | ST-401 – ST-410 | +| M5: Todo-lijst | Todo CRUD, promotie naar PBI/story; Data Table + detail-kaart | ST-501 – ST-506, ST-509 – ST-510 | +| M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 | +| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `mcp`) | ST-701 – ST-710 | +| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 | +| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | +| M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 | +| M11: Claude vraagt, gebruiker antwoordt | Persistent vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker | ST-1101 – ST-1108 | +--- + +## Backlog + +### M0: Foundation + +- [x] **ST-001** Project scaffolding + - `create-next-app` met TypeScript strict, Tailwind CSS, App Router; installeer shadcn/ui, Zustand, dnd-kit, iron-session, bcrypt, Zod; configureer path aliases (`@/`) + - Done when: `npm run dev` start zonder fouten; `npm run lint` geeft geen errors; shadcn `Button` rendert op een testpagina + +- [x] **ST-002** Prisma v7 setup + `prisma.config.ts` + - Installeer Prisma v7 + `@prisma/adapter-pg`; schrijf `prisma.config.ts` met `DATABASE_URL` via Zod-gevalideerde env; schrijf `lib/prisma.ts` singleton + - Done when: `npx prisma db push` slaagt; Prisma Client importeerbaar in een testbestand zonder fouten + +- [x] **ST-003** Database schema migratie (volledige initiële migratie) + - Schrijf het volledige `schema.prisma` op basis van het architectuurdocument: `User`, `UserRole`, `ApiToken`, `Product`, `Pbi`, `Story`, `StoryLog`, `Sprint`, `Task`, `Todo`; alle enums, indexes, cascade deletes + - Done when: `npx prisma migrate dev --name init` slaagt; alle tabellen zichtbaar in DB-client; `npx prisma validate` geeft geen fouten + +- [x] **ST-004** Seed met testdata + - Schrijf `prisma/seed.ts` op basis van het Product Backlog document (devplanner-product-backlog.md); seed één gebruiker, één product (Scrum4Me zelf), alle PBI's en stories als testdata; voeg demo-gebruiker toe + - Done when: `npx prisma db seed` slaagt; DB bevat alle PBI's en stories uit het backlog-document; demo-gebruiker aanwezig + +- [x] **ST-005** Environment variabelen + `lib/env.ts` + - Schrijf Zod-schema voor alle env vars (`DATABASE_URL`, `DIRECT_URL`, `SESSION_SECRET`, `NODE_ENV`); exporteer gevalideerd `env` object; schrijf `.env.example` met instructies + - Done when: app gooit een begrijpelijke fout bij ontbrekende env var; `.env.example` volledig gedocumenteerd + +- [x] **ST-006** Authenticatie — registratie en inloggen + - Schrijf `lib/auth.ts` (registreer met bcrypt hash, verifieer bij inloggen); schrijf `lib/session.ts` (iron-session config); implementeer `/register` en `/login` pagina's met Server Actions; sla `{ userId, isDemo }` op in sessiecookie + - Done when: registreren → ingelogde sessie → redirect `/dashboard`; inloggen met verkeerde credentials geeft generieke foutmelding; sessie blijft actief na paginaverversing + +- [x] **ST-007** Route-beveiliging via `proxy.ts` + - Schrijf `proxy.ts` die sessiecookie-aanwezigheid controleert; redirect naar `/login` bij alle `/dashboard`, `/products/*`, `/todos`, `/settings/*` routes zonder sessiecookie; authenticated users worden van `/login` en `/register` doorgestuurd naar `/dashboard`; volledige sessievalidatie gebeurt server-side in de app layout + - Done when: directe navigatie naar `/dashboard` zonder sessie redirect naar `/login`; ingelogde gebruiker op `/login` redirect naar `/dashboard` + +- [x] **ST-008** Navigatieshell + dashboard-layout + - Schrijf `app/(app)/layout.tsx` met navigatiebalk (logo, productenlink, todolink, instellingen, uitlogknop); implementeer uitlog Server Action; implementeer `/dashboard` als lege productenlijstpagina met "Maak je eerste product aan" lege staat; zet demo-badge zichtbaar als `isDemo === true` + - Done when: volledige auth-flow (register → login → dashboard → logout → login) werkt end-to-end; demo-gebruiker ziet badge in navigatie + +--- + +### M1: Producten & Product Backlog + +- [x] **ST-101** Product aanmaken + - `/products/new` pagina met formulier (naam, beschrijving, repo URL, definition of done); `createProduct` Server Action met Zod-validatie; uniekheidscontrole op naam per gebruiker; redirect naar `/products/[id]` na aanmaken + - Done when: product aangemaakt en zichtbaar op dashboard; dubbele naam geeft inline validatiefout; lege naam blokkeert submit + +- [x] **ST-102** Productenlijst op dashboard + - Haal actieve producten op via Prisma Server Component; toon naam, beschrijving (ingekort 80 tekens), repo-link; lege staat met CTA; klikken opent Product Backlog + - Done when: twee producten zichtbaar na aanmaken; gearchiveerd product niet zichtbaar in standaardlijst + +- [x] **ST-103** Product bewerken en archiveren + - Bewerkformulier (naam, beschrijving, repo URL, DoD) via Server Action; archiveerknop met bevestigingsdialoog; hersteloptie voor gearchiveerde producten; "toon gearchiveerd"-filter op dashboard + - Done when: naam bijwerken persisteert; archiveren verbergt product; herstel maakt het weer zichtbaar + +- [x] **ST-104** Gesplitst scherm layout component (`SplitPane`) + - Bouw herbruikbaar `` Client Component met versleepbare horizontale splitter; sla splitter-positie op in `localStorage` per sleutel; standaard 40/60 verhouding; minimale panelbreedte 200px; responsive fallback naar tabs op < 1024px + - Done when: splitter versleepbaar en positie behouden na paginaverversing; tabs getoond op smal scherm + +- [x] **ST-105** Navigatiebar-component per paneel + - Bouw herbruikbaar `` component met slots voor knoppen (aanmaken, filter, verwijderen); consistent design voor linker- en rechterpaneel + - Done when: navigatiebar herbruikt in minimaal twee gesplitste schermen zonder duplicatie + +- [x] **ST-106** PBI aanmaken en weergeven + - Linkerpaneel van `/products/[id]`: haal PBI's op gegroepeerd op prioriteit en sort_order; "PBI aanmaken" knop opent inline formulier (titel, prioriteit); `createPbi` Server Action; nieuw PBI verschijnt onderaan de juiste prioriteitsgroep + - Done when: PBI aangemaakt en zichtbaar in juiste prioriteitsgroep; lege staat toont prompt + +- [x] **ST-107** PBI prioriteitsgroepen met visuele scheiding + - Render PBI's gegroepeerd per prioriteit (1–4) met gelabelde scheidingslijn per groep (bijv. "Kritiek", "Hoog"); lege groepen zijn niet zichtbaar; prioriteitsbadge per PBI + - Done when: vier prioriteitsgroepen correct gerenderd met labels; PBI met prioriteit 1 staat boven prioriteit 4 + +- [x] **ST-108** PBI bewerken en verwijderen + - Inline bewerkingsmodus via dubbelklik of contextmenu (titel, omschrijving, prioriteit); `updatePbi` Server Action; verwijderen met bevestigingsdialoog inclusief waarschuwing cascade; `deletePbi` Server Action + - Done when: titelbewering opgeslagen zonder paginaverversing; verwijderen cascade-verwijdert stories (verifieerbaar in DB) + +- [x] **ST-109** PBI selecteren → stories laden + - Klikken op PBI in linkerpaneel toont bijbehorende stories rechts via `useSelectionStore`; geselecteerd PBI visueel gemarkeerd; lege staat rechts als geen stories + - Done when: klikken op PBI A toont stories van A rechts; klikken op PBI B schakelt direct over + +- [x] **ST-110** PBI filter + - Filterknop in linkerpaneel navigatiebar; dropdown voor prioriteit (1–4, alle); filter werkt realtime op gerenderde lijst; actief filter zichtbaar als badge; wissen via ×-knop + - Done when: filter op prioriteit 1 verbergt alle andere PBI's; wissen herstelt volledige lijst + +--- + +### M2: Stories & Drag-and-drop + +- [x] **ST-201** `usePlannerStore` Zustand-store + - Schrijf `stores/planner-store.ts` met `pbiOrder`, `storyOrder`, `taskOrder`; `init*`, `reorder*`, `rollback*` actions; TypeScript strict types + - Done when: store importeerbaar in een Client Component; `initPbis` vult order; `reorderPbis` muteert order; `rollbackPbis` herstelt vorige staat + +- [x] **ST-202** `useSelectionStore` Zustand-store + - Schrijf `stores/selection-store.ts` met `selectedPbiId`, `selectedStoryId`, setters en `clearSelection` + - Done when: selectie in linkerpaneel via store zichtbaar in rechterpaneel zonder prop drilling + +- [x] **ST-203** dnd-kit setup + PBI drag-and-drop + - Installeer dnd-kit; wrap linkerpaneel in `DndContext` + `SortableContext`; implementeer `useSortable` per PBI-rij; `onDragEnd`: bereken nieuwe `sort_order` via float-gemiddelde; optimistisch updaten via `usePlannerStore`; `reorderPbisAction` Server Action; rollback bij fout + - Done when: PBI versleepbaar binnen prioriteitsgroep; volgorde opgeslagen na loslaten; UI rollback bij gesimuleerde server-fout + +- [x] **ST-204** PBI drag-and-drop over prioriteitsgrens + - Uitbreiding ST-203: slepen over een prioriteitsgrens wijzigt `priority` van het PBI; `sort_order` wordt onderaan de doelgroep geplaatst; `updatePbiPriority` Server Action + - Done when: PBI naar prioriteit 2 slepen vanuit prioriteit 3 wijzigt zowel prioriteit als volgorde + +- [x] **ST-205** Story aanmaken en weergeven als blokken + - Rechterpaneel van Product Backlog: haal stories op voor geselecteerd PBI; render als blokken (~10% schermbreedte, horizontaal); elk blok toont titel (ingekort), prioriteitsbadge, statusbadge; "Story aanmaken" knop; `createStory` Server Action + - Done when: drie stories zichtbaar als blokken; nieuw blok verschijnt in juiste prioriteitsgroep + +- [x] **ST-206** Story prioriteitsgroepen met visuele scheiding + - Groepeer story-blokken per prioriteit; gekleurde band of scheidingslijn per groep; blokken horizontaal gerangschikt per rij; nieuwe rij bij overloop + - Done when: stories van vier prioriteiten correct gescheiden weergegeven + +- [x] **ST-207** Story drag-and-drop (horizontaal, binnen en tussen groepen) + - dnd-kit horizontale `SortableContext` per prioriteitsgroep; `onDragEnd`: herrangschikking via float-gemiddelde in `storyOrder`; slepen naar andere groep wijzigt prioriteit; optimistisch via `usePlannerStore`; `reorderStoriesAction` Server Action; rollback bij fout + - Done when: story versleepbaar binnen groep en naar andere groep; volgorde en prioriteit persistent na loslaten + +- [x] **ST-208** Story detail-modal / slide-over + - Klikken op storyblok opent slide-over of modal met titel, omschrijving, acceptatiecriteria, statusbadge, activiteitenlog (leeg bij nieuwe story); bewerkformulier voor titel/omschrijving/acceptatiecriteria; `updateStory` Server Action + - Done when: klikken op blok opent detail; bewerken persisteert; sluiten keert terug naar backlog + +- [x] **ST-209** Story verwijderen + - Verwijderknop in story-detail of contextmenu; bevestigingsdialoog met waarschuwing cascade (taken); `deleteStory` Server Action; blok verdwijnt optimistisch uit het rechterpaneel + - Done when: story verwijderd incl. cascade-taken (verifieerbaar in DB); blok direct verdwenen uit UI + +- [x] **ST-210** Story filter in rechterpaneel + - Filterknop in rechterpaneel navigatiebar; filter op status (OPEN, IN_SPRINT, DONE) en prioriteit; realtime; actief filter als badge; wissbaar + - Done when: filter op OPEN verbergt IN_SPRINT stories + +--- + +### M3: Sprint Backlog & Sprint Planning + +- [x] **ST-301** `useSprintStore` Zustand-store + - Schrijf `stores/sprint-store.ts`; `initSprint`, `addStoryToSprint`, `removeStoryFromSprint`, `reorderSprintStories`, `rollbackSprint` + - Done when: store beheert sprint-story-volgorde onafhankelijk van planner-store + +- [x] **ST-302** Sprint aanmaken + - "Sprint starten" knop op productpagina (zichtbaar als geen actieve Sprint); modal met Sprint Goal invoerveld; `createSprint` Server Action; max. 1 actieve Sprint per product afgedwongen in service-laag + - Done when: Sprint aangemaakt met Goal; tweede sprint aanmaken terwijl eerste actief is geeft foutmelding + +- [x] **ST-303** Sprint Backlog scherm — layout + - `/products/[id]/sprint` pagina; `SplitPane` met Sprint Backlog links (stories in Sprint op volgorde) en rechts de Product Backlog stories gegroepeerd per PBI (inklapbaar); Sprint Goal zichtbaar bovenaan; lege staat links met instructie + - Done when: pagina rendert correct; Sprint Goal zichtbaar; beide panelen tonen juiste data + +- [x] **ST-304** Story vanuit Product Backlog naar Sprint slepen + - dnd-kit drag vanuit rechterpaneel naar linkerpaneel; `onDragEnd`: `addStoryToSprint` in store; story krijgt badge "In Sprint" in Product Backlog; `addStoryToSprintAction` Server Action (zet `sprint_id` + status `IN_SPRINT`); rollback bij fout + - Done when: story gesleept naar Sprint verschijnt links en toont "In Sprint" badge rechts; persistent na herlaad + +- [x] **ST-305** Sprint Backlog story volgorde aanpassen + - dnd-kit verticale `SortableContext` in linkerpaneel; herrangschikking via float-gemiddelde in `useSprintStore`; `reorderSprintStoriesAction` Server Action + - Done when: volgorde in Sprint Backlog persistent na loslaten en na paginaverversing + +- [x] **ST-306** Story uit Sprint verwijderen + - Verwijderknop per story in Sprint Backlog; `removeStoryFromSprintAction` Server Action (wist `sprint_id`, zet status terug op `OPEN`); story verdwijnt links en badge verdwijnt rechts + - Done when: verwijderen persistent; story beschikbaar in Product Backlog rechterpaneel + +- [x] **ST-307** Sprint Planning scherm — layout + - `/products/[id]/sprint/planning` pagina; `SplitPane` met Sprint Backlog stories links (op volgorde) en taken van geselecteerde story rechts; Sprint Goal zichtbaar; lege staat rechts als geen story geselecteerd + - Done when: pagina rendert; story selecteren links toont taken rechts + +- [x] **ST-308** Taak aanmaken + - "Taak aanmaken" knop in rechterpaneel navigatiebar; inline formulier (titel, omschrijving, prioriteit); `createTask` Server Action; voortgangsindicator per story (bijv. "0/0 Done") + - Done when: taak aangemaakt en zichtbaar in takenlijst; voortgangsindicator toont "0/1 Done" + +- [x] **ST-309** Taak drag-and-drop (verticaal) + - dnd-kit verticale `SortableContext` in rechterpaneel; herrangschikking via float-gemiddelde in `usePlannerStore.taskOrder`; `reorderTasksAction` Server Action + - Done when: taken versleepbaar; volgorde persistent na loslaten + +- [x] **ST-310** Taakstatus bijhouden + - Status-toggle per taak (TO_DO → IN_PROGRESS → DONE) via klikbare badge of dropdown; `updateTaskStatus` Server Action; voortgangsindicator op story updatet optimistisch + - Done when: taak op DONE zetten verhoogt teller in voortgangsindicator; persistent na herlaad + +- [x] **ST-311** Taak bewerken en verwijderen + - Inline bewerken van titel, omschrijving en prioriteit; `updateTask` Server Action; verwijderen met bevestiging; `deleteTask` Server Action + - Done when: titelwijziging persisteert; verwijderde taak verdwijnt uit lijst + +- [x] **ST-312** Sprint afronden + - "Sprint afronden" knop op Sprint-pagina; dialoog toont per story de status en vraagt: "Markeer als Done of terug naar Backlog?"; `completeSprint` Server Action zet Sprint op COMPLETED, verwerkt keuzes per story + - Done when: Sprint afgerond; stories correct verplaatst naar DONE of OPEN; nieuwe Sprint aanmaakbaar + +- [x] **ST-313** Sprint Board — drie-panelen layout (vervangt ST-303 + ST-307) + - **Doel:** `/products/[id]/sprint` wordt één scherm met drie panelen van links naar rechts: Product Backlog · Sprint Backlog · Taken. De losse `/sprint/planning` route wordt verwijderd (redirect → `/sprint`). + - **Panelen:** + - *Links — Product Backlog:* PBIs met stories gegroepeerd en inklapbaar; stories die al in sprint zijn grijs/disabled; klikken of slepen voegt story toe aan Sprint Backlog (midden) + - *Midden — Sprint Backlog:* stories in sprint op volgorde; klikken selecteert story → taken laden rechts; versleepbaar om te sorteren; trash-knop verwijdert uit sprint + - *Rechts — Taken:* `TaskList` voor de geselecteerde story; lege staat "Selecteer een story" als niets geselecteerd; "+ Taak" knop zoals huidig + - **Layout:** `TriplePane` component — drie verticale panelen met twee versleepbare scheidingslijnen; opslaan in `localStorage` per product (key: `sprint-triple-${productId}`) + - **DnD:** één `DndContext` omhult alle drie panelen; drag van links naar midden werkt via `DragOverlay`; reorder binnen midden via `SortableContext`; taken-reorder in eigen geneste `DndContext` + - **State:** `SprintBoardClient` beheert sprint stories, product backlog data, `selectedStoryId`, en taken per story (vanuit server props); `useSelectionStore.selectedStoryId` voor story-selectie + - **Navigatie:** "Sprint Planning →" link onderaan Sprint Backlog pagina verwijderd; `SprintHeader` blijft bovenaan met "Sprint afronden" + - **Route cleanup:** `/sprint/planning/page.tsx` vervangt door redirect naar `/products/[id]/sprint`; `PlanningLeft`, `PlanningRightClient` components verwijderen + - Done when: één `/sprint` pagina toont alle drie panelen; story slepen van links naar midden werkt; story selecteren toont taken rechts; taak aanmaken en sorteren werkt; pagina hervat na herlaad met juiste data; `/sprint/planning` redirect werkt + +--- + +### M3.5: Solo Paneel & Story Assignment + +> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `specs/functional.md#solo-panel`. + +- [x] **ST-350** Story.assignee_id schema-migratie + auth-helpers + - **Schema:** voeg `assignee_id String?` + `assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)` toe aan `Story`; voeg `assigned_stories Story[] @relation("StoryAssignee")` toe aan `User`; voeg index `@@index([sprint_id, assignee_id])` toe; migratie via `prisma migrate dev --name add_story_assignee` + - **Auth-helpers:** schrijf `lib/auth.ts` met `getSession`, `requireUser`, `requireWriter`, `requireProductAccess`, `requireProductWriter` — laatste twee doen membership-check via owner (`Product.user_id`) OF lid (`ProductMember`); demo-check op basis van `session.isDemo` (uit ST-006); throwt *"Niet beschikbaar in demo-modus"* bij demo-write-poging + - Done when: migratie slaagt; `requireProductWriter` blokkeert demo-user; `requireProductAccess` accepteert zowel owner als member + +- [x] **ST-351** `` herbruikbare component + - Wrapper rond shadcn `Avatar`; props: `userId`, `username`, `size` ('xs' | 'sm' | 'md' | 'lg'), `className`; `` met fallback naar initialen (eerste 2 tekens username) op `bg-primary-container`; vier groottes via Tailwind classes + - Done when: avatar rendert in 4 sizes; bij ontbrekende avatar-data (404) fallback naar initialen zichtbaar; component bruikbaar in story-kaart, sprint board, instellingen + +- [x] **ST-352** Story-claim Server Actions + - Vier acties in `actions/stories.ts`: `claimStoryAction` (zet `assignee_id = currentUserId`), `unclaimStoryAction` (null), `reassignStoryAction` (valideert dat target user lid van product is), `claimAllUnassignedInActiveSprintAction` (bulk via `updateMany` voor ongeclaimde stories in actieve sprint); allemaal Zod-gevalideerd, achter `requireProductWriter`, met `revalidatePath` voor `/sprint` én `/solo`; tenant-guard via `where: { id, product_id }` + - Done when: alle vier acties testbaar via testbestand; demo-user krijgt foutmelding; reassignment naar niet-lid faalt met foutmelding; bulk claimt alleen ongeclaimde + +- [x] **ST-353** Sprint Board: assignee-chip + dropdown menu op story-kaart + - Op story-kaart in middenpaneel van ST-313 Sprint Board: assignee-chip onderaan met `` + username (of muted "Niet geclaimd" badge als `assignee_id === null`); shadcn `DropdownMenu` (3-dots rechtsboven) met items "Pak op" / "Geef terug aan team" / "Wijs toe aan ▶" (submenu met members); items conditioneel zichtbaar op basis van huidige assignee; demo-modus: dropdown disabled met tooltip "Niet beschikbaar in demo-modus" + - Done when: chip toont juiste state; dropdown roept juiste acties aan; revalidatie ververst kaart; toast "Story geclaimd" / "Toegewezen aan X" bij succes; demo-user ziet disabled-tooltip + +- [x] **ST-354** Sprint Board: bulk-claim knop "Claim alle ongeclaimde" + - Knop bovenaan Sprint Backlog paneel met telling: "Claim alle ongeclaimde stories (N)"; disabled als N=0 of `isDemo`; klik roept `claimAllUnassignedInActiveSprintAction` aan; Sonner success-toast "{count} stories geclaimd"; pending state via `useTransition` + - Done when: telling correct; claimen werkt; knop disabled bij 0 ongeclaimd of demo; toast verschijnt na succes + +- [x] **ST-355** Solo route — `/solo` redirect + `/products/[id]/solo` pagina + cookie + - **Cookie-helper:** schrijf `lib/cookies.ts` met `setLastProductCookie(productId)` (HTTP-only, sameSite lax, 30 dagen) + - **`/solo` page.tsx:** Server Component; leest cookie `lastProductId`; valideert toegang en redirect naar `/products/[id]/solo`, of toont `` als geen cookie of cookie ongeldig + - **`/products/[id]/solo` page.tsx:** Server Component; haalt active sprint op (404 → empty state ``); haalt taken op via `Task.findMany` met `where: { sprint_id, story: { assignee_id: session.userId } }` + count ongeclaimde stories parallel; geeft data door aan ``; zet `lastProductId` cookie bij elk bezoek + - **Empty state:** `` met titel, uitleg, link naar productpagina + - **``:** lijst van toegankelijke producten, klikken redirect naar `/products/[id]/solo` + - Done when: `/solo` zonder cookie toont picker; met geldige cookie redirect; pagina toont juiste taken; geen actieve sprint toont empty state; cookie persisteert tussen sessies + +- [x] **ST-356** Solo Kanban-bord met DnD en Zustand + - **Store `stores/solo-store.ts`:** `tasks`, `initTasks`, `optimisticMove(taskId, toStatus)` (returnt vorige status), `rollback(taskId, prevStatus)`, `updatePlan(taskId, plan)`; volgt patroon van `usePlannerStore` (ST-201) + - **`` Client Component:** root met `DndContext` (overslaan als `isDemo`), `PointerSensor` met `activationConstraint: { distance: 5 }`, `closestCorners` collision detection; header met productnaam, sprint goal, knop "Toon openstaande stories (N)"; grid met drie kolommen + - **``:** drop target per status (`TO_DO` / `IN_PROGRESS` / `DONE`); header met statuskleur via MD3 tokens (`bg-status-todo/15` etc.); count en lege staat + - **``:** hergebruik bestaande task-card (ST-310); draggable; toont prioriteit-indicator, taaktitel, story-titel; klik opent detail-dialoog (ST-357); demo: niet draggable + - **`onDragEnd` flow:** optimistische update via `optimisticMove`, dan `updateTaskStatusAction` aanroepen, op error rollback + Sonner error-toast "Status bijwerken mislukt — taak teruggeplaatst"; geen success-toast (te frequent) + - Done when: kaart sleepbaar tussen kolommen; status persisteert; gesimuleerde server-fout rollbackt UI; demo-user kan niet slepen + +- [x] **ST-357** Task detail-dialoog + `updateTaskPlanAction` + - **`updateTaskPlanAction`** in `actions/tasks.ts`: Zod-schema `{ taskId, productId, implementationPlan }`; `requireProductWriter`; tenant-guard via `where: { id: taskId, story: { product_id: productId } }`; `revalidatePath` + - **``** shadcn `Dialog`: header met taaktitel + statusbadge (MD3 tokens); sectie *Beschrijving* (read-only, volg bestaand patroon); sectie *Implementatieplan* met `