diff --git a/docs/INDEX.md b/docs/INDEX.md index e77092a..f6a2769 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -63,7 +63,13 @@ Auto-generated on 2026-05-02 from front-matter and headings. | Title | Path | Status | Updated | |---|---|---|---| | [Scrum4Me REST API](./api/rest-contract.md) | `api/rest-contract.md` | active | 2026-05-03 | -| [Scrum4Me — Technische Architectuur](./architecture.md) | `architecture.md` | active | 2026-05-03 | +| [Scrum4Me — Technische Architectuur (breadcrumb)](./architecture.md) | `architecture.md` | active | 2026-05-03 | +| [Authentication, Sessions & Demo Policy](./architecture/auth-and-sessions.md) | `architecture/auth-and-sessions.md` | active | 2026-05-03 | +| [Claude ↔ User Question Channel](./architecture/claude-question-channel.md) | `architecture/claude-question-channel.md` | active | 2026-05-03 | +| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-03 | +| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-03 | +| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-03 | +| [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 | | [Scrum4Me — Implementatie Backlog](./backlog/index.md) | `backlog/index.md` | active | 2026-05-03 | | [DevPlanner — Product Backlog](./backlog/product-historical.md) | `backlog/product-historical.md` | active | 2026-05-03 | | [Agent Instruction Audit](./decisions/agent-instructions-history.md) | `decisions/agent-instructions-history.md` | active | 2026-05-03 | diff --git a/docs/architecture.md b/docs/architecture.md index 504a009..8ada2a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,5 @@ --- -title: "Scrum4Me — Technische Architectuur" +title: "Scrum4Me — Technische Architectuur (breadcrumb)" status: active audience: [maintainer, contributor] language: nl @@ -8,1248 +8,13 @@ last_updated: 2026-05-03 # Scrum4Me — Technische Architectuur -**Versie:** 0.1 — april 2026 -**Volgt op:** Functionele Specificatie v0.2 +> Dit bestand is een breadcrumb. De inhoud is opgesplitst in topische bestanden. ---- - -## Architectuursamenvatting - -Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. - ---- - -## Stack - -| Laag | Technologie | Rationale | -|---|---|---| -| Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management | -| UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions | -| Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg | -| Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict | -| Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund | -| Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in | -| ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch | -| Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side | -| Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers | -| REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel | -| Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL | -| Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` | -| Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 | -| CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af | - ---- - -## Wat we NIET gebruiken (en waarom) - -| Technologie | Afgewezen omdat | +| Onderwerp | Bestand | |---|---| -| Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt | -| NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten | -| Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code | -| Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | -| React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | -| Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | -| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | -| Redis | Geen caching- of queuerequirements op deze schaal | -| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | -| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | -| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | - ---- - -## Datamodel - -### `users` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | Gegenereerd door Prisma | -| username | String | unique, not null, min 3 | Inlognaam | -| password_hash | String | not null | bcrypt hash (cost factor 12) | -| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten | -| bio | String | nullable, max 160 | Korte profielomschrijving | -| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving | -| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL | - -**Indexes:** `username` (unique lookup bij inloggen) - ---- - -### `user_roles` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | | - -**Indexes:** `(user_id)` — meerdere rollen per gebruiker -**Constraint:** unique `(user_id, role)` - ---- - -### `api_tokens` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| token_hash | String | not null | SHA-256 hash van het token | -| label | String | nullable | Bijv. "Claude Code — laptop" | -| created_at | DateTime | default now() | | -| revoked_at | DateTime | nullable | Null = actief | - -**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn) - ---- - -### `products` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| name | String | not null, max 200 | Uniek per gebruiker | -| description | String | nullable, max 1000 | | -| repo_url | String | nullable | Gevalideerde URL | -| definition_of_done | String | not null, max 500 | Vaste instelling per product | -| archived | Boolean | default false | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten -**Constraint:** unique `(user_id, name)` - ---- - -### `pbis` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| code | String | nullable, max 30 | Auto-gegenereerd of handmatig | -| title | String | not null, max 200 | | -| description | String | nullable, max 2000 | | -| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag | -| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering | -| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog - -**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. - ---- - -### `stories` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| pbi_id | String | FK → pbis (cascade delete) | | -| product_id | String | FK → products | Denormalisatie voor snellere queries | -| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog | -| title | String | not null, max 200 | | -| description | String | nullable, max 2000 | | -| acceptance_criteria | String | nullable, max 2000 | | -| priority | Int | 1–4, not null | | -| sort_order | Float | not null | | -| status | Enum | OPEN \| IN_SPRINT \| DONE | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` - -**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). - ---- - -### `story_logs` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| story_id | String | FK → stories (cascade delete) | | -| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | | -| content | String | not null | Tekst van plan of testuitvoer | -| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT | -| commit_hash | String | nullable | Alleen bij type COMMIT | -| commit_message | String | nullable | Alleen bij type COMMIT | -| created_at | DateTime | default now() | | - -**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI - ---- - -### `sprints` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| sprint_goal | String | not null, max 500 | | -| status | Enum | ACTIVE \| COMPLETED | | -| created_at | DateTime | default now() | | -| completed_at | DateTime | nullable | | - -**Indexes:** `(product_id, status)` — query voor actieve Sprint per product -**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag) - ---- - -### `tasks` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| story_id | String | FK → stories (cascade delete) | | -| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries | -| title | String | not null, max 200 | | -| description | String | nullable, max 1000 | | -| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | -| priority | Int | 1–4, not null | | -| sort_order | Float | not null | | -| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` - ---- - -### `todos` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users, not null | | -| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | -| title | String | not null | | -| done | Boolean | default false | | -| archived | Boolean | default false | | -| created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | | - -**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product - ---- - -### `product_members` - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| product_id | String | FK → products (cascade delete) | | -| user_id | String | FK → users (cascade delete) | | -| created_at | DateTime | default now() | | - -**Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is -**Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product - -Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. - ---- - -## Toegangsmodel en schrijfbeveiliging - -Producttoegang is centraal gedefinieerd als: - -- eigenaar: `products.user_id === gebruiker.id` -- teamlid: `product_members` bevat `(product_id, user_id)` - -Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. - -Schrijfoperaties volgen deze invarianten: - -- Controleer eerst authenticatie en `session.isDemo`. -- Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging. -- Controleer de parent-resource met `productAccessFilter`. -- Vertrouw bulk-ID's nooit los: haal de records eerst op met `id in (...)` plus de parent-scope (`product_id`, `pbi_id`, `sprint_id` of `story_id`) en weiger de operatie als aantallen niet exact overeenkomen. -- Weiger dubbele IDs in reorder- en beslissingslijsten. -- Leid denormalized foreign keys af van de database-parent (`pbi.product_id`, `sprint.product_id`) en niet van form-data of JSON body. -- Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped `deleteMany`/`updateMany` wanneer een unique `delete` anders onveilig zou zijn. - ---- - -## Prisma Schema (excerpt) - -```prisma -// prisma/schema.prisma - -generator client { - provider = "prisma-client-js" -} - -// Database wordt bepaald via prisma.config.ts — niet hier - -enum Role { - PRODUCT_OWNER - SCRUM_MASTER - DEVELOPER -} - -enum StoryStatus { - OPEN - IN_SPRINT - DONE -} - -enum PbiStatus { - READY - BLOCKED - DONE -} - -enum TaskStatus { - TO_DO - IN_PROGRESS - REVIEW - DONE -} - -enum LogType { - IMPLEMENTATION_PLAN - TEST_RESULT - COMMIT -} - -enum TestStatus { - PASSED - FAILED -} - -enum SprintStatus { - ACTIVE - COMPLETED -} - -model User { - id String @id @default(cuid()) - username String @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - avatar_data Bytes? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] -} - -model UserRole { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - role Role - - @@unique([user_id, role]) -} - -model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? - - @@index([token_hash]) -} - -model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - name String - description String? - repo_url String? - definition_of_done String - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - pbis Pbi[] - sprints Sprint[] - stories Story[] - todos Todo[] - members ProductMember[] - - @@unique([user_id, name]) - @@index([user_id, archived]) -} - -model Pbi { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - code String? @db.VarChar(30) - title String - description String? - priority Int - sort_order Float - status PbiStatus @default(READY) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - stories Story[] - - @@unique([product_id, code]) - @@index([product_id, priority, sort_order]) - @@index([product_id, status]) -} - -model Story { - id String @id @default(cuid()) - pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) - pbi_id String - product Product @relation(fields: [product_id], references: [id]) - product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - title String - description String? - acceptance_criteria String? - priority Int - sort_order Float - status StoryStatus @default(OPEN) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - logs StoryLog[] - tasks Task[] - - @@index([pbi_id, priority, sort_order]) - @@index([sprint_id, sort_order]) - @@index([product_id, status]) -} - -model StoryLog { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - type LogType - content String - status TestStatus? - commit_hash String? - commit_message String? - created_at DateTime @default(now()) - - @@index([story_id, created_at]) -} - -model Sprint { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - sprint_goal String - status SprintStatus @default(ACTIVE) - created_at DateTime @default(now()) - completed_at DateTime? - stories Story[] - tasks Task[] - - @@index([product_id, status]) -} - -model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - title String - description String? - implementation_plan String? - priority Int - sort_order Float - status TaskStatus @default(TO_DO) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@index([story_id, priority, sort_order]) - @@index([sprint_id, status]) -} - -model Todo { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - title String - done Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@index([user_id, done, archived]) - @@index([user_id, product_id]) -} - -model ProductMember { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - created_at DateTime @default(now()) - - @@unique([product_id, user_id]) - @@index([user_id]) - @@map("product_members") -} -``` - ---- - -## Authenticatieflow - -``` -Registratie: - POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB - → iron-session cookie zetten → redirect /dashboard - -Inloggen: - POST /login → gebruiker ophalen op username → bcrypt vergelijken - → bij match: iron-session cookie zetten → redirect /dashboard - → bij mismatch: generieke foutmelding (geen onderscheid) - -Sessie per request: - proxy.ts → sessiecookie-aanwezigheid controleren - → beschermde routes: redirect /login als geen sessiecookie aanwezig is - → app layout valideert de volledige sessie server-side - -API-aanroepen (Claude Code): - Authorization: Bearer header → SHA-256 hash → opzoeken in api_tokens - → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten - -Uitloggen: - Server Action → iron-session vernietigen → redirect /login -``` - ---- - -## QR-pairing flow (M10) - -Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt -door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke -toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- -sessie heeft eigen kortere TTL (8 u) + `paired`-vlag. - -### Sequence - -```mermaid -sequenceDiagram - participant D as Desktop (anon) - participant S as Server - participant M as Mobiel (ingelogd) - - D->>S: POST /api/auth/pair/start - S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min } - S-->>D: 200 { pairingId, mobileSecret, qrUrl }
Set-Cookie: s4m_pair=desktopToken - D->>D: render QR met qrUrl (#id=…&s=mobileSecret) - D->>S: GET /api/auth/pair/stream/[pairingId]
Cookie: s4m_pair - S->>S: LISTEN scrum4me_pairing - S-->>D: event: state { status: 'pending' } - - Note over M: Gebruiker scant QR - M->>M: location.hash → mobileSecret - M->>S: getPairingForApproval(pairingId, mobileSecret) - S-->>M: { desktop_ua, desktop_ip, username } - M->>M: toont bevestigingskaart - Note over M: Tap "Bevestig" - M->>S: approvePairing(pairingId, mobileSecret) - S->>S: status pending→approved, expires +5min
pg_notify scrum4me_pairing - S-->>D: data { status: 'approved' } - - D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } - S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed - S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } - S-->>D: 200, Set-Cookie: session
+ s4m_pair cleared - D->>D: redirect /dashboard -``` - -### Threat-model - -| Aanval | Mitigatie | -|---|---| -| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 | -| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart | -| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` | -| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) | -| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) | -| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke | - -### TTL-rationale - -- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft. -- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft. -- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen. - -### Waarom geen secret in URL - -Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access -logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een -geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit: - -1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door - browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client - Component leest `window.location.hash` en POST't de waarde in een body — - ook niet in een URL. -2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET - in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien - `Path=/api/auth/pair`-scoped, dus verlaat die route nooit. - -Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` -voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch -de andere kant compromitteert. - -Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`. - ---- - -## Vraag-antwoord-kanaal Claude ↔ user (M11) - -Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. -Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een -gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het -**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`. -De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert, -filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere -gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele -emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in -een latere sessie via `get_question_answer`) en gaat door. - -### Sequence - -```mermaid -sequenceDiagram - participant C as Claude (MCP) - participant DB as Postgres - participant SC as scrum4me_changes channel - participant SSE as /api/realtime/notifications - participant U as Scrum4Me UI (browser) - - C->>DB: INSERT claude_questions (status=open) - DB->>SC: pg_notify {entity:'question', op:'I', id, ...} - SC->>SSE: notification (filter: question + product-access) - SSE->>U: data event → Zustand store upsert → bell badge - - Note over U: Gebruiker klikt bell → Sheet → Modal - U->>DB: answerQuestion(questionId, answer)
Server Action: atomic updateMany WHERE status='open' - DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'} - SC->>SSE: notification - SSE->>U: data event → store remove → bell badge -1 - - Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s - C->>DB: SELECT status FROM claude_questions WHERE id=... - DB-->>C: status='answered', answer='...' - C->>C: gaat door met implementatie -``` - -### Threat-model - -| Aanval | Mitigatie | -|---|---| -| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst | -| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal | -| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) | -| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) | -| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 4 * * *` (dagelijks; Hobby-plan-limiet) markeert `status='expired'` → uit notifications-bell | -| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst | - -### Waarom hergebruik scrum4me_changes-kanaal - -In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van -de bestaande realtime-infra. Voordelen: - -- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties -- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key -- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen - nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen - -Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie: -expliciet `if (payload.entity === 'X') return false` in elke SSE-route die -betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'` -weert). - -Dit patroon (notification-channel via een bestaande pg_notify-stream) is -herbruikbaar — zie `docs/patterns/claude-question-channel.md`. - ---- - -## Projectstructuur - -``` -scrum4me/ -├── app/ -│ ├── (auth)/ -│ │ ├── login/page.tsx -│ │ └── register/page.tsx -│ ├── (app)/ # Beschermde routes -│ │ ├── layout.tsx # Auth-check + navigatie -│ │ ├── dashboard/page.tsx # Productenlijst -│ │ ├── products/ -│ │ │ ├── new/page.tsx -│ │ │ └── [id]/ -│ │ │ ├── layout.tsx # Zet actief product in Zustand store -│ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) -│ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) -│ │ │ ├── sprint/ -│ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) -│ │ │ │ └── planning/page.tsx # Redirect → /sprint -│ │ ├── todos/page.tsx -│ │ └── settings/ -│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens -│ │ └── tokens/page.tsx -│ ├── api/ # REST API voor Claude Code -│ │ ├── products/ -│ │ │ └── [id]/ -│ │ │ └── next-story/route.ts -│ │ ├── profile/ -│ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto -│ │ ├── sprints/ -│ │ │ └── [id]/ -│ │ │ └── tasks/route.ts -│ │ ├── stories/ -│ │ │ └── [id]/ -│ │ │ ├── log/route.ts -│ │ │ └── tasks/reorder/route.ts -│ │ ├── tasks/ -│ │ │ └── [id]/route.ts -│ │ └── todos/route.ts -├── components/ -│ ├── ui/ # shadcn/ui primitieven -│ ├── split-pane/ # Gesplitst scherm component -│ ├── backlog/ # PBI- en story-componenten -│ ├── sprint/ # Sprint-componenten -│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton -│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton -│ └── dnd/ # dnd-kit wrappers -├── lib/ -│ ├── prisma.ts # Prisma Client singleton -│ ├── session.ts # iron-session configuratie -│ ├── auth.ts # login/register/token helpers -│ ├── api-auth.ts # Bearer token middleware voor API -│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) -│ └── env.ts # Zod-gevalideerde env vars -├── stores/ # Zustand stores -│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) -│ ├── planner-store.ts # Optimistische drag-and-drop volgorde -│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) -│ ├── sprint-store.ts # Sprint Backlog taakvolgordes -│ ├── solo-store.ts # Solo board optimistische taakstatus -│ └── product-store.ts # Actief product (naam + id) voor navbar -├── prisma/ -│ ├── schema.prisma -│ ├── migrations/ -│ └── seed.ts # Testdata uit Product Backlog document -├── proxy.ts # Next.js 16 proxy voor route protection -├── prisma.config.ts # Prisma v7 config (DATABASE_URL) -└── .env.example -``` - ---- - -## Sleutelarchitectuurbeslissingen - -### Beslissing: iron-session in plaats van Auth.js / Supabase Auth -**Keuze:** iron-session voor versleutelde server-side sessiecookies -**Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. -**Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. - -### Beslissing: Route Handlers naast Server Actions -**Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API -**Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. -**Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. - -### Beslissing: Float voor sort_order -**Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken -**Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). -**Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. - -### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` -**Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` -**Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. -**Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. - -### Beslissing: Zustand voor client-side state management -**Keuze:** Vijf Zustand-stores naast Server Components -**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. -**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. - ---- - -## Zustand stores - -### `usePlannerStore` — optimistische drag-and-drop volgorde - -Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency. - -```ts -// stores/planner-store.ts -import { create } from 'zustand' - -interface PlannerStore { - // Optimistische volgorde per container (id-arrays) - pbiOrder: Record // productId → pbi-ids - storyOrder: Record // pbiId → story-ids - taskOrder: Record // storyId → taak-ids - - // Initialiseren vanuit server-data (bij mount) - initPbis: (productId: string, ids: string[]) => void - initStories: (pbiId: string, ids: string[]) => void - initTasks: (storyId: string, ids: string[]) => void - - // Optimistisch updaten (vóór server-bevestiging) - reorderPbis: (productId: string, newOrder: string[]) => void - reorderStories: (pbiId: string, newOrder: string[]) => void - reorderTasks: (storyId: string, newOrder: string[]) => void - - // Terugdraaien bij server-fout - rollbackPbis: (productId: string, prevOrder: string[]) => void - rollbackStories: (pbiId: string, prevOrder: string[]) => void - rollbackTasks: (storyId: string, prevOrder: string[]) => void -} -``` - -**Gebruikspatroon:** -```ts -// 1. Server Component geeft ids door -// app/(app)/products/[id]/page.tsx -const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] }) -return p.id)} pbis={pbis} /> - -// 2. Client Component hydrateert store -// components/backlog/backlog-panel.tsx -'use client' -const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore() -useEffect(() => { initPbis(productId, initialPbiIds) }, []) - -// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action -const prevOrder = usePlannerStore(s => s.pbiOrder[productId]) -reorderPbis(productId, newOrder) -const result = await reorderPbisAction(productId, newOrder) -if (!result.success) rollbackPbis(productId, prevOrder) -``` - ---- - -### `useSelectionStore` — navigatieselectie - -Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling. - -```ts -// stores/selection-store.ts -interface SelectionStore { - selectedPbiId: string | null - selectedStoryId: string | null - selectPbi: (id: string | null) => void - selectStory: (id: string | null) => void - clearSelection: () => void -} -``` - ---- - -### `useSprintStore` — Sprint Backlog interacties - -Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen. - -```ts -// stores/sprint-store.ts -interface SprintStore { - // Stories per Sprint (optimistisch, op volgorde) - sprintStoryIds: Record // sprintId → story-ids - - initSprint: (sprintId: string, ids: string[]) => void - addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void - removeStoryFromSprint: (sprintId: string, storyId: string) => void - reorderSprintStories: (sprintId: string, newOrder: string[]) => void - rollbackSprint: (sprintId: string, prevIds: string[]) => void -} -``` - ---- - -### `useSoloStore` — Solo board optimistische taakstatus - -Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout. - -```ts -// stores/solo-store.ts -interface SoloStore { - tasks: Record - initTasks: (tasks: SoloTask[]) => void - optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null - rollback: (taskId: string, prevStatus: TaskStatus) => void - updatePlan: (taskId: string, plan: string | null) => void -} -``` - ---- - -### `useProductStore` — Actief product voor navbar - -Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie. - -```ts -// stores/product-store.ts -interface ProductStore { - currentProduct: { id: string; name: string } | null - setCurrentProduct: (id: string, name: string) => void - clearCurrentProduct: () => void -} -``` - ---- - -## Data flow architectuur - -``` -┌─────────────────────────────────────────┐ -│ Server Component (page.tsx) │ -│ Prisma query → initiële data + ids │ -│ → props naar Client Component │ -└──────────────────┬──────────────────────┘ - │ initialIds, initialData - ▼ -┌─────────────────────────────────────────┐ -│ Client Component (panel.tsx) │ -│ useEffect → store.init(ids) │ -│ dnd-kit drag → store.reorder() │ -│ → Server Action (async) │ -│ → bij fout: store.rollback()│ -└──────────────────┬──────────────────────┘ - │ selecteert state via selector - ▼ -┌─────────────────────────────────────────┐ -│ Zustand Stores │ -│ usePlannerStore useSelectionStore │ -│ useSprintStore │ -│ │ -│ Alleen ephemere UI-staat │ -│ Nooit server-data of business logic │ -└─────────────────────────────────────────┘ -``` -**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel -**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. -**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken. - ---- - -## Realtime updates (M8) - -Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: - -``` -┌─────────────────────────┐ -│ Mutatie (Prisma write) │ PATCH /api/tasks/:id -└────────────┬────────────┘ Server Action, MCP, etc. - ▼ -┌─────────────────────────┐ -│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE -│ scrum4me_notify_change()│ bouwt JSON payload -└────────────┬────────────┘ - ▼ pg_notify('scrum4me_changes', json) -┌─────────────────────────┐ -│ /api/realtime/solo │ Node runtime, dedicated pg.Client -│ LISTEN scrum4me_changes │ filtert op product + sprint + assignee -└────────────┬────────────┘ - ▼ text/event-stream -┌─────────────────────────┐ -│ EventSource (browser) │ beheerd door useSoloRealtime -│ → solo-store.handleEvent│ via flushSync + startViewTransition -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ SoloBoard re-render │ kanban-kaartje animeert naar -│ (View Transitions API) │ zijn nieuwe kolom -└─────────────────────────┘ -``` - -**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). -**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. -**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. - -### Mutaties die NOTIFY triggeren - -De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. - -### Server-side filter - -`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: -- `product_id` matcht de query-param -- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) -- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) - -Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. - -### Connection lifecycle - -- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount. -- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). -- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. -- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. -- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. - -**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. - -### Animatie - -Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. - -### Auth - -Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. - ---- - -## Realtime — Backlog SSE (ST-1115) - -De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. - -``` -┌─────────────────────────┐ -│ Mutatie (Prisma write) │ Server Action, MCP, etc. -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE -│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' -└────────────┬────────────┘ - ▼ pg_notify('scrum4me_changes', json) -┌─────────────────────────┐ -│ /api/realtime/backlog │ Node runtime, dedicated pg.Client -│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} -│ │ én product_id matcht query-param -└────────────┬────────────┘ - ▼ text/event-stream -┌─────────────────────────┐ -│ EventSource (browser) │ beheerd door useBacklogRealtime -│ → backlog-store.apply │ via applyChange(entity, op, data) -│ Change(entity,op,data)│ -└────────────┬────────────┘ - ▼ -┌─────────────────────────┐ -│ PbiList / StoryPanel / │ re-render op basis van Zustand state -│ TaskPanel re-render │ -└─────────────────────────┘ -``` - -### Hydration en SSE-mount - -De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: -1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). -2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. - -Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. - -### backlog-store en applyChange - -```ts -// stores/backlog-store.ts -applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) -``` - -- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array -- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) -- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload - -### Server-side filter (backlog) - -`/api/realtime/backlog?product_id=...` filtert op: -- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd -- `product_id` matcht de query-param - -Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. - ---- - -## Demo-user policy (ST-1110) - -Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: - -### Laag 1 — Middleware-guard (proxy.ts) - -`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`. - -```ts -// Whitelist: paden die demo mag aanroepen ondanks non-GET -const DEMO_WRITE_ALLOWLIST = [ - '/api/cron/', // machine-auth, irrelevant voor demo -] -// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2 -``` - -### Laag 2 — Per-route guards (Server Actions & Route Handlers) - -Elke schrijfactie controleert `session.isDemo` vóór DB-toegang: - -```ts -if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } -``` - -**QR-pairing (M10):** -- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops -- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd -- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110 - -**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth). - -### Laag 3 — UI-laag (DemoTooltip) - -Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`. - -Patroon: -```tsx - - - -``` - -**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. - ---- - -## Claude job queue (M13 — ST-1111) - -Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status. - -### State machine - -``` -QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE - → FAILED - → CANCELLED (door user) -CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist) -QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed) -``` - -**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd. - -### ClaudeJob model - -``` -claude_jobs - id, user_id, product_id, task_id - status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) - claimed_by_token_id (FK → api_tokens, nullable) - claimed_at, started_at, finished_at - plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim - branch, pushed_at, summary, error - verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT) - @@index([user_id, status]) - @@index([task_id, status]) - @@index([status, claimed_at]) — voor stale-claim cleanup -``` - -**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`: - -| Waarde | Betekenis | -|---|---| -| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie | -| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker | -| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft | -| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig | - -**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen. - -**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte. - -### NOTIFY/LISTEN flow - -``` -UI klikt 'Voer uit' - → enqueueClaudeJobAction() Server Action - → prisma.claudeJob.create(QUEUED) - → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...}) - → /api/realtime/solo SSE server-side filter: user_id + product_id - → EventSource.onmessage browser: handleJobEvent() - → useSoloStore.claudeJobsByTaskId map - → SoloTaskCard pill + dialog-footer update -``` - -### Idempotency - -`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken. - -### Auto-promote task-status op job-overgangen - -Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat: - -- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan. -- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is. - -De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig. - -### Hybride-ready - -De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd. - -## Environment variables - -| Variabele | Doel | Waar te vinden | -|---|---|---| -| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) | -| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) | -| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` | -| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node | - -`.env.example`: -```bash -# Database -DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" -DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require" - -# Sessie -SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output" - -# Optioneel -NODE_ENV="development" -``` - ---- - -## Deployment - -**Hosting:** Vercel (Hobby — gratis voor v1) -**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` -**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap -**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema -**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/assets/erd.svg` -**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document - -### Deployment checklist (pre-launch) - -- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings) -- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens) -- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase -- [ ] Demo-gebruiker aangemaakt via seed of handmatig -- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products` -- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek -- [ ] Vercel preview-deployments getest op een PR -- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten - ---- - -## Kostenscattting - -| Service | Plan | Maandelijkse kosten | -|---|---|---| -| Vercel | Hobby | Gratis | -| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis | -| GitHub | Free | Gratis | -| Domein | Eigen domein (optioneel) | ~€1–2/maand | -| **Totaal** | | **€0–2/maand** | - -> Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog. +| Overzicht, Stack, Keuzes | [architecture/overview.md](./architecture/overview.md) | +| Datamodel, Prisma Schema | [architecture/data-model.md](./architecture/data-model.md) | +| Authenticatie, Sessions, Demo-policy | [architecture/auth-and-sessions.md](./architecture/auth-and-sessions.md) | +| QR-pairing login flow | [architecture/qr-pairing.md](./architecture/qr-pairing.md) | +| Claude ↔ User vraag-kanaal | [architecture/claude-question-channel.md](./architecture/claude-question-channel.md) | +| Projectstructuur, Stores, Realtime, Job queue | [architecture/project-structure.md](./architecture/project-structure.md) | diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/architecture/auth-and-sessions.md b/docs/architecture/auth-and-sessions.md new file mode 100644 index 0000000..4d633c3 --- /dev/null +++ b/docs/architecture/auth-and-sessions.md @@ -0,0 +1,216 @@ +--- +title: "Authentication, Sessions & Demo Policy" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [qr-pairing.md](./qr-pairing.md) +--- + +## Authenticatieflow + +``` +Registratie: + POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB + → iron-session cookie zetten → redirect /dashboard + +Inloggen: + POST /login → gebruiker ophalen op username → bcrypt vergelijken + → bij match: iron-session cookie zetten → redirect /dashboard + → bij mismatch: generieke foutmelding (geen onderscheid) + +Sessie per request: + proxy.ts → sessiecookie-aanwezigheid controleren + → beschermde routes: redirect /login als geen sessiecookie aanwezig is + → app layout valideert de volledige sessie server-side + +API-aanroepen (Claude Code): + Authorization: Bearer header → SHA-256 hash → opzoeken in api_tokens + → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten + +Uitloggen: + Server Action → iron-session vernietigen → redirect /login +``` + +--- + +## Demo-user policy (ST-1110) + +Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: + +### Laag 1 — Middleware-guard (proxy.ts) + +`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`. + +```ts +// Whitelist: paden die demo mag aanroepen ondanks non-GET +const DEMO_WRITE_ALLOWLIST = [ + '/api/cron/', // machine-auth, irrelevant voor demo +] +// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2 +``` + +### Laag 2 — Per-route guards (Server Actions & Route Handlers) + +Elke schrijfactie controleert `session.isDemo` vóór DB-toegang: + +```ts +if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } +``` + +**QR-pairing (M10):** +- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops +- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd +- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110 + +**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth). + +### Laag 3 — UI-laag (DemoTooltip) + +Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`. + +Patroon: +```tsx + + + +``` + +**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. + +--- + +## Claude job queue (M13 — ST-1111) + +Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status. + +### State machine + +``` +QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE + → FAILED + → CANCELLED (door user) +CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist) +QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed) +``` + +**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd. + +### ClaudeJob model + +``` +claude_jobs + id, user_id, product_id, task_id + status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) + claimed_by_token_id (FK → api_tokens, nullable) + claimed_at, started_at, finished_at + plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim + branch, pushed_at, summary, error + verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT) + @@index([user_id, status]) + @@index([task_id, status]) + @@index([status, claimed_at]) — voor stale-claim cleanup +``` + +**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`: + +| Waarde | Betekenis | +|---|---| +| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie | +| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker | +| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft | +| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig | + +**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen. + +**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte. + +### NOTIFY/LISTEN flow + +``` +UI klikt 'Voer uit' + → enqueueClaudeJobAction() Server Action + → prisma.claudeJob.create(QUEUED) + → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...}) + → /api/realtime/solo SSE server-side filter: user_id + product_id + → EventSource.onmessage browser: handleJobEvent() + → useSoloStore.claudeJobsByTaskId map + → SoloTaskCard pill + dialog-footer update +``` + +### Idempotency + +`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken. + +### Auto-promote task-status op job-overgangen + +Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat: + +- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan. +- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is. + +De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig. + +### Hybride-ready + +De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd. + +## Environment variables + +| Variabele | Doel | Waar te vinden | +|---|---|---| +| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) | +| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) | +| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` | +| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node | + +`.env.example`: +```bash +# Database +DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require" +DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require" + +# Sessie +SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output" + +# Optioneel +NODE_ENV="development" +``` + +--- + +## Deployment + +**Hosting:** Vercel (Hobby — gratis voor v1) +**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` +**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap +**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema +**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/assets/erd.svg` +**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document + +### Deployment checklist (pre-launch) + +- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings) +- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens) +- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase +- [ ] Demo-gebruiker aangemaakt via seed of handmatig +- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products` +- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek +- [ ] Vercel preview-deployments getest op een PR +- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten + +--- + +## Kostenscattting + +| Service | Plan | Maandelijkse kosten | +|---|---|---| +| Vercel | Hobby | Gratis | +| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis | +| GitHub | Free | Gratis | +| Domein | Eigen domein (optioneel) | ~€1–2/maand | +| **Totaal** | | **€0–2/maand** | + +> Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog. diff --git a/docs/architecture/claude-question-channel.md b/docs/architecture/claude-question-channel.md new file mode 100644 index 0000000..d4fc05a --- /dev/null +++ b/docs/architecture/claude-question-channel.md @@ -0,0 +1,79 @@ +--- +title: "Claude ↔ User Question Channel" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [project-structure.md](./project-structure.md) +--- + +## Vraag-antwoord-kanaal Claude ↔ user (M11) + +Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. +Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een +gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het +**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`. +De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert, +filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere +gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele +emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in +een latere sessie via `get_question_answer`) en gaat door. + +### Sequence + +```mermaid +sequenceDiagram + participant C as Claude (MCP) + participant DB as Postgres + participant SC as scrum4me_changes channel + participant SSE as /api/realtime/notifications + participant U as Scrum4Me UI (browser) + + C->>DB: INSERT claude_questions (status=open) + DB->>SC: pg_notify {entity:'question', op:'I', id, ...} + SC->>SSE: notification (filter: question + product-access) + SSE->>U: data event → Zustand store upsert → bell badge + + Note over U: Gebruiker klikt bell → Sheet → Modal + U->>DB: answerQuestion(questionId, answer)
Server Action: atomic updateMany WHERE status='open' + DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'} + SC->>SSE: notification + SSE->>U: data event → store remove → bell badge -1 + + Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s + C->>DB: SELECT status FROM claude_questions WHERE id=... + DB-->>C: status='answered', answer='...' + C->>C: gaat door met implementatie +``` + +### Threat-model + +| Aanval | Mitigatie | +|---|---| +| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst | +| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal | +| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) | +| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) | +| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 4 * * *` (dagelijks; Hobby-plan-limiet) markeert `status='expired'` → uit notifications-bell | +| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst | + +### Waarom hergebruik scrum4me_changes-kanaal + +In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van +de bestaande realtime-infra. Voordelen: + +- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties +- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key +- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen + nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen + +Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie: +expliciet `if (payload.entity === 'X') return false` in elke SSE-route die +betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'` +weert). + +Dit patroon (notification-channel via een bestaande pg_notify-stream) is +herbruikbaar — zie `docs/patterns/claude-question-channel.md`. + +--- + diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 0000000..511251b --- /dev/null +++ b/docs/architecture/data-model.md @@ -0,0 +1,460 @@ +--- +title: "Data Model & Prisma Schema" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [auth-and-sessions.md](./auth-and-sessions.md) +--- + +## Datamodel + +### `users` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | Gegenereerd door Prisma | +| username | String | unique, not null, min 3 | Inlognaam | +| password_hash | String | not null | bcrypt hash (cost factor 12) | +| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten | +| bio | String | nullable, max 160 | Korte profielomschrijving | +| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving | +| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL | + +**Indexes:** `username` (unique lookup bij inloggen) + +--- + +### `user_roles` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | | + +**Indexes:** `(user_id)` — meerdere rollen per gebruiker +**Constraint:** unique `(user_id, role)` + +--- + +### `api_tokens` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| token_hash | String | not null | SHA-256 hash van het token | +| label | String | nullable | Bijv. "Claude Code — laptop" | +| created_at | DateTime | default now() | | +| revoked_at | DateTime | nullable | Null = actief | + +**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn) + +--- + +### `products` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| name | String | not null, max 200 | Uniek per gebruiker | +| description | String | nullable, max 1000 | | +| repo_url | String | nullable | Gevalideerde URL | +| definition_of_done | String | not null, max 500 | Vaste instelling per product | +| archived | Boolean | default false | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten +**Constraint:** unique `(user_id, name)` + +--- + +### `pbis` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| code | String | nullable, max 30 | Auto-gegenereerd of handmatig | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag | +| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering | +| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog + +**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. + +--- + +### `stories` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| pbi_id | String | FK → pbis (cascade delete) | | +| product_id | String | FK → products | Denormalisatie voor snellere queries | +| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| acceptance_criteria | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | | +| sort_order | Float | not null | | +| status | Enum | OPEN \| IN_SPRINT \| DONE | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` + +**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). + +--- + +### `story_logs` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| story_id | String | FK → stories (cascade delete) | | +| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | | +| content | String | not null | Tekst van plan of testuitvoer | +| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT | +| commit_hash | String | nullable | Alleen bij type COMMIT | +| commit_message | String | nullable | Alleen bij type COMMIT | +| created_at | DateTime | default now() | | + +**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI + +--- + +### `sprints` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| sprint_goal | String | not null, max 500 | | +| status | Enum | ACTIVE \| COMPLETED | | +| created_at | DateTime | default now() | | +| completed_at | DateTime | nullable | | + +**Indexes:** `(product_id, status)` — query voor actieve Sprint per product +**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag) + +--- + +### `tasks` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| story_id | String | FK → stories (cascade delete) | | +| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries | +| title | String | not null, max 200 | | +| description | String | nullable, max 1000 | | +| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | +| priority | Int | 1–4, not null | | +| sort_order | Float | not null | | +| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` + +--- + +### `todos` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| user_id | String | FK → users, not null | | +| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | +| title | String | not null | | +| done | Boolean | default false | | +| archived | Boolean | default false | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | + +**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product + +--- + +### `product_members` + +| Kolom | Type | Constraints | Noten | +|---|---|---|---| +| id | String (cuid) | PK | | +| product_id | String | FK → products (cascade delete) | | +| user_id | String | FK → users (cascade delete) | | +| created_at | DateTime | default now() | | + +**Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is +**Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product + +Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. + +--- + +## Toegangsmodel en schrijfbeveiliging + +Producttoegang is centraal gedefinieerd als: + +- eigenaar: `products.user_id === gebruiker.id` +- teamlid: `product_members` bevat `(product_id, user_id)` + +Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. + +Schrijfoperaties volgen deze invarianten: + +- Controleer eerst authenticatie en `session.isDemo`. +- Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging. +- Controleer de parent-resource met `productAccessFilter`. +- Vertrouw bulk-ID's nooit los: haal de records eerst op met `id in (...)` plus de parent-scope (`product_id`, `pbi_id`, `sprint_id` of `story_id`) en weiger de operatie als aantallen niet exact overeenkomen. +- Weiger dubbele IDs in reorder- en beslissingslijsten. +- Leid denormalized foreign keys af van de database-parent (`pbi.product_id`, `sprint.product_id`) en niet van form-data of JSON body. +- Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped `deleteMany`/`updateMany` wanneer een unique `delete` anders onveilig zou zijn. + +--- + +## Prisma Schema (excerpt) + +```prisma +// prisma/schema.prisma + +generator client { + provider = "prisma-client-js" +} + +// Database wordt bepaald via prisma.config.ts — niet hier + +enum Role { + PRODUCT_OWNER + SCRUM_MASTER + DEVELOPER +} + +enum StoryStatus { + OPEN + IN_SPRINT + DONE +} + +enum PbiStatus { + READY + BLOCKED + DONE +} + +enum TaskStatus { + TO_DO + IN_PROGRESS + REVIEW + DONE +} + +enum LogType { + IMPLEMENTATION_PLAN + TEST_RESULT + COMMIT +} + +enum TestStatus { + PASSED + FAILED +} + +enum SprintStatus { + ACTIVE + COMPLETED +} + +model User { + id String @id @default(cuid()) + username String @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] +} + +model UserRole { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + role Role + + @@unique([user_id, role]) +} + +model ApiToken { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + + @@index([token_hash]) +} + +model Product { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + name String + description String? + repo_url String? + definition_of_done String + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + pbis Pbi[] + sprints Sprint[] + stories Story[] + todos Todo[] + members ProductMember[] + + @@unique([user_id, name]) + @@index([user_id, archived]) +} + +model Pbi { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String? @db.VarChar(30) + title String + description String? + priority Int + sort_order Float + status PbiStatus @default(READY) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + stories Story[] + + @@unique([product_id, code]) + @@index([product_id, priority, sort_order]) + @@index([product_id, status]) +} + +model Story { + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + pbi_id String + product Product @relation(fields: [product_id], references: [id]) + product_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + acceptance_criteria String? + priority Int + sort_order Float + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + logs StoryLog[] + tasks Task[] + + @@index([pbi_id, priority, sort_order]) + @@index([sprint_id, sort_order]) + @@index([product_id, status]) +} + +model StoryLog { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + type LogType + content String + status TestStatus? + commit_hash String? + commit_message String? + created_at DateTime @default(now()) + + @@index([story_id, created_at]) +} + +model Sprint { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + sprint_goal String + status SprintStatus @default(ACTIVE) + created_at DateTime @default(now()) + completed_at DateTime? + stories Story[] + tasks Task[] + + @@index([product_id, status]) +} + +model Task { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([story_id, priority, sort_order]) + @@index([sprint_id, status]) +} + +model Todo { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + title String + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, done, archived]) + @@index([user_id, product_id]) +} + +model ProductMember { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + created_at DateTime @default(now()) + + @@unique([product_id, user_id]) + @@index([user_id]) + @@map("product_members") +} +``` + +--- + diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..7a4fa5b --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,59 @@ +--- +title: "Scrum4Me — Architecture Overview" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [data-model.md](./data-model.md), [project-structure.md](./project-structure.md) +--- + +**Versie:** 0.1 — april 2026 +**Volgt op:** Functionele Specificatie v0.2 + +--- + +## Architectuursamenvatting + +Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. + +--- + +## Stack + +| Laag | Technologie | Rationale | +|---|---|---| +| Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management | +| UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions | +| Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg | +| Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict | +| Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund | +| Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in | +| ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch | +| Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side | +| Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers | +| REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel | +| Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL | +| Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` | +| Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 | +| CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af | + +--- + +## Wat we NIET gebruiken (en waarom) + +| Technologie | Afgewezen omdat | +|---|---| +| Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt | +| NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten | +| Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code | +| Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | +| React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | +| Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | +| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | +| Redis | Geen caching- of queuerequirements op deze schaal | +| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | +| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | +| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | + +--- + diff --git a/docs/architecture/project-structure.md b/docs/architecture/project-structure.md new file mode 100644 index 0000000..bce5d7a --- /dev/null +++ b/docs/architecture/project-structure.md @@ -0,0 +1,397 @@ +--- +title: "Project Structure, Stores, Realtime & Job Queue" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [data-model.md](./data-model.md) +--- + +## Projectstructuur + +``` +scrum4me/ +├── app/ +│ ├── (auth)/ +│ │ ├── login/page.tsx +│ │ └── register/page.tsx +│ ├── (app)/ # Beschermde routes +│ │ ├── layout.tsx # Auth-check + navigatie +│ │ ├── dashboard/page.tsx # Productenlijst +│ │ ├── products/ +│ │ │ ├── new/page.tsx +│ │ │ └── [id]/ +│ │ │ ├── layout.tsx # Zet actief product in Zustand store +│ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) +│ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) +│ │ │ ├── sprint/ +│ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) +│ │ │ │ └── planning/page.tsx # Redirect → /sprint +│ │ ├── todos/page.tsx +│ │ └── settings/ +│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens +│ │ └── tokens/page.tsx +│ ├── api/ # REST API voor Claude Code +│ │ ├── products/ +│ │ │ └── [id]/ +│ │ │ └── next-story/route.ts +│ │ ├── profile/ +│ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto +│ │ ├── sprints/ +│ │ │ └── [id]/ +│ │ │ └── tasks/route.ts +│ │ ├── stories/ +│ │ │ └── [id]/ +│ │ │ ├── log/route.ts +│ │ │ └── tasks/reorder/route.ts +│ │ ├── tasks/ +│ │ │ └── [id]/route.ts +│ │ └── todos/route.ts +├── components/ +│ ├── ui/ # shadcn/ui primitieven +│ ├── split-pane/ # Gesplitst scherm component +│ ├── backlog/ # PBI- en story-componenten +│ ├── sprint/ # Sprint-componenten +│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton +│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton +│ └── dnd/ # dnd-kit wrappers +├── lib/ +│ ├── prisma.ts # Prisma Client singleton +│ ├── session.ts # iron-session configuratie +│ ├── auth.ts # login/register/token helpers +│ ├── api-auth.ts # Bearer token middleware voor API +│ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) +│ └── env.ts # Zod-gevalideerde env vars +├── stores/ # Zustand stores +│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) +│ ├── planner-store.ts # Optimistische drag-and-drop volgorde +│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) +│ ├── sprint-store.ts # Sprint Backlog taakvolgordes +│ ├── solo-store.ts # Solo board optimistische taakstatus +│ └── product-store.ts # Actief product (naam + id) voor navbar +├── prisma/ +│ ├── schema.prisma +│ ├── migrations/ +│ └── seed.ts # Testdata uit Product Backlog document +├── proxy.ts # Next.js 16 proxy voor route protection +├── prisma.config.ts # Prisma v7 config (DATABASE_URL) +└── .env.example +``` + +--- + +## Sleutelarchitectuurbeslissingen + +### Beslissing: iron-session in plaats van Auth.js / Supabase Auth +**Keuze:** iron-session voor versleutelde server-side sessiecookies +**Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. +**Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. + +### Beslissing: Route Handlers naast Server Actions +**Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API +**Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. +**Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. + +### Beslissing: Float voor sort_order +**Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken +**Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). +**Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. + +### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` +**Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` +**Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. +**Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. + +### Beslissing: Zustand voor client-side state management +**Keuze:** Vijf Zustand-stores naast Server Components +**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. +**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. + +--- + +## Zustand stores + +### `usePlannerStore` — optimistische drag-and-drop volgorde + +Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency. + +```ts +// stores/planner-store.ts +import { create } from 'zustand' + +interface PlannerStore { + // Optimistische volgorde per container (id-arrays) + pbiOrder: Record // productId → pbi-ids + storyOrder: Record // pbiId → story-ids + taskOrder: Record // storyId → taak-ids + + // Initialiseren vanuit server-data (bij mount) + initPbis: (productId: string, ids: string[]) => void + initStories: (pbiId: string, ids: string[]) => void + initTasks: (storyId: string, ids: string[]) => void + + // Optimistisch updaten (vóór server-bevestiging) + reorderPbis: (productId: string, newOrder: string[]) => void + reorderStories: (pbiId: string, newOrder: string[]) => void + reorderTasks: (storyId: string, newOrder: string[]) => void + + // Terugdraaien bij server-fout + rollbackPbis: (productId: string, prevOrder: string[]) => void + rollbackStories: (pbiId: string, prevOrder: string[]) => void + rollbackTasks: (storyId: string, prevOrder: string[]) => void +} +``` + +**Gebruikspatroon:** +```ts +// 1. Server Component geeft ids door +// app/(app)/products/[id]/page.tsx +const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] }) +return p.id)} pbis={pbis} /> + +// 2. Client Component hydrateert store +// components/backlog/backlog-panel.tsx +'use client' +const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore() +useEffect(() => { initPbis(productId, initialPbiIds) }, []) + +// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action +const prevOrder = usePlannerStore(s => s.pbiOrder[productId]) +reorderPbis(productId, newOrder) +const result = await reorderPbisAction(productId, newOrder) +if (!result.success) rollbackPbis(productId, prevOrder) +``` + +--- + +### `useSelectionStore` — navigatieselectie + +Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling. + +```ts +// stores/selection-store.ts +interface SelectionStore { + selectedPbiId: string | null + selectedStoryId: string | null + selectPbi: (id: string | null) => void + selectStory: (id: string | null) => void + clearSelection: () => void +} +``` + +--- + +### `useSprintStore` — Sprint Backlog interacties + +Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen. + +```ts +// stores/sprint-store.ts +interface SprintStore { + // Stories per Sprint (optimistisch, op volgorde) + sprintStoryIds: Record // sprintId → story-ids + + initSprint: (sprintId: string, ids: string[]) => void + addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void + removeStoryFromSprint: (sprintId: string, storyId: string) => void + reorderSprintStories: (sprintId: string, newOrder: string[]) => void + rollbackSprint: (sprintId: string, prevIds: string[]) => void +} +``` + +--- + +### `useSoloStore` — Solo board optimistische taakstatus + +Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout. + +```ts +// stores/solo-store.ts +interface SoloStore { + tasks: Record + initTasks: (tasks: SoloTask[]) => void + optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null + rollback: (taskId: string, prevStatus: TaskStatus) => void + updatePlan: (taskId: string, plan: string | null) => void +} +``` + +--- + +### `useProductStore` — Actief product voor navbar + +Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie. + +```ts +// stores/product-store.ts +interface ProductStore { + currentProduct: { id: string; name: string } | null + setCurrentProduct: (id: string, name: string) => void + clearCurrentProduct: () => void +} +``` + +--- + +## Data flow architectuur + +``` +┌─────────────────────────────────────────┐ +│ Server Component (page.tsx) │ +│ Prisma query → initiële data + ids │ +│ → props naar Client Component │ +└──────────────────┬──────────────────────┘ + │ initialIds, initialData + ▼ +┌─────────────────────────────────────────┐ +│ Client Component (panel.tsx) │ +│ useEffect → store.init(ids) │ +│ dnd-kit drag → store.reorder() │ +│ → Server Action (async) │ +│ → bij fout: store.rollback()│ +└──────────────────┬──────────────────────┘ + │ selecteert state via selector + ▼ +┌─────────────────────────────────────────┐ +│ Zustand Stores │ +│ usePlannerStore useSelectionStore │ +│ useSprintStore │ +│ │ +│ Alleen ephemere UI-staat │ +│ Nooit server-data of business logic │ +└─────────────────────────────────────────┘ +``` +**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel +**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. +**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken. + +--- + +## Realtime updates (M8) + +Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ PATCH /api/tasks/:id +└────────────┬────────────┘ Server Action, MCP, etc. + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ bouwt JSON payload +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/solo │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op product + sprint + assignee +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useSoloRealtime +│ → solo-store.handleEvent│ via flushSync + startViewTransition +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ SoloBoard re-render │ kanban-kaartje animeert naar +│ (View Transitions API) │ zijn nieuwe kolom +└─────────────────────────┘ +``` + +**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). +**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. +**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. + +### Mutaties die NOTIFY triggeren + +De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. + +### Server-side filter + +`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) +- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) + +Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. + +### Connection lifecycle + +- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount. +- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). +- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. +- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. +- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. + +**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. + +### Animatie + +Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. + +### Auth + +Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. + +--- + +## Realtime — Backlog SSE (ST-1115) + +De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ Server Action, MCP, etc. +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/backlog │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} +│ │ én product_id matcht query-param +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useBacklogRealtime +│ → backlog-store.apply │ via applyChange(entity, op, data) +│ Change(entity,op,data)│ +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ PbiList / StoryPanel / │ re-render op basis van Zustand state +│ TaskPanel re-render │ +└─────────────────────────┘ +``` + +### Hydration en SSE-mount + +De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: +1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). +2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. + +Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. + +### backlog-store en applyChange + +```ts +// stores/backlog-store.ts +applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) +``` + +- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array +- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) +- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload + +### Server-side filter (backlog) + +`/api/realtime/backlog?product_id=...` filtert op: +- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd +- `product_id` matcht de query-param + +Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. + +--- + diff --git a/docs/architecture/qr-pairing.md b/docs/architecture/qr-pairing.md new file mode 100644 index 0000000..61a30ea --- /dev/null +++ b/docs/architecture/qr-pairing.md @@ -0,0 +1,88 @@ +--- +title: "QR-pairing Login Flow" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +related: [auth-and-sessions.md](./auth-and-sessions.md) +--- + +## QR-pairing flow (M10) + +Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt +door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke +toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- +sessie heeft eigen kortere TTL (8 u) + `paired`-vlag. + +### Sequence + +```mermaid +sequenceDiagram + participant D as Desktop (anon) + participant S as Server + participant M as Mobiel (ingelogd) + + D->>S: POST /api/auth/pair/start + S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min } + S-->>D: 200 { pairingId, mobileSecret, qrUrl }
Set-Cookie: s4m_pair=desktopToken + D->>D: render QR met qrUrl (#id=…&s=mobileSecret) + D->>S: GET /api/auth/pair/stream/[pairingId]
Cookie: s4m_pair + S->>S: LISTEN scrum4me_pairing + S-->>D: event: state { status: 'pending' } + + Note over M: Gebruiker scant QR + M->>M: location.hash → mobileSecret + M->>S: getPairingForApproval(pairingId, mobileSecret) + S-->>M: { desktop_ua, desktop_ip, username } + M->>M: toont bevestigingskaart + Note over M: Tap "Bevestig" + M->>S: approvePairing(pairingId, mobileSecret) + S->>S: status pending→approved, expires +5min
pg_notify scrum4me_pairing + S-->>D: data { status: 'approved' } + + D->>S: POST /api/auth/pair/claim
Cookie: s4m_pair, body: { pairingId } + S->>S: atomic UPDATE WHERE status=approved AND token-hash
→ status=consumed + S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt } + S-->>D: 200, Set-Cookie: session
+ s4m_pair cleared + D->>D: redirect /dashboard +``` + +### Threat-model + +| Aanval | Mitigatie | +|---|---| +| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 | +| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart | +| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` | +| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) | +| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) | +| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke | + +### TTL-rationale + +- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft. +- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft. +- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen. + +### Waarom geen secret in URL + +Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access +logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een +geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit: + +1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door + browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client + Component leest `window.location.hash` en POST't de waarde in een body — + ook niet in een URL. +2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET + in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien + `Path=/api/auth/pair`-scoped, dus verlaat die route nooit. + +Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` +voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch +de andere kant compromitteert. + +Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`. + +--- +