Scrum4Me/docs/scrum4me-architecture.md
Janpeter Visser 73087e9705
M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)
* feat(ST-1111.1): add ClaudeJob model and state-machine enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.2): add ClaudeJob status API mappers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.7): add job status pill with spinner on solo task cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ST-1111.8): cover job-status mappers and enqueue/cancel actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1111.9): document Claude job queue architecture and agent flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10a): add ClaudeWorker presence model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10c): forward worker presence events on solo SSE + initial count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:51:48 +02:00

50 KiB
Raw Permalink Blame History

Scrum4Me — Technische Architectuur

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


Architectuursamenvatting

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


Stack

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

Wat we NIET gebruiken (en waarom)

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

Datamodel

users

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

Indexes: username (unique lookup bij inloggen)


user_roles

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

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


api_tokens

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

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


products

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

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


pbis

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

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

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


stories

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

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


story_logs

Kolom Type Constraints Noten
id String (cuid) PK
story_id String FK → stories (cascade delete)
type Enum IMPLEMENTATION_PLAN | TEST_RESULT | COMMIT
content String not null Tekst van plan of testuitvoer
status Enum PASSED | FAILED, nullable Alleen bij type TEST_RESULT
commit_hash String nullable Alleen bij type COMMIT
commit_message String nullable Alleen bij type COMMIT
created_at DateTime default now()

Indexes: (story_id, created_at) — chronologische weergave in de UI


sprints

Kolom Type Constraints Noten
id String (cuid) PK
product_id String FK → products (cascade delete)
sprint_goal String not null, max 500
status Enum ACTIVE | COMPLETED
created_at DateTime default now()
completed_at DateTime nullable

Indexes: (product_id, status) — query voor actieve Sprint per product Constraint: Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag)


tasks

Kolom Type Constraints Noten
id String (cuid) PK
story_id String FK → stories (cascade delete)
sprint_id String FK → sprints, nullable Denormalisatie voor snellere queries
title String not null, max 200
description String nullable, max 1000
implementation_plan String nullable Opgeslagen door Claude Code MCP via PATCH /api/tasks/:id
priority Int 14, not null
sort_order Float not null
status Enum TO_DO | IN_PROGRESS | REVIEW | DONE
created_at DateTime default now()
updated_at DateTime auto-update

Indexes: (story_id, priority, sort_order), (sprint_id, status)


todos

Kolom Type Constraints Noten
id String (cuid) PK
user_id String FK → users, not null
product_id String? FK → products, nullable Optioneel in UI; SetNull bij verwijderen product
title String not null
done Boolean default false
archived Boolean default false
created_at DateTime default now()
updated_at DateTime auto-update

Indexes: (user_id, done, archived) — standaard weergave filtert op actieve todo's; (user_id, product_id) — filteren per product


product_members

Kolom Type Constraints Noten
id String (cuid) PK
product_id String FK → products (cascade delete)
user_id String FK → users (cascade delete)
created_at DateTime default now()

Indexes: (user_id) — opzoeken van producten waarbij een gebruiker lid is Constraint: unique (product_id, user_id) — één lidmaatschap per gebruiker per product

Koppelt Developer-gebruikers aan een product backlog. De eigenaar (products.user_id) heeft altijd volledige toegang; via product_members kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet user_roles. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol DEVELOPER heeft.


Toegangsmodel en schrijfbeveiliging

Producttoegang is centraal gedefinieerd als:

  • eigenaar: products.user_id === gebruiker.id
  • teamlid: product_members bevat (product_id, user_id)

Code gebruikt hiervoor productAccessFilter(userId) uit lib/product-access.ts. Route Handlers en Server Actions mogen geen eigenaar-only filter (user_id) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren.

Schrijfoperaties volgen deze invarianten:

  • Controleer eerst authenticatie en session.isDemo.
  • Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging.
  • Controleer de parent-resource met productAccessFilter.
  • Vertrouw bulk-ID's nooit los: haal de records eerst op met id in (...) plus de parent-scope (product_id, pbi_id, sprint_id of story_id) en weiger de operatie als aantallen niet exact overeenkomen.
  • Weiger dubbele IDs in reorder- en beslissingslijsten.
  • Leid denormalized foreign keys af van de database-parent (pbi.product_id, sprint.product_id) en niet van form-data of JSON body.
  • Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped deleteMany/updateMany wanneer een unique delete anders onveilig zou zijn.

Prisma Schema (excerpt)

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

// Database wordt bepaald via prisma.config.ts — niet hier

enum Role {
  PRODUCT_OWNER
  SCRUM_MASTER
  DEVELOPER
}

enum StoryStatus {
  OPEN
  IN_SPRINT
  DONE
}

enum PbiStatus {
  READY
  BLOCKED
  DONE
}

enum TaskStatus {
  TO_DO
  IN_PROGRESS
  REVIEW
  DONE
}

enum LogType {
  IMPLEMENTATION_PLAN
  TEST_RESULT
  COMMIT
}

enum TestStatus {
  PASSED
  FAILED
}

enum SprintStatus {
  ACTIVE
  COMPLETED
}

model User {
  id              String          @id @default(cuid())
  username        String          @unique
  password_hash   String
  is_demo         Boolean         @default(false)
  bio             String?         @db.VarChar(160)
  bio_detail      String?         @db.VarChar(2000)
  avatar_data     Bytes?
  created_at      DateTime        @default(now())
  updated_at      DateTime        @updatedAt
  roles           UserRole[]
  api_tokens      ApiToken[]
  products        Product[]
  todos           Todo[]
  product_members ProductMember[]
}

model UserRole {
  id      String @id @default(cuid())
  user    User   @relation(fields: [user_id], references: [id], onDelete: Cascade)
  user_id String
  role    Role

  @@unique([user_id, role])
}

model ApiToken {
  id         String    @id @default(cuid())
  user       User      @relation(fields: [user_id], references: [id], onDelete: Cascade)
  user_id    String
  token_hash String    @unique
  label      String?
  created_at DateTime  @default(now())
  revoked_at DateTime?

  @@index([token_hash])
}

model Product {
  id                 String          @id @default(cuid())
  user               User            @relation(fields: [user_id], references: [id], onDelete: Cascade)
  user_id            String
  name               String
  description        String?
  repo_url           String?
  definition_of_done String
  archived           Boolean         @default(false)
  created_at         DateTime        @default(now())
  updated_at         DateTime        @updatedAt
  pbis               Pbi[]
  sprints            Sprint[]
  stories            Story[]
  todos              Todo[]
  members            ProductMember[]

  @@unique([user_id, name])
  @@index([user_id, archived])
}

model Pbi {
  id          String    @id @default(cuid())
  product     Product   @relation(fields: [product_id], references: [id], onDelete: Cascade)
  product_id  String
  code        String?   @db.VarChar(30)
  title       String
  description String?
  priority    Int
  sort_order  Float
  status      PbiStatus @default(READY)
  created_at  DateTime  @default(now())
  updated_at  DateTime  @updatedAt
  stories     Story[]

  @@unique([product_id, code])
  @@index([product_id, priority, sort_order])
  @@index([product_id, status])
}

model Story {
  id                   String      @id @default(cuid())
  pbi                  Pbi         @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
  pbi_id               String
  product              Product     @relation(fields: [product_id], references: [id])
  product_id           String
  sprint               Sprint?     @relation(fields: [sprint_id], references: [id])
  sprint_id            String?
  title                String
  description          String?
  acceptance_criteria  String?
  priority             Int
  sort_order           Float
  status               StoryStatus @default(OPEN)
  created_at           DateTime    @default(now())
  updated_at           DateTime    @updatedAt
  logs                 StoryLog[]
  tasks                Task[]

  @@index([pbi_id, priority, sort_order])
  @@index([sprint_id, sort_order])
  @@index([product_id, status])
}

model StoryLog {
  id             String      @id @default(cuid())
  story          Story       @relation(fields: [story_id], references: [id], onDelete: Cascade)
  story_id       String
  type           LogType
  content        String
  status         TestStatus?
  commit_hash    String?
  commit_message String?
  created_at     DateTime    @default(now())

  @@index([story_id, created_at])
}

model Sprint {
  id           String       @id @default(cuid())
  product      Product      @relation(fields: [product_id], references: [id], onDelete: Cascade)
  product_id   String
  sprint_goal  String
  status       SprintStatus @default(ACTIVE)
  created_at   DateTime     @default(now())
  completed_at DateTime?
  stories      Story[]
  tasks        Task[]

  @@index([product_id, status])
}

model Task {
  id                  String     @id @default(cuid())
  story               Story      @relation(fields: [story_id], references: [id], onDelete: Cascade)
  story_id            String
  sprint              Sprint?    @relation(fields: [sprint_id], references: [id])
  sprint_id           String?
  title               String
  description         String?
  implementation_plan String?
  priority            Int
  sort_order          Float
  status              TaskStatus @default(TO_DO)
  created_at          DateTime   @default(now())
  updated_at          DateTime   @updatedAt

  @@index([story_id, priority, sort_order])
  @@index([sprint_id, status])
}

model Todo {
  id         String   @id @default(cuid())
  user       User     @relation(fields: [user_id], references: [id], onDelete: Cascade)
  user_id    String
  product    Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
  product_id String?
  title      String
  done       Boolean  @default(false)
  archived   Boolean  @default(false)
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  @@index([user_id, done, archived])
  @@index([user_id, product_id])
}

model ProductMember {
  id         String   @id @default(cuid())
  product    Product  @relation(fields: [product_id], references: [id], onDelete: Cascade)
  product_id String
  user       User     @relation(fields: [user_id], references: [id], onDelete: Cascade)
  user_id    String
  created_at DateTime @default(now())

  @@unique([product_id, user_id])
  @@index([user_id])
  @@map("product_members")
}

Authenticatieflow

Registratie:
  POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB
  → iron-session cookie zetten → redirect /dashboard

Inloggen:
  POST /login → gebruiker ophalen op username → bcrypt vergelijken
  → bij match: iron-session cookie zetten → redirect /dashboard
  → bij mismatch: generieke foutmelding (geen onderscheid)

Sessie per request:
  proxy.ts → sessiecookie-aanwezigheid controleren
  → beschermde routes: redirect /login als geen sessiecookie aanwezig is
  → app layout valideert de volledige sessie server-side

API-aanroepen (Claude Code):
  Authorization: Bearer <token> header → SHA-256 hash → opzoeken in api_tokens
  → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten

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

QR-pairing flow (M10)

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

Sequence

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

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

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

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

Threat-model

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

TTL-rationale

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

Waarom geen secret in URL

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

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

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

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


Vraag-antwoord-kanaal Claude ↔ user (M11)

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

Sequence

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

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

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

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

Threat-model

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

Waarom hergebruik scrum4me_changes-kanaal

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

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

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

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


Projectstructuur

scrum4me/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (app)/                        # Beschermde routes
│   │   ├── layout.tsx                # Auth-check + navigatie
│   │   ├── dashboard/page.tsx        # Productenlijst
│   │   ├── products/
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/
│   │   │       ├── layout.tsx        # Zet actief product in Zustand store
│   │   │       ├── page.tsx          # Product Backlog (gesplitst scherm)
│   │   │       ├── solo/page.tsx     # Solo board (Kanban per ingelogde gebruiker)
│   │   │       ├── sprint/
│   │   │       │   ├── page.tsx      # Sprint Backlog (drie-paneel scherm)
│   │   │       │   └── planning/page.tsx  # Redirect → /sprint
│   │   ├── todos/page.tsx
│   │   └── settings/
│   │       ├── page.tsx              # Profiel, account, PB-overzicht, rollen, tokens
│   │       └── tokens/page.tsx
│   ├── api/                          # REST API voor Claude Code
│   │   ├── products/
│   │   │   └── [id]/
│   │   │       └── next-story/route.ts
│   │   ├── profile/
│   │   │   └── avatar/route.ts       # POST upload + GET serve profielfoto
│   │   ├── sprints/
│   │   │   └── [id]/
│   │   │       └── tasks/route.ts
│   │   ├── stories/
│   │   │   └── [id]/
│   │   │       ├── log/route.ts
│   │   │       └── tasks/reorder/route.ts
│   │   ├── tasks/
│   │   │   └── [id]/route.ts
│   │   └── todos/route.ts
├── components/
│   ├── ui/                           # shadcn/ui primitieven
│   ├── split-pane/                   # Gesplitst scherm component
│   ├── backlog/                      # PBI- en story-componenten
│   ├── sprint/                       # Sprint-componenten
│   ├── products/                     # ProductForm, TeamManager, ArchiveProductButton
│   ├── settings/                     # RoleManager, ProfileEditor, LeaveProductButton
│   └── dnd/                         # dnd-kit wrappers
├── lib/
│   ├── prisma.ts                     # Prisma Client singleton
│   ├── session.ts                    # iron-session configuratie
│   ├── auth.ts                       # login/register/token helpers
│   ├── api-auth.ts                   # Bearer token middleware voor API
│   ├── product-access.ts             # productAccessFilter helper (eigenaar of teamlid)
│   └── env.ts                        # Zod-gevalideerde env vars
├── stores/                           # Zustand stores
│   ├── planner-store.ts              # Optimistische drag-and-drop volgorde
│   ├── selection-store.ts            # Geselecteerd PBI / story
│   ├── sprint-store.ts               # Sprint Backlog taakvolgordes
│   ├── solo-store.ts                 # Solo board optimistische taakstatus
│   └── product-store.ts              # Actief product (naam + id) voor navbar
├── prisma/
│   ├── schema.prisma
│   ├── migrations/
│   └── seed.ts                       # Testdata uit Product Backlog document
├── proxy.ts                          # Next.js 16 proxy voor route protection
├── prisma.config.ts                  # Prisma v7 config (DATABASE_URL)
└── .env.example

Sleutelarchitectuurbeslissingen

Beslissing: iron-session in plaats van Auth.js / Supabase Auth

Keuze: iron-session voor versleutelde server-side sessiecookies Rationale: Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met { userId, isDemo } en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. Trade-off: Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig.

Beslissing: Route Handlers naast Server Actions

Keuze: Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API Rationale: Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. Trade-off: Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in lib/ die beide aanroepen.

Beslissing: Float voor sort_order

Keuze: Float in plaats van Int voor volgorde van PBI's, stories en taken Rationale: Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. (1.0 + 2.0) / 2 = 1.5). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). Trade-off: Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt.

Beslissing: Denormalisatie van product_id op stories en sprint_id op tasks

Keuze: product_id opslaan op zowel pbis als stories; sprint_id op zowel stories als tasks Rationale: Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. Trade-off: Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag.

Beslissing: Zustand voor client-side state management

Keuze: Vijf Zustand-stores naast Server Components Rationale: De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. useState per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. Trade-off: Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert.


Zustand stores

usePlannerStore — optimistische drag-and-drop volgorde

Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency.

// stores/planner-store.ts
import { create } from 'zustand'

interface PlannerStore {
  // Optimistische volgorde per container (id-arrays)
  pbiOrder: Record<string, string[]>    // productId → pbi-ids
  storyOrder: Record<string, string[]>  // pbiId → story-ids
  taskOrder: Record<string, string[]>   // storyId → taak-ids

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

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

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

Gebruikspatroon:

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

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

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

useSelectionStore — navigatieselectie

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

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

useSprintStore — Sprint Backlog interacties

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

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

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

useSoloStore — Solo board optimistische taakstatus

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

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

useProductStore — Actief product voor navbar

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

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

Data flow architectuur

┌─────────────────────────────────────────┐
│         Server Component (page.tsx)      │
│  Prisma query → initiële data + ids      │
│  → props naar Client Component          │
└──────────────────┬──────────────────────┘
                   │ initialIds, initialData
                   ▼
┌─────────────────────────────────────────┐
│       Client Component (panel.tsx)       │
│  useEffect → store.init(ids)            │
│  dnd-kit drag → store.reorder()         │
│             → Server Action (async)     │
│             → bij fout: store.rollback()│
└──────────────────┬──────────────────────┘
                   │ selecteert state via selector
                   ▼
┌─────────────────────────────────────────┐
│           Zustand Stores                 │
│  usePlannerStore  useSelectionStore      │
│  useSprintStore                          │
│                                          │
│  Alleen ephemere UI-staat               │
│  Nooit server-data of business logic    │
└─────────────────────────────────────────┘

Keuze: API-tokens opgeslagen als SHA-256 hashes in de api_tokens tabel Rationale: Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. Trade-off: Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken.


Realtime updates (M8)

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

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

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

Mutaties die NOTIFY triggeren

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

Server-side filter

/api/realtime/solo?product_id=... filtert NOTIFY-payloads op:

  • product_id matcht de query-param
  • sprint_id matcht de actieve sprint van het product (resolve éénmaal per connect)
  • assignee_id is gelijk aan de ingelogde userId (of null voor unassigned-story-claims)

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

Connection lifecycle

  • Open: EventSource('/api/realtime/solo?product_id=...') zodra de gebruiker op /solo is.
  • Reconnect: exponential backoff bij onerror (1s → 30s, reset bij ready event).
  • Pause op tab-hidden: document.visibilityState === 'hidden' sluit de stream actief. Bij visible wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden.
  • Hard close: server sluit zelf na 240s (Vercel maxDuration is 300s); client herconnect transparant.
  • Heartbeat: server stuurt elke 25s een : heartbeat-comment om proxies te keep-alive'n.

Bekende beperking M8: events die binnenkomen terwijl de tab hidden is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume.

Animatie

Voor task UPDATE-events wordt de store-update gewikkeld in document.startViewTransition(() => flushSync(() => handleEvent(payload))). flushSync dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist view-transition-name op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load.

Auth

Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen.


Demo-user policy (ST-1110)

Demo-gebruikers (is_demo = true in de database, isDemo: true in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags:

Laag 1 — Middleware-guard (proxy.ts)

proxy.ts blokkeert alle non-GET requests op /api/* voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt unsealData direct (geen getIronSession) omdat request.cookies in middleware RequestCookies is, niet de volledige CookieStore.

// Whitelist: paden die demo mag aanroepen ondanks non-GET
const DEMO_WRITE_ALLOWLIST = [
  '/api/cron/', // machine-auth, irrelevant voor demo
]
// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2

Laag 2 — Per-route guards (Server Actions & Route Handlers)

Elke schrijfactie controleert session.isDemo vóór DB-toegang:

if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }

QR-pairing (M10):

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

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

Laag 3 — UI-laag (DemoTooltip)

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

Patroon:

<DemoTooltip show={isDemo}>
  <Button disabled={isDemo} onClick={() => !isDemo && handleAction()}>
    Actie
  </Button>
</DemoTooltip>

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


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 → RUNNING → DONE
                           → FAILED
       → CANCELLED (door user)
CLAIMED → QUEUED (stale claim cleanup, >30min)

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
  branch, summary, error
  @@index([user_id, status])
  @@index([task_id, status])
  @@index([status, claimed_at])   — voor stale-claim cleanup

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.

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:

# Database
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"

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

# Optioneel
NODE_ENV="development"

Deployment

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

Deployment checklist (pre-launch)

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

Kostenscattting

Service Plan Maandelijkse kosten
Vercel Hobby Gratis
Neon Free tier (0.5 GB, 190 compute-uren) Gratis
GitHub Free Gratis
Domein Eigen domein (optioneel) ~€12/maand
Totaal €02/maand

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