Scrum4Me/docs/scrum4me-architecture.md
Janpeter Visser 1cb5772edd
M12 / ST-1110: Demo gebruiker read-only (#17)
* 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
2026-04-29 18:44:14 +02:00

49 KiB
Raw Blame History

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 14, 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 14, 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 14, 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/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:

  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

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_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.


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 via getIronSession(await cookies(), sessionOptions) — blokkeert demo-desktops
  • pair/claim: check pairing.user?.is_demo na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd
  • pair/approve en pair/cancel: waren al geblokkeerd vóór ST-1110

Realtime SSE en cron-routes: niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth).

Laag 3 — UI-laag (DemoTooltip)

Alle write-knoppen zijn disabled met een DemoTooltip show={isDemo} wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: components/shared/demo-tooltip.tsx.

Patroon:

<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_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) ~€12/maand
Totaal €02/maand

Bij groei naar meerdere gebruikers (v2): Neon Launch plan ($19/maand) en Vercel Pro ($20/maand) zijn de eerste stappen omhoog.