* feat(ST-1110.3): add proxy.ts demo-guard for non-GET API routes
* feat(ST-1110.3+4): demo-guard proxy + block demo in QR-pairing
- proxy.ts: gebruik unsealData ipv getIronSession (middleware-compatibel)
- pair/start: isDemo-check via cookies() guard
- pair/claim: check pairing.user.is_demo na DB-read; 403 + clearPairCookie
* feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip
Convert all !isDemo && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
* test(ST-1110.6): proxy demo-guard coverage — 403 for demo+non-GET on /api/*
* docs(ST-1110.7): document three-layer demo-readonly policy and mirror plan
49 KiB
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) | |
| 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)
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_membersbevat(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_idofstory_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/updateManywanneer een uniquedeleteanders onveilig zou zijn.
Prisma Schema (excerpt)
// 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 <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
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:
- URL-fragment voor
mobileSecret. Het deel achter de#wordt door browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client Component leestwindow.location.hashen POST't de waarde in een body — ook niet in een URL. - HttpOnly cookie voor
desktopToken. Cookie-headers worden meestal NIET in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendienPath=/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
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.
// 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:
// 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.
// 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.
// 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.
// 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.
// 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_idmatcht de query-paramsprint_idmatcht de actieve sprint van het product (resolve éénmaal per connect)assignee_idis gelijk aan de ingelogdeuserId(ofnullvoor 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/solois. - Reconnect: exponential backoff bij
onerror(1s → 30s, reset bijreadyevent). - Pause op tab-hidden:
document.visibilityState === 'hidden'sluit de stream actief. Bijvisiblewordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. - Hard close: server sluit zelf na 240s (Vercel
maxDurationis 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.
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.
// 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:
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
QR-pairing (M10):
pair/start: isDemo-check viagetIronSession(await cookies(), sessionOptions)— blokkeert demo-desktopspair/claim: checkpairing.user?.is_demona DB-read — blokkeert demo-users die op mobiel hebben goedgekeurdpair/approveenpair/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:
<DemoTooltip show={isDemo}>
<Button disabled={isDemo} onClick={() => !isDemo && handleAction()}>
Actie
</Button>
</DemoTooltip>
Let op: drag-and-drop handles (⠿) blijven verborgen voor demo ({!isDemo && <span {...listeners} />}) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.
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:
# 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_URLenDIRECT_URLgezet in Vercel dashboard (Neon connection strings)SESSION_SECRETgezet in Vercel dashboard (min. 32 tekens)prisma migrate deployuitgevoerd 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 buildlokaal 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.