* chore(M10): swap demo-active sprint from M3.5 to M10 M3.5 was de demo-actieve sprint zolang er geen recentere milestone in progress was. Nu M10 het actieve werk is, willen we dat get_claude_context (en implement_next_story) ST-1001 als next-story teruggeven i.p.v. ST-350. Vereist een herhaling van npx prisma db seed na deze commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(M10): gate Solo demo-stories on M3.5 active milestone De hardcoded Solo Paneel-demoset uit M3.5 (priority=2) schreeuwt over de parser-driven M10-stories heen (priority=4) en laat get_claude_context op "Gebruikersauthenticatie opzetten" wijzen i.p.v. ST-1001. Sluit het blok nu alleen open als de actieve sprint van het Scrum4Me-product M3.5 betreft. Voor M10+ leveren de parser-stories zelf de bord-content; de demo-set blijft beschikbaar als M3.5 ooit weer ACTIVE wordt voor demo-doeleinden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(M10): drop hardcoded Solo Paneel demo data from seed DB wordt voortaan leidend voor de werkstaat; testdata voor andere projecten / demo-scenario's komt elders. Deze hardgecodeerde set was specifiek gemaakt voor de M3.5 Solo Paneel-demo en raakt nu het next_story-resultaat: priority=2 won van de M10 parser-stories (priority=4) waardoor get_claude_context op 'Gebruikersauthenticatie opzetten' bleef hangen i.p.v. ST-1001. Vervangt de eerdere M3.5-gating-aanpak (commit0e3228d) — schoner om het helemaal weg te halen dan met een conditional aanwezig te houden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(M10): add npm run seed shortcut Wrapt prisma db seed (die de bestaande prisma.seed-config in package.json gebruikt) zodat re-seeden één korte invocatie wordt zonder de prisma-CLI-syntaxis te onthouden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1001): add LoginPairing model + pg_notify trigger via migration Schema (prisma/schema.prisma): - model LoginPairing met id (cuid), secret_hash + desktop_token_hash (beide NOT NULL — scheiden mobiel- en desktop-bewijs), status (pending|approved|consumed |cancelled), optionele user_id met onDelete: SetNull, desktop_ua VarChar(255), desktop_ip VarChar(45) voor IPv6, created_at + expires_at + approved_at + consumed_at, indexes op (expires_at) en (status, expires_at) - back-relation login_pairings LoginPairing[] op User Migratie (20260427200734_add_login_pairing): - Prisma-gegenereerde DDL voor login_pairings + indexes + FK - Toegevoegde notify_pairing_change() functie + login_pairings_notify trigger op AFTER INSERT/UPDATE; emit pg_notify('scrum4me_pairing', payload) met { op: 'I'|'U', pairing_id, status } - DELETE niet ondersteund — pairings gaan naar consumed/cancelled, niet weg - Channel naam analoog aan scrum4me_changes uit ST-801 Verification: Node pg-client roundtrip-test via DATABASE_URL toonde notifies bij INSERT (op=I) en UPDATE (op=U) met correcte payload-shape. Bouwt voort op M8 LISTEN/NOTIFY-infra. SSE-route /api/auth/pair/stream/[id] in ST-1004 abonneert hierop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1002): add pairing helpers, pre-auth cookie + paired-session guard lib/auth/pairing.ts: pure crypto-helpers voor de QR-pairing flow. - generateMobileSecret() / generateDesktopToken() — beide 32 bytes base64url, los zodat ze elkaar niet onthullen - hashToken(t) — sha256-hex - verifyToken(t, hash) — timingSafeEqual met length-guard - isPairedSessionExpired(session) — geëxtraheerde helper zodat de Server- Component-render Date.now() niet rechtstreeks aanroept (React Compiler-flag) lib/auth/pair-cookie.ts: HttpOnly pre-auth cookie helpers (s4m_pair). - Path=/api/auth/pair, Max-Age=120s (gelijk aan pending-TTL pairing), SameSite=Lax, Secure in productie lib/session.ts: SessionData uitgebreid met optionele paired + pairedExpiresAt. app/(app)/layout.tsx: guard die paired-sessies vernietigt zodra pairedExpiresAt verstreken is en redirect naar /login. Tests: 14 unit-tests in __tests__/lib/auth/pairing.test.ts dekken hash- determinisme, timing-safe verify (true/false/length-mismatch), generator- uniciteit en vier expiry-scenario's voor isPairedSessionExpired. Quality gates: npm run lint (0 errors), tsc --noEmit clean, vitest 111/111. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1003): add /api/auth/pair/start with rate-limit + pre-auth cookie POST /api/auth/pair/start (anon, runtime: 'nodejs'): - Geen authenticateApiRequest — desktop heeft nog geen sessie - Genereert los mobileSecret + desktopToken via lib/auth/pairing - Persisteert alleen sha256-hashes in login_pairings; status='pending', expires_at = now + 2 min - Slaat user-agent + best-effort IP op (afgekapt op kolom-grootte) - Set-Cookie via setPairCookie helper: HttpOnly, Path=/api/auth/pair, Max-Age=120, SameSite=Lax - Response body: { pairingId, mobileSecret, expiresAt, qrUrl } met qrUrl = origin/m/pair#id=…&s=… → secret reist alleen via fragment (#…), nooit in querystring of access logs Rate-limit: 'pair-start' expliciet aan lib/rate-limit.ts CONFIGS toegevoegd voor self-documentatie (10/min, gelijk aan login). Tests __tests__/api/pair-start.test.ts (6 cases): - 200 met body-shape (pairingId, mobileSecret 43-char base64url, qrUrl met fragment, expiresAt ISO) - alleen hashes in DB, geen plaintext - cookie set met juiste opties - UA + IP afgekapt op kolom-grootte - IP=null als x-forwarded-for ontbreekt - 11e POST levert 429 met NL foutmelding Quality gates: lint 0 errors, tsc clean (na prisma generate), vitest 117/117. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth GET /api/auth/pair/stream/[pairingId]: - runtime: 'nodejs', maxDuration: 300, dynamic: 'force-dynamic' - Auth via s4m_pair HttpOnly cookie (readPairCookie + verifyToken tegen desktop_token_hash); 401 zonder cookie of bij hash-mismatch, 404 als pairing onbekend, 410 als verlopen — geen geheim materiaal in URL of querystring - Hergebruikt LISTEN/NOTIFY-pattern uit app/api/realtime/solo/route.ts: ReadableStream + dedicated pg.Client + heartbeat 25s + hard-close 240s - Channel: scrum4me_pairing; filter notifies op pairing_id-match - Initial 'state'-event direct na connect met huidige status (voorkomt race waarbij approve net vóór SSE-open landt — desktop ziet 'm alsnog) - Auto-close zodra status consumed/cancelled binnenkomt - Fallback DIRECT_URL → DATABASE_URL (de eerste staat lokaal op een placeholder) Tests __tests__/api/pair-stream.test.ts (4 cases — auth-paden): - 401 zonder cookie (en geen DB-call gedaan) - 404 op onbekende pairingId - 410 op verlopen pairing - 401 op cookie/hash-mismatch Full-stream-test (LISTEN+notify-roundtrip) is een handmatige acceptatietest in ST-1008 — niet zinvol te mocken voor v1. Quality gates: lint 0 errors, tsc clean, vitest 121/121. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1005): add pairing server actions + mobile confirmation page actions/pairing.ts (Server Actions, volgt docs/patterns/server-action.md): - getPairingForApproval(pairingId, mobileSecret): auth + Zod + lookup + status + expiry + verifyToken-check; retourneert UA/IP/username voor de bevestigingspagina. Demo MAG aanroepen (read-only). - approvePairing: zelfde checks PLUS demo-blokkade (session.isDemo). Update status pending→approved, zet user_id + approved_at, bumpt expires_at +5min. Postgres-trigger emit pg_notify automatisch — desktop-SSE pikt het op. - cancelPairing: status pending→cancelled. Demo mag annuleren. - Tagged-union return-type uit loadPendingPairing voor schone discriminatie. app/(app)/m/pair/page.tsx (Server Component, achter (app)/layout-guard): - Geen searchParams uitlezen — page leest URL niet. Alleen statische uitleg + PairConfirmation client-island. app/(app)/m/pair/pair-confirmation.tsx (Client Component): - useEffect parseert window.location.hash voor #id=…&s=… (server ziet de fragment nooit) - Roept getPairingForApproval om UA/IP/username op te halen - Toont kaart "Inloggen als <username> op dit apparaat?" met UA + IP + expliciete waarschuwing tegen phishing-QR; Bevestig/Annuleer-knoppen - Na approve: window.history.replaceState wist de hash zodat back/forward de secret niet meer onthult; transitioneert naar success-state - queueMicrotask voor synchrone setState om React-Compiler "cascading renders" warning te vermijden Tests __tests__/actions/pairing.test.ts (11 cases): - getPairingForApproval: ok + 5 fail-paths (geen sessie, approved, verlopen, verkeerd secret, ongeldige cuid) - approvePairing: happy + demo-block + verkeerd secret (geen DB-write) - cancelPairing: happy + demo mag annuleren Quality gates: lint 0 errors, tsc clean, vitest 132/132. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session POST /api/auth/pair/claim (cookie-auth, runtime: 'nodejs'): - Auth via s4m_pair HttpOnly cookie alleen — body bevat enkel pairingId, geen secret. Het cookie-token is het bewijs. - Atomic state-transitie via prisma.loginPairing.updateMany met composite WHERE (id + status='approved' + desktop_token_hash + expires_at > now); PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één count=1 zien — de rest 410. - Bij geen rij geüpdate: tweede findFirst om te disambigueren tussen 401 (cookie matcht geen pairing) en 410 (al consumed/cancelled). Cookie altijd gecleared bij faalpaden om herhaalde verwerking te voorkomen. - Bij succes: getIronSession schrijft scrum4me-session-cookie met userId + isDemo (uit user-record als vangnet) + paired=true + pairedExpiresAt = now+8h (kortere TTL voor publieke desktops). s4m_pair wordt gecleared. - Logging onder NODE_ENV !== 'production' alleen pairingId, nooit cookie of mobileSecret. Tests __tests__/api/pair-claim.test.ts (7 cases): - 200 happy: updateMany met juiste WHERE, iron-session payload (userId, isDemo, paired, pairedExpiresAt ~8h), save() called, s4m_pair cleared - demo-vangnet: isDemo=true wordt doorgezet - 401 zonder cookie (geen DB-call) - 400 op malformed body - 400 zonder pairingId - 410 op tweede claim (al consumed, cookie cleared, geen session.save) - 401 op cookie/hash-mismatch (cookie cleared) Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(M10): bump pending-TTL to 5min + repair MD3 contrast on pair page TTL: 2 min was te kort voor handmatig curl-paste-confirm-testen — gebruiker zag 'Pairing verlopen' voor hij kon bevestigen. Bumpt naar 5 min (gelijk aan approved-TTL): nog steeds tight voor security, ruim voor menselijke reactie. - app/api/auth/pair/start/route.ts: PENDING_TTL_MS 120s → 300s - lib/auth/pair-cookie.ts: MAX_AGE_SECONDS 120 → 300 - __tests__/api/pair-start.test.ts: maxAge en expires_at-window meegegroeid Kleuren: bevestigingspagina gebruikte bg-destructive/10 + text-destructive- foreground — beide lichte kleuren, te weinig contrast. Vervangen door MD3 container-tokens (zelfde patroon als components/auth/auth-form.tsx): - error-state: bg-error-container + text-error-container-foreground + border-l-4 border-error - approved-state: bg-success-container + foreground + accent-border - cancelled-state: bg-surface-container-high + neutral foreground Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-1007): add QR login button on /login with SSE listener Voltooit de desktop-zijde van de QR-pairing-flow. Gebruiker klikt "Inloggen via mobiel" naast het wachtwoord-formulier → krijgt een QR-code → telefoon scant en bevestigt → desktop wordt automatisch ingelogd zonder dat er ooit een wachtwoord is getypt op het publieke apparaat. app/(auth)/login/qr-login-button.tsx (Client Component): - Phase-state: idle | starting | showing | expired | claiming - klik → POST /api/auth/pair/start (credentials:'same-origin' voor s4m_pair) - QRCodeSVG met fragment-URL als value (level=M, 200px); aria-label - EventSource('/api/auth/pair/stream/<id>', { withCredentials: true }) vereist voor cookie-auth — standaard verstuurt EventSource geen credentials - bij data.status === 'approved': es.close → POST /pair/claim → router.push('/dashboard') - aftellende timer (mm:ss); bij 0s → 'expired' state met Vernieuwen-knop - cleanup bij unmount: removeEventListener + close - A11y: <details> sectie toont fragment-URL als kopieerbare tekst voor screenreaders en gebruikers zonder camera app/(auth)/login/page.tsx: QrLoginButton onder het bestaande wachtwoord-form met "of"-divider, achter de bestaande surface-container-low styling. Dependency: qrcode.react ^4.2.0 (client-side SVG; geen extra round-trip; mobileSecret blijft op desktop in JS-geheugen). Quality gates: lint 0 errors, tsc clean, vitest 139/139, next build slaagt (login-route static, m/pair en pair/* dynamic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-1008): document QR-pairing endpoints, flow, threat-model + pattern docs/API.md — nieuwe sectie 'Auth — QR-pairing (M10)' met alle drie endpoints (start, stream, claim), cookie-mechaniek, foutcodes (400/401/410/429), curl-voorbeelden inclusief --cookie-jar. docs/scrum4me-architecture.md — sectie 'QR-pairing flow' met: - Mermaid sequence-diagram (start → QR → scan → approve → claim) - Threat-model (replay, phishing-QR, demo-block, rate-limit, secret-leak, long-lived sessie) met expliciete mitigaties - TTL-rationale voor de drie tijden (5min pending / +5min approved / 8u paired) - Subsectie 'Waarom geen secret in URL' — fragment-eigenschap + HttpOnly cookie + twee gescheiden hashes docs/patterns/qr-login.md — herbruikbaar pattern 'QR-pairing via unauth-SSE + pre-auth cookie' met de drie endpoints, vier security-uitgangspunten, sjabloon-bestanden, TTL-richtlijn, en wanneer NIET te gebruiken. CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe pattern-doc verwijst. Acceptatie ST-1008 (zeven scenario's): - Happy path: gedekt door manuele E2E in vorige stories (gebruiker bevestigde dat M10-stories op Solo bord verschijnen + curl-roundtrip werkt) - Demo-block: actions/pairing.test.ts → approvePairing demo → Niet beschikbaar - Replay: pair-claim.test.ts → 410 op tweede claim - Expiry tijdens pending: pair-stream.test.ts + pairing.test.ts → 410/error - Expiry tussen approve+claim: pair-claim.test.ts → 410 - Cookie-mismatch op SSE/claim: pair-stream.test.ts + pair-claim.test.ts → 401 - Secret niet in URL/logs: per ontwerp — fragment + cookie reizen niet via URL-paden of querystrings (gedocumenteerd in architecture.md) Quality gates: lint 0 errors, tsc clean, vitest 139/139 (16 files). M10 is hiermee compleet — feat/M10-qr-login bevat 13 commits klaar voor gebruiker-acceptatie en PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: move logout form outside DropdownMenuContent so requestSubmit fires UserMenu's hidden logout-form zat binnen <DropdownMenuContent>. Wanneer een DropdownMenuItem onSelect vuurt, sluit base-ui de menu en unmount het content-portal in dezelfde tick — waardoor de form verdwijnt voordat requestSubmit() wordt aangeroepen, en logoutFormRef.current null is. Form naar top-level van het component verplaatsen (als sibling van DropdownMenu, binnen Fragment) houdt de ref geldig. Geen DOM-side-effecten — form is hidden, zat nooit visueel in het menu. Quality gates: lint 0 errors, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: call logoutAction directly via useTransition instead of form-ref submit De form-ref-dance werkte niet betrouwbaar in de huidige base-ui: - onSelect vuurde requestSubmit() op een hidden form - Form zat eerst binnen DropdownMenuContent (form geunmount → ref null) - Form daarna naar top-level verplaatst — vuurde nog steeds geen request af, vermoedelijk doordat onSelect in deze base-ui-build niet (consistent) een click-event genereerde dat de form-API trigger'de Vervang door directe call: Server Actions kunnen sinds Next.js 14 als async functie worden aangeroepen vanuit Client Components. useTransition voorkomt dat de UI bevriest tijdens de redirect. Naast onSelect ook onClick als veiligheid voor het geval base-ui later weer van event-prop wisselt — beide handlers wijzen naar dezelfde idempotente function (handleLogout via startTransition). Pendingstate ('Uitloggen…' label, disabled item) zodat dubbele klikken niet dubbele logoutAction-calls afvuren. Quality gates: lint 0 errors, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-1007): listen for SSE 'state' event so approve-during-connect resolves De SSE-route in ST-1004 stuurt de catch-up payload als `event: state\ndata: …` om een race te dichten: tussen pair/start en SSE-open kan de mobiel approven, de pg_notify fired vóór onze LISTEN actief is en gaat verloren (Postgres queuet niet). De server compenseert door direct na connect een `state`-event te sturen met de huidige status uit de DB. Maar de client luisterde alleen op 'message'. EventSource routeert events met `event: <name>` enkel naar listeners voor die exacte naam — het catch-up event werd dus genegeerd. Gevolg bij een (zeldzame) race: QR blijft hangen tot expiry omdat noch de notify noch de catch-up doorkomt. Fix: dezelfde onMessage-handler ook aan 'state' binden (en netjes unsubscriben bij cleanup). Geen server-side wijziging nodig — protocol bleef bewust om de semantische scheiding 'initial state' vs 'live notify' te behouden voor toekomstige clients die er onderscheid in willen maken. Severity: middel-laag — kleine race-window, geen data/security-impact, alleen "QR doet niks" tot user op Vernieuwen klikt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(M10): close pair/stream race + demo-block on cancelPairing Twee P1's uit code-review: (1) pair/stream race: de findUnique die de pairing-status leest gebeurde vóór LISTEN actief was. Als de mobiel approvet tussen die query en LISTEN: pg_notify fired in dat venster gaat verloren (Postgres queuet niet voor abonnees die nog niet listen) én was de eerder gelezen status stale. De catch-up state- event emitte dus 'pending' terwijl de DB inmiddels 'approved' was, en de desktop bleef hangen tot expiry. Tweede findUnique toegevoegd ná LISTEN actief is: het venster sluit, omdat elke approve na dat punt via de notify-handler doorkomt. Aanvullend op de eerdere client-side fix die 'state' events nu ook routeert (commitd6e71f9). (2) cancelPairing demo-block: cancel was een DB-write zonder demo-guard, in tegenspraak met de "demo = 403 op writes"-regel. Demo-blokkade toegevoegd; bestaande test omgedraaid naar 'wordt geblokkeerd, geen DB-write'. Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
42 KiB
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 | 1–4, 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 | 1–4, not null | |
| sort_order | Float | not null | |
| status | Enum | OPEN | IN_SPRINT | DONE | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update |
Indexes: (pbi_id, priority, sort_order), (sprint_id, sort_order), (product_id, status)
story_logs
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| story_id | String | FK → stories (cascade delete) | |
| type | Enum | IMPLEMENTATION_PLAN | TEST_RESULT | COMMIT | |
| content | String | not null | Tekst van plan of testuitvoer |
| status | Enum | PASSED | FAILED, nullable | Alleen bij type TEST_RESULT |
| commit_hash | String | nullable | Alleen bij type COMMIT |
| commit_message | String | nullable | Alleen bij type COMMIT |
| created_at | DateTime | default now() |
Indexes: (story_id, created_at) — chronologische weergave in de UI
sprints
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| sprint_goal | String | not null, max 500 | |
| status | Enum | ACTIVE | COMPLETED | |
| created_at | DateTime | default now() | |
| completed_at | DateTime | nullable |
Indexes: (product_id, status) — query voor actieve Sprint per product
Constraint: Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag)
tasks
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| story_id | String | FK → stories (cascade delete) | |
| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries |
| title | String | not null, max 200 | |
| description | String | nullable, max 1000 | |
| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via PATCH /api/tasks/:id |
| priority | Int | 1–4, not null | |
| sort_order | Float | not null | |
| status | Enum | TO_DO | IN_PROGRESS | REVIEW | DONE | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update |
Indexes: (story_id, priority, sort_order), (sprint_id, status)
todos
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users, not null | |
| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product |
| title | String | not null | |
| done | Boolean | default false | |
| archived | Boolean | default false | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update |
Indexes: (user_id, done, archived) — standaard weergave filtert op actieve todo's; (user_id, product_id) — filteren per product
product_members
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| user_id | String | FK → users (cascade delete) | |
| created_at | DateTime | default now() |
Indexes: (user_id) — opzoeken van producten waarbij een gebruiker lid is
Constraint: unique (product_id, user_id) — één lidmaatschap per gebruiker per product
Koppelt Developer-gebruikers aan een product backlog. De eigenaar (products.user_id) heeft altijd volledige toegang; via product_members kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet user_roles. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol DEVELOPER heeft.
Toegangsmodel en schrijfbeveiliging
Producttoegang is centraal gedefinieerd als:
- eigenaar:
products.user_id === gebruiker.id - teamlid:
product_membersbevat(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_idofstory_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/updateManywanneer een uniquedeleteanders 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 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
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:
- URL-fragment voor
mobileSecret. Het deel achter de#wordt door browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client Component leestwindow.location.hashen POST't de waarde in een body — ook niet in een URL. - HttpOnly cookie voor
desktopToken. Cookie-headers worden meestal NIET in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendienPath=/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.
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_idmatcht de query-paramsprint_idmatcht de actieve sprint van het product (resolve éénmaal per connect)assignee_idis gelijk aan de ingelogdeuserId(ofnullvoor 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/solois. - Reconnect: exponential backoff bij
onerror(1s → 30s, reset bijreadyevent). - Pause op tab-hidden:
document.visibilityState === 'hidden'sluit de stream actief. Bijvisiblewordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. - Hard close: server sluit zelf na 240s (Vercel
maxDurationis 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:
# 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_URLenDIRECT_URLgezet in Vercel dashboard (Neon connection strings)SESSION_SECRETgezet in Vercel dashboard (min. 32 tekens)prisma migrate deployuitgevoerd 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 buildlokaal geslaagd zonder TypeScript-fouten
Kostenscattting
| Service | Plan | Maandelijkse kosten |
|---|---|---|
| Vercel | Hobby | Gratis |
| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis |
| GitHub | Free | Gratis |
| Domein | Eigen domein (optioneel) | ~€1–2/maand |
| Totaal | €0–2/maand |
Bij groei naar meerdere gebruikers (v2): Neon Launch plan (
$19/maand) en Vercel Pro ($20/maand) zijn de eerste stappen omhoog.