# Scrum4Me — Technische Architectuur

**Versie:** 0.1 — april 2026
**Volgt op:** Functionele Specificatie v0.2

---

## Architectuursamenvatting

Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp.

---

## Stack

| Laag | Technologie | Rationale |
|---|---|---|
| Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management |
| UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions |
| Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg |
| Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict |
| Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund |
| Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in |
| ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch |
| Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side |
| Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers |
| REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel |
| Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL |
| Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` |
| Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 |
| CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af |

---

## Wat we NIET gebruiken (en waarom)

| Technologie | Afgewezen omdat |
|---|---|
| Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt |
| NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten |
| Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code |
| Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar |
| React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden |
| Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder |
| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat |
| Redis | Geen caching- of queuerequirements op deze schaal |
| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe |
| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak |
| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients |

---

## Datamodel

### `users`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | Gegenereerd door Prisma |
| username | String | unique, not null, min 3 | Inlognaam |
| password_hash | String | not null | bcrypt hash (cost factor 12) |
| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten |
| bio | String | nullable, max 160 | Korte profielomschrijving |
| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving |
| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL |

**Indexes:** `username` (unique lookup bij inloggen)

---

### `user_roles`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users, not null | |
| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | |

**Indexes:** `(user_id)` — meerdere rollen per gebruiker
**Constraint:** unique `(user_id, role)`

---

### `api_tokens`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users, not null | |
| token_hash | String | not null | SHA-256 hash van het token |
| label | String | nullable | Bijv. "Claude Code — laptop" |
| created_at | DateTime | default now() | |
| revoked_at | DateTime | nullable | Null = actief |

**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn)

---

### `products`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users, not null | |
| name | String | not null, max 200 | Uniek per gebruiker |
| description | String | nullable, max 1000 | |
| repo_url | String | nullable | Gevalideerde URL |
| definition_of_done | String | not null, max 500 | Vaste instelling per product |
| archived | Boolean | default false | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |

**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten
**Constraint:** unique `(user_id, name)`

---

### `pbis`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| code | String | nullable, max 30 | Auto-gegenereerd of handmatig |
| title | String | not null, max 200 | |
| description | String | nullable, max 2000 | |
| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag |
| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering |
| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |

**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog

**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY.

---

### `stories`

| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| pbi_id | String | FK → pbis (cascade delete) | |
| product_id | String | FK → products | Denormalisatie voor snellere queries |
| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog |
| title | String | not null, max 200 | |
| description | String | nullable, max 2000 | |
| acceptance_criteria | String | nullable, max 2000 | |
| priority | Int | 1–4, not null | |
| sort_order | Float | not null | |
| status | Enum | OPEN \| IN_SPRINT \| DONE | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |

**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`

**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 <token> header → SHA-256 hash → opzoeken in api_tokens
  → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten

Uitloggen:
  Server Action → iron-session vernietigen → redirect /login
```

---

## QR-pairing flow (M10)

Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt
door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke
toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired-
sessie heeft eigen kortere TTL (8 u) + `paired`-vlag.

### Sequence

```mermaid
sequenceDiagram
  participant D as Desktop (anon)
  participant S as Server
  participant M as Mobiel (ingelogd)

  D->>S: POST /api/auth/pair/start
  S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min }
  S-->>D: 200 { pairingId, mobileSecret, qrUrl }<br/>Set-Cookie: s4m_pair=desktopToken
  D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
  D->>S: GET /api/auth/pair/stream/[pairingId]<br/>Cookie: s4m_pair
  S->>S: LISTEN scrum4me_pairing
  S-->>D: event: state { status: 'pending' }

  Note over M: Gebruiker scant QR
  M->>M: location.hash → mobileSecret
  M->>S: getPairingForApproval(pairingId, mobileSecret)
  S-->>M: { desktop_ua, desktop_ip, username }
  M->>M: toont bevestigingskaart
  Note over M: Tap "Bevestig"
  M->>S: approvePairing(pairingId, mobileSecret)
  S->>S: status pending→approved, expires +5min<br/>pg_notify scrum4me_pairing
  S-->>D: data { status: 'approved' }

  D->>S: POST /api/auth/pair/claim<br/>Cookie: s4m_pair, body: { pairingId }
  S->>S: atomic UPDATE WHERE status=approved AND token-hash<br/>→ status=consumed
  S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
  S-->>D: 200, Set-Cookie: scrum4me-session<br/>+ s4m_pair cleared
  D->>D: redirect /dashboard
```

### Threat-model

| Aanval | Mitigatie |
|---|---|
| **Replay** van een geconsumeerde pairing | Atomic `updateMany WHERE status='approved'` — concurrent dubbele claim ziet count=0 → 410 |
| **Phishing-QR** ingesloten op een vreemde site | Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart |
| **Demo-account misbruik** | `approvePairing` early-return op `session.isDemo` — pairing blijft `pending` |
| **Brute-force** van pairings | Rate-limit 10 starts per IP per minuut; `pairingId` is CUID (lange entropy) |
| **Secret-leak via DB-dump** | DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop) |
| **Long-lived sessie op publieke desktop** | Paired-sessie krijgt 8u TTL i.p.v. reguliere; `paired: true` markeert 'm voor toekomstige remote-revoke |

### TTL-rationale

- **Pending: 5 min.** Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft.
- **Approved (na bump): nogmaals 5 min.** Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft.
- **Paired-sessie: 8 uur.** Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen.

### Waarom geen secret in URL

Servers loggen URL-paden en querystrings standaard — `nginx`, Vercel access
logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een
geheim in `?s=…` belandt onbedoeld in al die logs. Twee technieken voorkomen dit:

1. **URL-fragment voor `mobileSecret`.** Het deel achter de `#` wordt door
   browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client
   Component leest `window.location.hash` en POST't de waarde in een body —
   ook niet in een URL.
2. **HttpOnly cookie voor `desktopToken`.** Cookie-headers worden meestal NIET
   in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien
   `Path=/api/auth/pair`-scoped, dus verlaat die route nooit.

Twee gescheiden hashes (`secret_hash` voor mobiel-bewijs, `desktop_token_hash`
voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch
de andere kant compromitteert.

Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`.

---

## Vraag-antwoord-kanaal Claude ↔ user (M11)

Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker.
Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een
gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het
**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`.
De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert,
filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere
gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele
emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in
een latere sessie via `get_question_answer`) en gaat door.

### Sequence

```mermaid
sequenceDiagram
  participant C as Claude (MCP)
  participant DB as Postgres
  participant SC as scrum4me_changes channel
  participant SSE as /api/realtime/notifications
  participant U as Scrum4Me UI (browser)

  C->>DB: INSERT claude_questions (status=open)
  DB->>SC: pg_notify {entity:'question', op:'I', id, ...}
  SC->>SSE: notification (filter: question + product-access)
  SSE->>U: data event → Zustand store upsert → bell badge

  Note over U: Gebruiker klikt bell → Sheet → Modal
  U->>DB: answerQuestion(questionId, answer)<br/>Server Action: atomic updateMany WHERE status='open'
  DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'}
  SC->>SSE: notification
  SSE->>U: data event → store remove → bell badge -1

  Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s
  C->>DB: SELECT status FROM claude_questions WHERE id=...
  DB-->>C: status='answered', answer='...'
  C->>C: gaat door met implementatie
```

### Threat-model

| Aanval | Mitigatie |
|---|---|
| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst |
| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal |
| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) |
| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) |
| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 4 * * *` (dagelijks; Hobby-plan-limiet) markeert `status='expired'` → uit notifications-bell |
| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst |

### Waarom hergebruik scrum4me_changes-kanaal

In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van
de bestaande realtime-infra. Voordelen:

- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties
- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key
- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen
  nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen

Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie:
expliciet `if (payload.entity === 'X') return false` in elke SSE-route die
betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'`
weert).

Dit patroon (notification-channel via een bestaande pg_notify-stream) is
herbruikbaar — zie `docs/patterns/claude-question-channel.md`.

---

## Projectstructuur

```
scrum4me/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (app)/                        # Beschermde routes
│   │   ├── layout.tsx                # Auth-check + navigatie
│   │   ├── dashboard/page.tsx        # Productenlijst
│   │   ├── products/
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/
│   │   │       ├── layout.tsx        # Zet actief product in Zustand store
│   │   │       ├── page.tsx          # Product Backlog (gesplitst scherm)
│   │   │       ├── solo/page.tsx     # Solo board (Kanban per ingelogde gebruiker)
│   │   │       ├── sprint/
│   │   │       │   ├── page.tsx      # Sprint Backlog (drie-paneel scherm)
│   │   │       │   └── planning/page.tsx  # Redirect → /sprint
│   │   ├── todos/page.tsx
│   │   └── settings/
│   │       ├── page.tsx              # Profiel, account, PB-overzicht, rollen, tokens
│   │       └── tokens/page.tsx
│   ├── api/                          # REST API voor Claude Code
│   │   ├── products/
│   │   │   └── [id]/
│   │   │       └── next-story/route.ts
│   │   ├── profile/
│   │   │   └── avatar/route.ts       # POST upload + GET serve profielfoto
│   │   ├── sprints/
│   │   │   └── [id]/
│   │   │       └── tasks/route.ts
│   │   ├── stories/
│   │   │   └── [id]/
│   │   │       ├── log/route.ts
│   │   │       └── tasks/reorder/route.ts
│   │   ├── tasks/
│   │   │   └── [id]/route.ts
│   │   └── todos/route.ts
├── components/
│   ├── ui/                           # shadcn/ui primitieven
│   ├── split-pane/                   # Gesplitst scherm component
│   ├── backlog/                      # PBI- en story-componenten
│   ├── sprint/                       # Sprint-componenten
│   ├── products/                     # ProductForm, TeamManager, ArchiveProductButton
│   ├── settings/                     # RoleManager, ProfileEditor, LeaveProductButton
│   └── dnd/                         # dnd-kit wrappers
├── lib/
│   ├── prisma.ts                     # Prisma Client singleton
│   ├── session.ts                    # iron-session configuratie
│   ├── auth.ts                       # login/register/token helpers
│   ├── api-auth.ts                   # Bearer token middleware voor API
│   ├── product-access.ts             # productAccessFilter helper (eigenaar of teamlid)
│   └── env.ts                        # Zod-gevalideerde env vars
├── stores/                           # Zustand stores
│   ├── 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<string, string[]>    // productId → pbi-ids
  storyOrder: Record<string, string[]>  // pbiId → story-ids
  taskOrder: Record<string, string[]>   // storyId → taak-ids

  // Initialiseren vanuit server-data (bij mount)
  initPbis: (productId: string, ids: string[]) => void
  initStories: (pbiId: string, ids: string[]) => void
  initTasks: (storyId: string, ids: string[]) => void

  // Optimistisch updaten (vóór server-bevestiging)
  reorderPbis: (productId: string, newOrder: string[]) => void
  reorderStories: (pbiId: string, newOrder: string[]) => void
  reorderTasks: (storyId: string, newOrder: string[]) => void

  // Terugdraaien bij server-fout
  rollbackPbis: (productId: string, prevOrder: string[]) => void
  rollbackStories: (pbiId: string, prevOrder: string[]) => void
  rollbackTasks: (storyId: string, prevOrder: string[]) => void
}
```

**Gebruikspatroon:**
```ts
// 1. Server Component geeft ids door
// app/(app)/products/[id]/page.tsx
const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] })
return <BacklogPanel productId={id} initialPbiIds={pbis.map(p => p.id)} pbis={pbis} />

// 2. Client Component hydrateert store
// components/backlog/backlog-panel.tsx
'use client'
const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore()
useEffect(() => { initPbis(productId, initialPbiIds) }, [])

// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action
const prevOrder = usePlannerStore(s => s.pbiOrder[productId])
reorderPbis(productId, newOrder)
const result = await reorderPbisAction(productId, newOrder)
if (!result.success) rollbackPbis(productId, prevOrder)
```

---

### `useSelectionStore` — navigatieselectie

Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling.

```ts
// stores/selection-store.ts
interface SelectionStore {
  selectedPbiId: string | null
  selectedStoryId: string | null
  selectPbi: (id: string | null) => void
  selectStory: (id: string | null) => void
  clearSelection: () => void
}
```

---

### `useSprintStore` — Sprint Backlog interacties

Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen.

```ts
// stores/sprint-store.ts
interface SprintStore {
  // Stories per Sprint (optimistisch, op volgorde)
  sprintStoryIds: Record<string, string[]>  // sprintId → story-ids

  initSprint: (sprintId: string, ids: string[]) => void
  addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void
  removeStoryFromSprint: (sprintId: string, storyId: string) => void
  reorderSprintStories: (sprintId: string, newOrder: string[]) => void
  rollbackSprint: (sprintId: string, prevIds: string[]) => void
}
```

---

### `useSoloStore` — Solo board optimistische taakstatus

Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout.

```ts
// stores/solo-store.ts
interface SoloStore {
  tasks: Record<string, SoloTask>
  initTasks: (tasks: SoloTask[]) => void
  optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
  rollback: (taskId: string, prevStatus: TaskStatus) => void
  updatePlan: (taskId: string, plan: string | null) => void
}
```

---

### `useProductStore` — Actief product voor navbar

Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie.

```ts
// stores/product-store.ts
interface ProductStore {
  currentProduct: { id: string; name: string } | null
  setCurrentProduct: (id: string, name: string) => void
  clearCurrentProduct: () => void
}
```

---

## Data flow architectuur

```
┌─────────────────────────────────────────┐
│         Server Component (page.tsx)      │
│  Prisma query → initiële data + ids      │
│  → props naar Client Component          │
└──────────────────┬──────────────────────┘
                   │ initialIds, initialData
                   ▼
┌─────────────────────────────────────────┐
│       Client Component (panel.tsx)       │
│  useEffect → store.init(ids)            │
│  dnd-kit drag → store.reorder()         │
│             → Server Action (async)     │
│             → bij fout: store.rollback()│
└──────────────────┬──────────────────────┘
                   │ selecteert state via selector
                   ▼
┌─────────────────────────────────────────┐
│           Zustand Stores                 │
│  usePlannerStore  useSelectionStore      │
│  useSprintStore                          │
│                                          │
│  Alleen ephemere UI-staat               │
│  Nooit server-data of business logic    │
└─────────────────────────────────────────┘
```
**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel
**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal.
**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken.

---

## Realtime updates (M8)

Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn:

```
┌─────────────────────────┐
│  Mutatie (Prisma write) │  PATCH /api/tasks/:id
└────────────┬────────────┘  Server Action, MCP, etc.
             ▼
┌─────────────────────────┐
│ Postgres row trigger    │  AFTER INSERT/UPDATE/DELETE
│ scrum4me_notify_change()│  bouwt JSON payload
└────────────┬────────────┘
             ▼  pg_notify('scrum4me_changes', json)
┌─────────────────────────┐
│ /api/realtime/solo      │  Node runtime, dedicated pg.Client
│ LISTEN scrum4me_changes │  filtert op product + sprint + assignee
└────────────┬────────────┘
             ▼  text/event-stream
┌─────────────────────────┐
│ EventSource (browser)   │  beheerd door useSoloRealtime
│ → solo-store.handleEvent│  via flushSync + startViewTransition
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│ SoloBoard re-render     │  kanban-kaartje animeert naar
│ (View Transitions API)  │  zijn nieuwe kolom
└─────────────────────────┘
```

**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime).
**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events.
**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren.

### Mutaties die NOTIFY triggeren

De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8.

### Server-side filter

`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op:
- `product_id` matcht de query-param
- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect)
- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims)

Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet.

### Connection lifecycle

- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker 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<string, unknown>)
```

- **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
<DemoTooltip show={isDemo}>
  <Button disabled={isDemo} onClick={() => !isDemo && handleAction()}>
    Actie
  </Button>
</DemoTooltip>
```

**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && <span {...listeners} />}`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.

---

## Claude job queue (M13 — ST-1111)

Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status.

### State machine

```
QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE
                                               → FAILED
       → CANCELLED (door user)
CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist)
QUEUED  → CLAIMED (re-claim na stale reset; snapshot refreshed)
```

**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd.

### ClaudeJob model

```
claude_jobs
  id, user_id, product_id, task_id
  status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED)
  claimed_by_token_id (FK → api_tokens, nullable)
  claimed_at, started_at, finished_at
  plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim
  branch, pushed_at, summary, error
  verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT)
  @@index([user_id, status])
  @@index([task_id, status])
  @@index([status, claimed_at])   — voor stale-claim cleanup
```

**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`:

| Waarde | Betekenis |
|---|---|
| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie |
| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker |
| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft |
| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig |

**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen.

**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte.

### NOTIFY/LISTEN flow

```
UI klikt 'Voer uit'
  → enqueueClaudeJobAction()       Server Action
  → prisma.claudeJob.create(QUEUED)
  → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...})
  → /api/realtime/solo SSE         server-side filter: user_id + product_id
  → EventSource.onmessage          browser: handleJobEvent()
  → useSoloStore.claudeJobsByTaskId map
  → SoloTaskCard pill + dialog-footer update
```

### Idempotency

`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken.

### Auto-promote task-status op job-overgangen

Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat:

- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan.
- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is.

De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig.

### Hybride-ready

De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd.

## Environment variables

| Variabele | Doel | Waar te vinden |
|---|---|---|
| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) |
| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) |
| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` |
| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node |

`.env.example`:
```bash
# Database
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"

# Sessie
SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output"

# Optioneel
NODE_ENV="development"
```

---

## Deployment

**Hosting:** Vercel (Hobby — gratis voor v1)
**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main`
**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap
**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema
**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/erd.svg`
**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document

### Deployment checklist (pre-launch)

- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings)
- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens)
- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase
- [ ] Demo-gebruiker aangemaakt via seed of handmatig
- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products`
- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek
- [ ] Vercel preview-deployments getest op een PR
- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten

---

## Kostenscattting

| Service | Plan | Maandelijkse kosten |
|---|---|---|
| Vercel | Hobby | Gratis |
| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis |
| GitHub | Free | Gratis |
| Domein | Eigen domein (optioneel) | ~€1–2/maand |
| **Totaal** | | **€0–2/maand** |

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