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 `