Scrum4Me/docs/scrum4me-architecture.md
Janpeter Visser 2460eae9df
feat(M8): Realtime Solo Paneel via Postgres LISTEN/NOTIFY (ST-801..ST-806) (#8)
* feat(ST-801): pg_notify triggers on tasks and stories

Add notify_task_change() and notify_story_change() PL/pgSQL functions
plus AFTER INSERT/UPDATE/DELETE triggers on tasks and stories. Each
write emits a JSONB payload on the 'scrum4me_changes' channel with
op, entity, id, product_id, sprint_id, assignee_id and (for UPDATE)
the list of changed columns. Tasks resolve product/sprint/assignee
via their parent story so the SSE handler can filter without an extra
DB roundtrip.

The migration is a side-effect-only change (no Prisma model/schema
diff) so the Prisma Client and TypeScript types are unaffected.

Verified locally with a node-pg LISTEN client: both task and story
mutations produce the expected payload within milliseconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-802): SSE route /api/realtime/solo for Solo Paneel updates

Node.js-runtime route handler die een dedicated pg-Client opent op
DIRECT_URL en LISTEN't op het scrum4me_changes-kanaal. Per inkomende
NOTIFY-payload filtert het server-side op product (uit query-param),
sprint (active sprint van het product) en persoonlijke relevantie
(assignee_id == userId, of assignee_id IS NULL voor stories voor de
claim-lijst).

Auth via iron-session cookie:
- 401 als sessie ontbreekt
- 400 als product_id query-param ontbreekt
- 403 als de user geen toegang heeft tot het product
- demo-tokens mogen lezen (geen write-tools op deze route)

Stream-bouw:
- text/event-stream met juiste headers (no-cache, no-transform,
  X-Accel-Buffering: no voor proxy-vrije buffering)
- ready-event bij connect met product_id en active sprint_id
- heartbeat (SSE-comment) elke 25s
- hard-close na 240s als safety-net onder Vercel maxDuration; client
  herconnect via EventSource
- cleanup op request.signal abort (tab dicht / refresh)

Cleanup-pad sluit de pg-client en de stream-controller idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-803): useSoloRealtime hook with EventSource lifecycle

Client-only hook die de SSE-stream van /api/realtime/solo opent en
voor de UI twee statussen exposed:
  - status: 'connecting' | 'open' | 'disconnected'
  - showConnectingIndicator: pas true als status !== 'open' >2s duurt
    (ST-805 animatie B — voorkomt flikker bij microscopische
    disconnects)

Lifecycle-gedrag:
- Reconnect met exponential backoff start 1s, plafond 30s, reset op
  'ready'-event
- Page Visibility API: bij hidden sluit de hook de connectie en stopt
  reconnect-pogingen; bij visible reopent direct
- onerror van EventSource wordt onderschept zodat we eigen backoff
  kunnen voeren ipv de browser-default
- Volledige cleanup op unmount

Voert events naar useSoloStore.getState().handleRealtimeEvent — de
echte dispatch-logica met pendingOps en gedifferentieerde
apply{Task,Story}{Update,Create,Delete} landt in ST-804. Hier is dat
nog een stub zodat dit zelf-staand kan compileren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-804): solo-store realtime dispatch + pendingOps

Wire de SSE-events uit /api/realtime/solo door naar de Zustand-store
zodat het Solo Paneel zonder refresh meebeweegt met DB-mutaties uit
welke bron dan ook (web, REST, MCP).

Migratie 20260427000216_extend_realtime_payload: voegt new-state
velden aan de pg_notify-payload toe (task_status, task_sort_order,
task_title, story_status, story_sort_order, story_title, story_code)
zodat de client geen extra fetch nodig heeft per event.

Store-uitbreiding (stores/solo-store.ts):
- pendingOps: Set<task-id> die optimistic-writes markeert; realtime
  echos voor die ids worden onderdrukt zodat eigen UI-mutaties niet
  twee keer toegepast worden of door een latere echo overschreven
- handleRealtimeEvent: dispatch op entity + op
  - task UPDATE/INSERT: bestaande tasks krijgen status/title/sort_order
    bijgewerkt; onbekende tasks worden genegeerd (story-context
    ontbreekt — gebruiker ziet ze pas na refresh)
  - task DELETE: verwijdert uit store
  - story UPDATE: werkt story_title/story_code bij op alle child-tasks
    in de store
  - story DELETE: verwijdert alle child-tasks (cascade reflectie)

Unit-test: 7 scenario's (status update, pendingOps echo-suppression,
DELETE, story-rename cascade, story-delete cascade, unknown task
no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-805): wire useSoloRealtime + live indicator + column-move animatie

- SoloBoard roept useSoloRealtime(productId) aan, zo komt elke task/
  story-mutatie uit web/REST/MCP binnen via SSE en wordt door de
  store-dispatcher (ST-804) verwerkt
- markPending/clearPending rond de drag-drop optimistic write zodat
  de echo van de eigen Server Action de store niet dubbel beweegt
- RealtimeIndicator: kleine status-dot in de header
  - groen ('Live') wanneer SSE-stream open OF tijdens de eerste 2s
    grace-period (animatie B in de hook — voorkomt micro-flikker)
  - grijs ('Verbinden…') na 2s in connecting-state
  - rood ('Verbroken') na 2s in disconnected-state
- Animatie A (kolom-move): bij task UPDATE-events wikkelt de hook de
  store-dispatch in document.startViewTransition + flushSync. SoloTask-
  cards krijgen view-transition-name `solo-task-<id>` (alleen wanneer
  niet aan het draggen) zodat de browser de positie-shift soepel
  animeert van bezig naar klaar (en omgekeerd)

Bestaande 89 tests blijven groen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: pin dev to port 3000 + predev hook to clear stale processes

Voorkomt dat een stale next-dev op 3000 ervoor zorgt dat een tweede
'npm run dev' op 3001 start — wat sessies, cookies en MCP-config
inconsistent maakt.

- dev: '-p 3000' expliciet
- predev: lsof/kill alles op 3000 (idempotent — falen is ok)
- CLAUDE.md: regel toegevoegd onder Conventies

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(M8): make SSE-stream survive Solo Paneel mutations

Symptoom op feat/ST-801-realtime-triggers initial implementation:
elke task-update sloot de open SSE-stream af en triggerde een
herverbinding met backoff. In de tussentijd gemiste events.

Oorzaak: Server Actions in App Router doen een impliciete
route-tree refresh die client components remount; daarmee killt
React de useEffect die de EventSource beheert.

Fix in twee delen:

1. Hef de realtime-hook op naar de (app)-layout via een nieuwe
   `SoloRealtimeBridge`-component. Layouts overleven Server-
   Action-refreshes beter dan pages, en de bridge leest het
   product-id uit de URL via usePathname. Connection-status
   (status, showConnectingIndicator) gaat naar de solo-store
   zodat SoloBoard 'm uit een gedeelde plek kan lezen.

2. Vervang updateTaskStatusAction en updateTaskPlanAction in de
   Solo-componenten door fetch naar de bestaande Route Handler
   `PATCH /api/tasks/[id]`. Route Handlers triggeren geen
   page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts
   accepteert nu naast Bearer-tokens ook iron-session cookies
   zodat browser-fetches zonder token werken.

Bijkomend: actions/tasks.ts laat /solo bewust niet meer
revalideren (wordt nu via realtime gedekt). Sprint/planning blijft
wel revalidaten — geen realtime daar.

Toegevoegd:
- components/solo/realtime-bridge.tsx — mount in (app) layout
- scripts/realtime-mutate.ts — handige test-helper voor externe
  mutaties (alsof MCP/REST schrijft) tijdens acceptance

Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor
ST-806 acceptance; worden later gestript.

Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource
om de paar seconden vanwege de 6-connectie-limiet en retry-
heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2
multiplexing) zou beide browsers stabiel moeten houden — Vercel
preview test is volgende stap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(dev): disable reactStrictMode for stable SSE testing locally

Strict Mode dubbel-mount maakt langlopende connecties tijdens
ontwikkeling moeilijk te observeren — de mount/unmount cycles in
dev rondom Hot Reload + Turbopack triggeren herhaalde EventSource-
reconnects die verbergen of de productie-flow stabiel is.

Productie kent dit gedrag niet (Strict Mode is dev-only). Heroverwogen
als M8 acceptance rond is — kan dan weer aan voor andere effects-
discipline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(M8): unguard debug logs in /api/realtime/solo for Vercel diagnose

Maakt de connect/listen/notify-logs zichtbaar in Vercel function-logs
op preview zodat we kunnen zien waarom events niet doorkomen ondanks
DIRECT_URL-fix. Logt ook hostname (gemaskeerd) + sprint id bij connect,
en pg client errors + end-events voor closed connections.

Wordt gestript in ST-806 voordat de PR uit draft gaat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(debug): add /debug-realtime page + bare SSE endpoint

Tijdelijke debug-tooling voor M8-acceptance op Vercel preview.

- app/api/debug/realtime-stream/route.ts — geen auth, geen filtering;
  dropt elke pg_notify-event op scrum4me_changes rauw door als SSE
- app/debug-realtime/page.tsx — open zonder login op de root, toont
  binnenkomende events in een simpele <table>

Doel: isoleren of de SSE + Postgres LISTEN-pipe op Vercel überhaupt
events laat zien, los van iron-session, productfilter of solo-store.
Als ook deze niets binnen krijgt: probleem zit in pg connection of
Vercel function lifecycle. Als deze wel events toont: probleem zit
hoger in de stack (filter, store, hook).

VERWIJDEREN voordat de PR uit draft gaat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(debug): extend /debug-realtime with stats, emit-button and filters

Bouwt de basale luister-tabel uit met diagnostische tooling om de
SSE+LISTEN-pipe stress-vrij te kunnen valideren.

Toegevoegd:
- POST /api/debug/emit-test-notify — vuurt een handmatige pg_notify
  op scrum4me_changes met een synthetic payload (debug:true) zonder
  een echte DB-UPDATE te doen. Isoleert de SSE-route van Prisma/triggers.
- DebugRealtimeClient: stats-grid (status, reconnects, total events,
  since last event met >30s rood-warning, largest gap, first-event-
  time), emit-button, reset-stats, filters op type en entity
  (incl. "debug only").
- Type/entity kolom in de tabel met kleuring per type.

Geen impact op productie- of solo-flow. Tijdelijke testtooling;
verwijderen wanneer we deze pagina niet meer nodig hebben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(debug): add Layer 2 — mini Zustand-store + dispatch toggles

Test of SSE-event → store → render-pipeline werkt buiten de Solo
Paneel context. Mirrort het patroon van solo-store maar minimaal.

- debug-store.ts: kleine Zustand-store met tasks + applyEvent +
  applyCount/skipCount-tellers
- store-panel.tsx: rendert store-state in een tabel met statuskleuring
- client.tsx: drie layer-toggles (dispatch / flushSync / startView-
  Transition) + lift dispatch in onmessage. Zo kunnen we elke
  combinatie isoleren

Bevestigd: alle drie de toggles werken op het bare /debug-realtime
endpoint. Volgende laag is Server Action revalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ST-806): cleanup debug-pages, document realtime, restore strict mode

M8 acceptance heeft de end-to-end pipeline bevestigd: trigger → NOTIFY →
SSE → store → View Transition. De cycling-symptomen waren een artefact
van testen via terminal (alt-tab triggert visibility-pause-by-design),
geen bug. Tijd om de tijdelijke instrumentatie en debug-pagina's weg te
halen en de architectuur op te schrijven.

- Verwijder /debug-realtime, /(app)/debug-realtime-app, /api/debug/*
- Strip debug-logs uit /api/realtime/solo (closed-reden alleen in dev)
- reactStrictMode weer aan
- CONNECTING_INDICATOR_DELAY_MS 2s → 4s (minder flikker bij micro-disconnects)
- Nieuwe sectie "Realtime updates (M8)" in scrum4me-architecture.md:
  diagram, NOTIFY-bron, server-filter, connection lifecycle inclusief
  visibility-pause + bekende beperking, animatie, auth
- DIRECT_URL env-rij uitgebreid met realtime-doel
- GET /api/realtime/solo gedocumenteerd in API.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:32 +02:00

897 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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) | |
| 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 |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |
**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm
---
### `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
// 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 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
title String
description String?
priority Int
sort_order Float
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
@@index([product_id, priority, sort_order])
}
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
```
---
## 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.
```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 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.
---
## 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) | ~€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.