* docs(ST-1101..1108): add M11 — Claude question-channel milestone to backlog
Plant acht stories ST-1101..ST-1108 voor het persistente vraag-antwoord-kanaal
tussen Claude (MCP) en de actieve gebruiker. Eerste concrete uitwerking van
de AI-driven dev-flow-richting (strategisch besluit "B" uit overleg na M10).
Beveiligingsuitgangspunt: atomic answer via updateMany WHERE status='open',
demo-blok op write-tools, access-check via productAccessFilter in DB-query én
SSE-filter, cron-endpoint via Bearer-secret, geen vraag/antwoord-tekst in logs.
Hergebruikt bestaande scrum4me_changes-channel (uitgebreid met entity:'question')
en het LISTEN/NOTIFY+ReadableStream-pattern uit M8/M10. Nieuw: user-scoped SSE
op /api/realtime/notifications zodat de bell globaal werkt over producten heen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(M11): swap demo-active sprint from M10 to M11
M10 is gemerged en afgesloten — M11 wordt de nieuwe demo-actieve milestone
zodat get_claude_context (via MCP) ST-1101 als next-story teruggeeft.
Drie maps in parse-backlog.ts uitgebreid: M11 priority=4, goal omschrijving,
sprint_status='ACTIVE'. M10 → COMPLETED.
Vereist npx prisma db seed na deze commit zodat de live DB de nieuwe
sprint-state weerspiegelt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(ST-1108): add F-11b — Claude question-channel to functional spec
Voegt feature-omschrijving toe naast bestaande F-11 (Claude Code REST API).
Beschrijft het verloop (Claude → MCP-tool → DB → trigger → SSE → user → answer
→ trigger → Claude polls), acceptatiecriteria (8 items), randgevallen (offline-
Claude, assignee-change, expiry, abuse) en datamodel (claude_questions tabel).
Persona Lars als primair, Dina secundair voor klant-werk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(M11): drop parser ACTIVE-flip; sprint goes via UI from now on
Bij M9/M10 hebben we de seed-flip (MILESTONE_SPRINT_STATUS pivot) gebruikt om
nieuwe stories als IN_SPRINT in een verse sprint te krijgen. Dat werkt maar
is fragiel:
- npm run seed wist user-data
- de "sprint" die de seed maakt is geen echte planning-actie
- bij multi-product scenario's breekt het model
Vanaf M11 gebruiken we de bestaande Sprint-creatie-UI van Scrum4Me. Stories
voor M11 worden via scripts/insert-milestone.ts (idempotent insert, geen
seed-reset) aan de DB toegevoegd; de gebruiker maakt zelf een Sprint aan in
/products/[scrum4me]/sprint en sleept ST-1101..1108 ernaartoe.
Parser-map M11 dus terug naar COMPLETED zodat een eventuele re-seed niet meer
een fake sprint aanmaakt voor M11-stories.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ST-1101): add ClaudeQuestion model + notify_question_change trigger
Schema (prisma/schema.prisma):
- Nieuw model ClaudeQuestion: id (cuid), story_id (FK Cascade), task_id?
(FK SetNull), product_id (FK Cascade — gedenormaliseerd uit story.product_id
voor SSE-filter zonder join), asked_by (FK Restrict — Claude-token-houder),
question (Text), options (Json? — string[] voor multi-choice), status
('open'|'answered'|'cancelled'|'expired'), answer (Text?), answered_by
(FK SetNull), answered_at?, created_at, expires_at
- Indexes: (story_id, status), (product_id, status), (status, expires_at)
- Back-relations: User.asked_questions (ClaudeQuestionAsker),
User.answered_questions (ClaudeQuestionAnswerer), Story.claude_questions,
Task.claude_questions, Product.claude_questions
Migratie (20260427224849_add_claude_questions):
- Prisma-gegenereerde DDL voor claude_questions + indexes + 5 FK's
- Toegevoegde notify_question_change() functie + claude_questions_notify trigger
op AFTER INSERT/UPDATE
- Emit op BESTAANDE scrum4me_changes-channel met entity:'question' (i.t.t. M10
dat eigen scrum4me_pairing-channel kreeg) — solo-route in ST-1104 moet
entity='question' wegfilteren om regressie op solo-board te voorkomen
- Trigger leest story.assignee_id voor "wacht op jou"-emphase in payload
- DELETE niet ondersteund — questions gaan naar answered/cancelled/expired
Verification: Node pg-client roundtrip via DATABASE_URL toonde correcte payloads
bij INSERT (op=I, status=open) en UPDATE (op=U, status=answered) met alle FK-IDs
en assignee_id correct uit story-join.
Volgende stap M11: ST-1102 — vier MCP-tools in scrum4me-mcp-repo
(ask_user_question, get_question_answer, list_open_questions, cancel_question).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ST-1103): add answerQuestion server action
actions/questions.ts:
- answerQuestion(questionId, answer) — auth + Zod + demo-blok + access-check
via productAccessFilter (anyone met product-membership mag antwoorden,
consistent met Scrum self-organizing — niet alleen story-assignee)
- Atomic prisma.claudeQuestion.updateMany WHERE id + status='open' +
expires_at>now → status='answered'; concurrent dubbele submit: één wint
(count=1), rest count=0 met disambiguatie via second findFirst
- revalidatePath('/', 'layout') refresh't NavBar bell-count voor SSR-paths;
realtime updates voor andere clients gaan via SSE in ST-1104/1105
- Begrijpelijke NL-foutmeldingen voor elk faalpad
Tests __tests__/actions/questions.test.ts (6 cases):
- happy: status update + revalidatePath called
- demo-block: error + geen DB-call + geen revalidate
- geen access: error + geen update
- al-answered: race-error 'is al answered'
- expired: race-error 'is verlopen'
- lege answer: Zod-validatie
Quality gates: lint 0 errors, tsc clean, vitest 145/145 (17 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ST-1104): add user-scoped /api/realtime/notifications + filter solo-route
Twee delen:
1. Solo-route filter (1-regel-fix in app/api/realtime/solo/route.ts):
- NotifyPayload uitgebreid met entity:'question'
- shouldEmit returnt direct false bij entity='question'
Voorkomt dat solo-clients M11 question-events ontvangen (geen lekkage naar
het Solo-bord; geen onnodig netwerk-verkeer; loose coupling tussen features).
2. Nieuwe SSE-route app/api/realtime/notifications/route.ts:
- User-scoped (geen ?product_id=); query alle accessible product-IDs één keer
bij connect via productAccessFilter
- LISTEN scrum4me_changes; filter entity='question' && product_id ∈ accessible
- Initial-state-event NA LISTEN actief (race-fix conform M10 ST-1004):
query open vragen voor deze user's accessible products, stuur als event:state
met summary (id, story_code/title, assignee_id, question, options, expires_at)
- Hergebruikt het pg.Client + ReadableStream + heartbeat 25s + hard-close 240s +
abort-cleanup-pattern uit solo-route
Tests __tests__/api/notifications-stream.test.ts:
- 401 zonder iron-session cookie (en geen DB-call)
- Solo-route filter wordt visueel/E2E gedekt in ST-1108-acceptatie
Quality gates: lint 0 errors, tsc clean, vitest 146/146 (18 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ST-1105): add NavBar bell + sheet + answer-modal + Zustand store + SSE hook
UI-volledig voor de Claude vraag-antwoord-flow (M11). Bel-icon links van avatar
in NavBar; klik opent slide-over rechts met openstaande vragen; klik op een vraag
opent een modal voor antwoord. Story-assignee = current user krijgt visuele
"voor jou"-emphase met primary-container accent en error-color badge-ring.
Bestanden:
- stores/notifications-store.ts — Zustand store met init/upsert/remove +
openCount/forYouCount selectors (vereenvoudigd vs solo-store: geen pendingOps,
geen optimistic-echo-onderdrukking)
- lib/realtime/use-notifications-realtime.ts — EventSource hook met state-
event en message-event handling, exponential-backoff reconnect, Page
Visibility pause-resume
- components/notifications/notifications-bridge.tsx — Server Component die
initial open-questions fetcht via productAccessFilter
- components/notifications/notifications-realtime-mount.tsx — tiny client
island dat de store hydrateert + de hook activeert
- components/notifications/notifications-sheet.tsx — shadcn Sheet met item-
lijst, "voor jou"-accent voor assignee-vragen, lege staat
- components/notifications/answer-modal.tsx — Dialog met options-radio of
free-text Textarea (max 4000), char-counter, demo-blok via Tooltip; bij
succes optimistisch remove + sheet blijft open zodat meerdere vragen
achter elkaar te beantwoorden zijn
- components/shared/notifications-bell.tsx — Bell-icon met badge (count >9 → "9+"),
ring-accent als forYouCount > 0, ARIA-label voor screenreaders
Wiring:
- components/shared/nav-bar.tsx — <NotificationsBell /> rechts naast <UserMenu>
- app/(app)/layout.tsx — <NotificationsBridge /> naast <SoloRealtimeBridge />,
user.id (server-side) als prop
base-ui-aanpassingen: SheetTrigger/TooltipTrigger gebruiken render-prop ipv
asChild (geen Radix).
Quality gates: lint 0 errors, tsc clean, vitest 146/146, npm run build groen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ST-1106): add cross-product access-isolation test for notifications SSE
Demo-policy + assignee-emphase zaten al in eerdere stories:
- answerQuestion demo-blok in actions/questions.test.ts (ST-1103)
- AnswerModal demo-tooltip in components/notifications/answer-modal.tsx (ST-1105)
- requireWriteAccess in MCP write-tools (ST-1102)
Deze story voegt expliciet een access-isolation-test toe op de notifications-
SSE-route: productAccessFilter wordt met de echte userId aangeroepen, en
prisma.product.findMany filter't op archived=false + user_id-scope. Dat
garandeert dat een gebruiker geen question-events ontvangt voor producten waar
hij geen membership op heeft.
Story-assignee-emphase blijft visueel-only (NotificationsBell ring-accent +
Sheet primary-container) — toegang werkt product-membership-breed zodat een
team-lid kan invallen als de assignee niet beschikbaar is.
Quality gates: lint 0 errors, tsc clean, vitest 147/147 (was 146).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ST-1107): add Vercel cron expire-questions (+ M10 pairing cleanup)
POST /api/cron/expire-questions:
- Auth via Authorization: Bearer ${CRON_SECRET} (Vercel injecteert dit
automatisch wanneer de env-var op de project-omgeving staat); 401 als secret
niet matcht of niet is gezet (faal-veilig — geen open endpoint in dev)
- updateMany op claude_questions WHERE status='open' AND expires_at<now →
'expired'
- Bonus: zelfde route ruimt M10 login_pairings op (status='pending' AND
expires_at<now → 'cancelled'). Eén cron-job is goedkoper qua Vercel-budget
en houdt cleanup-strategie centraal — opvolg-actie uit M10 dat geparkeerd was.
Config:
- vercel.json: crons-entry { path: '/api/cron/expire-questions', schedule: '0 */6 * * *' } (4x/dag)
- lib/env.ts: CRON_SECRET als optional in Zod-schema
- .env.example: documentatie + openssl rand-tip
Tests __tests__/api/cron-expire-questions.test.ts (4 cases):
- 401 zonder Authorization-header
- 401 met verkeerde secret
- 401 als CRON_SECRET niet is gezet (faal-veilig)
- 200 met juiste secret: response { expired_questions, expired_pairings, ran_at }
+ beide updateMany WHERE/data correct
Quality gates: lint 0 errors, tsc clean, vitest 151/151.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(ST-1108): document M11 question-channel — API + architecture + pattern
docs/API.md — twee nieuwe secties:
- 'Notifications' met /api/realtime/notifications SSE-endpoint (event-shapes,
filter-rules, voorbeeld)
- 'Cron — Expire questions' met /api/cron/expire-questions (Bearer-auth,
schedule, response-shape, manual curl)
docs/scrum4me-architecture.md — nieuw hoofdstuk 'Vraag-antwoord-kanaal Claude
↔ user' tussen QR-pairing-flow en Projectstructuur:
- Mermaid sequence-diagram (Claude → DB → trigger → SSE → user → answer →
trigger → Claude polls)
- Threat-model-tabel (race, demo-misbruik, cross-product leak, cron-misbruik,
growth, log-leakage)
- Subsectie 'Waarom hergebruik scrum4me_changes-kanaal' met trade-off vs M10's
eigen-kanaal-aanpak
docs/patterns/claude-question-channel.md — herbruikbaar pattern 'Bidirectionele
async-comms tussen MCP-agent en interactieve user' met de vier eindpunten,
vier security-uitgangspunten, channel-strategie-tabel, TTL-richtlijn, en
sjabloon-bestanden per laag (DB / server / client / MCP-tools).
CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe
pattern-doc verwijst.
Acceptatie 6 scenario's:
1. Sync happy path (MCP wait_seconds + UI submit) — handmatig getest tijdens
ST-1105 acceptance-loop met de q-test injection
2. Async happy path — gedekt door get_question_answer-tool in ST-1102 +
list_open_questions
3. Demo-block — actions/questions.test.ts (case 2: demo-user) + AnswerModal
tooltip (visueel)
4. Access-isolation — notifications-stream.test.ts (case 'access-isolation')
5. Expiry — cron-expire-questions.test.ts (case '200 met juiste secret')
6. Race — actions/questions.test.ts (case 'al-answered' via atomic updateMany)
Quality gates: lint 0 errors, tsc clean, vitest 151/151 (19 files), npm run
build groen.
M11 is hiermee feature-compleet. feat/M11-claude-questions heeft 12 commits
lokaal, klaar voor user-acceptatie en PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ST-1107): cron schedule daily — Vercel Hobby allows only 1 run/day
Vercel deploy faalde met:
> Hobby accounts are limited to daily cron jobs.
> This cron expression (0 */6 * * *) would run more than once per day.
Schedule van 4×/dag (0 */6 * * *) naar 1×/dag (0 4 * * * — 04:00 UTC, rustig
tijdstip). Functioneel acceptabel: ClaudeQuestion TTL is 24u, dus daily
cleanup pakt alles dat in de afgelopen 24u verlopen is. Login-pairings TTL
is 2 min — die zijn al onbruikbaar zodra ze expiren, cron is alleen voor
status-housekeeping.
Schedule-referenties consistent bijgewerkt in docs (API.md, architecture,
backlog M11-sectie, plan-doc, pattern-doc) + comment in route.ts. Vermelding
overal dat dit een Hobby-plan-beperking is en Pro fijnmaziger ondersteunt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1046 lines
46 KiB
Markdown
1046 lines
46 KiB
Markdown
# Scrum4Me — Technische Architectuur
|
||
|
||
**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 |
|
||
|
||
---
|
||
|
||
## 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) | |
|
||
| 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 |
|
||
| created_at | DateTime | default now() | |
|
||
| updated_at | DateTime | auto-update | |
|
||
|
||
**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm
|
||
|
||
---
|
||
|
||
### `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)`
|
||
|
||
---
|
||
|
||
### `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 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
|
||
title String
|
||
description String?
|
||
priority Int
|
||
sort_order Float
|
||
created_at DateTime @default(now())
|
||
updated_at DateTime @updatedAt
|
||
stories Story[]
|
||
|
||
@@index([product_id, priority, sort_order])
|
||
}
|
||
|
||
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 <token> 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 }<br/>Set-Cookie: s4m_pair=desktopToken
|
||
D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
|
||
D->>S: GET /api/auth/pair/stream/[pairingId]<br/>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<br/>pg_notify scrum4me_pairing
|
||
S-->>D: data { status: 'approved' }
|
||
|
||
D->>S: POST /api/auth/pair/claim<br/>Cookie: s4m_pair, body: { pairingId }
|
||
S->>S: atomic UPDATE WHERE status=approved AND token-hash<br/>→ status=consumed
|
||
S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
|
||
S-->>D: 200, Set-Cookie: scrum4me-session<br/>+ 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)<br/>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
|
||
│ ├── planner-store.ts # Optimistische drag-and-drop volgorde
|
||
│ ├── selection-store.ts # Geselecteerd PBI / story
|
||
│ ├── 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<string, string[]> // productId → pbi-ids
|
||
storyOrder: Record<string, string[]> // pbiId → story-ids
|
||
taskOrder: Record<string, string[]> // 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 <BacklogPanel productId={id} initialPbiIds={pbis.map(p => 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<string, string[]> // 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<string, SoloTask>
|
||
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 op `/solo` is.
|
||
- **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.
|
||
|
||
---
|
||
|
||
## 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.
|