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