docs: sync data-model, glossary en specs met huidig schema (#164)

Brengt de docs gelijk met de werkelijkheid na PBI-46/47/50/58/59/61/63
en M12. Belangrijkste fixes:

- data-model.md herschreven naar prisma/schema.prisma: nieuwe entiteiten
  (Idea, IdeaLog, IdeaProduct, UserQuestion, ClaudeQuestion, ClaudeJob,
  SprintRun, SprintTaskExecution, ClaudeWorker, LoginPairing,
  PushSubscription, ModelPrice, ProductMember), nieuwe enums
  (FAILED/EXCLUDED, OPEN/CLOSED/ARCHIVED, ADMIN, etc.) en codes
  (PBI/ST/T/SP-N) toegevoegd; verwijderde todos-tabel verwijderd.
- glossary.md: Sprint zonder "max 1 actief" (PBI-63), Story/Task incl.
  FAILED/EXCLUDED, Todo verwijderd, Idea/SprintRun/ClaudeJob/
  verify_result toegevoegd.
- project-structure.md: app/(app)/todos vervangen door
  ideas/insights/jobs/manual/admin/solo; api-tree volledig.
- overview.md: "geen realtime in v1" en Docker-rationale herschreven —
  Postgres LISTEN/NOTIFY + SSE, claude_jobs als queue, opt-in
  Docker-deploy-flow.
- functional.md: F-08 Todo-lijst -> Ideeen-laag, F-09 multi-sprint,
  F-10 Task-status incl. FAILED/EXCLUDED, F-11 endpoint-lijst,
  navigatiestructuur, datamodel-schets en Flow 3 bijgewerkt.
- README.md API-tabel: /api/todos weg, ideas/jobs/users/profile/health
  toegevoegd, kort over realtime/auth-pair/internal/cron.
- patterns + mcp-integration runbook: Todo-/ACTIVE-references vervangen
  door Idea/OPEN; create_todo MCP-tool note over verwijdering.

Linkcheck groen (105 files), INDEX hergegenereerd (98 docs).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-08 08:16:44 +02:00 committed by GitHub
parent 3842c05ae9
commit f7464db837
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 544 additions and 424 deletions

View file

@ -3,27 +3,34 @@ title: "Data Model & Prisma Schema"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
last_updated: 2026-05-08
related: [auth-and-sessions.md](./auth-and-sessions.md)
---
## Datamodel
> Bron van waarheid is [`prisma/schema.prisma`](../../prisma/schema.prisma); dit document samenvat de tabellen en sleutelinvarianten. Bij twijfel wint het schema.
### `users`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | Gegenereerd door Prisma |
| id | String (cuid) | PK | |
| username | String | unique, not null, min 3 | Inlognaam |
| email | String? | unique | Optioneel; gebruikt voor wachtwoord-reset-flows |
| 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) |
| bio | String? | max 160 | Korte profielomschrijving |
| bio_detail | String? | max 2000 | Uitgebreide profielbeschrijving |
| must_reset_password | Boolean | default false | Forceert wachtwoord-reset bij volgende login |
| avatar_data | Bytes? | | Profielfoto als WebP bytea (max 700×700) |
| active_product_id | String? | FK → products (SetNull) | Persistente actieve PB-keuze (M9) |
| idea_code_counter | Int | default 0 | Sequentiële teller voor user-scoped idea-codes |
| min_quota_pct | Int | default 20 | Worker stand-by-drempel (M13 quota-check) |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL |
| updated_at | DateTime | auto-update | Cache-buster voor avatar-URL |
**Indexes:** `username` (unique lookup bij inloggen)
**Indexes:** `username` (unique), `email` (unique), `active_product_id`
---
@ -32,10 +39,9 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users, not null | |
| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | |
| user_id | String | FK → users (Cascade) | |
| role | Enum | `PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER \| ADMIN` | |
**Indexes:** `(user_id)` — meerdere rollen per gebruiker
**Constraint:** unique `(user_id, role)`
---
@ -45,13 +51,13 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| 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" |
| user_id | String | FK → users (Cascade) | |
| token_hash | String | unique, not null | SHA-256 hash van het token |
| label | String? | | Bijv. "Claude Code — laptop" |
| created_at | DateTime | default now() | |
| revoked_at | DateTime | nullable | Null = actief |
| revoked_at | DateTime? | | Null = actief |
**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn)
**Indexes:** `token_hash` (unique). Eén token kan max. één `claude_workers`-record hebben.
---
@ -60,17 +66,20 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| 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 |
| user_id | String | FK → users (Cascade) | Eigenaar |
| name | String | not null | Uniek per gebruiker |
| code | String? | max 30 | Optionele afkorting; uniek per gebruiker als gezet |
| description | String? | | |
| repo_url | String? | | Gevalideerde URL |
| definition_of_done | String | not null | Vaste instelling per product |
| auto_pr | Boolean | default false | Automatische PR creëren na sprint-completion |
| pr_strategy | Enum | `SPRINT \| STORY \| SPRINT_BATCH`, default `SPRINT` | Granulariteit voor auto-PR |
| 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)`
**Constraints:** unique `(user_id, name)`, unique `(user_id, code)`
---
@ -79,19 +88,22 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| code | String | nullable, max 30 | Auto-gegenereerd of handmatig |
| title | String | not null, max 200 | |
| description | String | nullable, max 2000 | |
| priority | Int | 14, not null | 1 = Kritiek, 4 = Laag |
| product_id | String | FK → products (Cascade) | |
| code | String | max 30, not null | Verplicht; auto-gegenereerd of handmatig |
| title | String | not null | |
| description | String? | | |
| priority | Int | 14 | 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) |
| status | Enum | `READY \| BLOCKED \| FAILED \| DONE`, default `READY` | Auto-promotie naar DONE bij sprint-close |
| pr_url | String? | | URL van de PR die deze PBI dekt (PBI-strategie) |
| pr_merged_at | DateTime? | | Gezet wanneer de PR daadwerkelijk gemerged is |
| 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
**Indexes:** `(product_id, priority, sort_order)`, `(product_id, status)`
**Constraint:** unique `(product_id, code)`
**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.
**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE, zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een DONE-PBI wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY.
---
@ -100,21 +112,24 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| pbi_id | String | FK → pbis (cascade delete) | |
| pbi_id | String | FK → pbis (Cascade) | |
| product_id | String | FK → products | Denormalisatie voor snellere queries |
| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog |
| title | String | not null, max 200 | |
| description | String | nullable, max 2000 | |
| acceptance_criteria | String | nullable, max 2000 | |
| priority | Int | 14, not null | |
| sprint_id | String? | FK → sprints | Null = in Product Backlog |
| assignee_id | String? | FK → users (SetNull) | Story-claim op het Solo-bord |
| code | String | max 30, not null | Auto-gegenereerd of handmatig |
| title | String | not null | |
| description | String? | | |
| acceptance_criteria | String? | | |
| priority | Int | 14 | |
| sort_order | Float | not null | |
| status | Enum | OPEN \| IN_SPRINT \| DONE | |
| status | Enum | `OPEN \| IN_SPRINT \| DONE \| FAILED`, default `OPEN` | |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |
**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`
**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`, `(sprint_id, assignee_id)`
**Constraint:** unique `(product_id, code)`
**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]`).
**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story heropend, dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN` (dat zou "terug in productbacklog" betekenen, een sprint-management-actie). 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]`).
---
@ -123,15 +138,16 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| 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 |
| story_id | String | FK → stories (Cascade) | |
| type | Enum | `IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT` | |
| content | String | not null | |
| status | Enum | `PASSED \| FAILED`? | Alleen bij type `TEST_RESULT` |
| commit_hash | String? | | Alleen bij type `COMMIT` |
| commit_message | String? | | Alleen bij type `COMMIT` |
| metadata | Json? | | Vrije bag voor bron-info (bv. agent, model_id) |
| created_at | DateTime | default now() | |
**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI
**Indexes:** `(story_id, created_at)`
---
@ -140,14 +156,43 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| 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 | |
| product_id | String | FK → products (Cascade) | |
| code | String | max 30, not null | Auto-gegenereerd `SP-N` per product (PBI-59) |
| sprint_goal | String | not null | |
| status | Enum | `OPEN \| CLOSED \| ARCHIVED \| FAILED`, default `OPEN` | |
| start_date | Date? | | Optionele planningsmetadata |
| end_date | Date? | | Optionele planningsmetadata |
| created_at | DateTime | default now() | |
| completed_at | DateTime | nullable | |
| completed_at | DateTime? | | Wordt gezet bij overgang naar CLOSED |
**Indexes:** `(product_id, status)` — query voor actieve Sprint per product
**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag)
**Indexes:** `(product_id, status)`
**Constraint:** unique `(product_id, code)`
**Eén product, meerdere sprints (PBI-63):** een product kan tegelijk meer dan één sprint hebben. `OPEN` is geen exclusieve status; de sprint-switcher in de product-header laat de gebruiker tussen sprints kiezen. Stories in `IN_SPRINT` linken via `sprint_id` naar één specifieke sprint.
---
### `sprint_runs`
Eén `sprint_runs`-record per uitvoering van de SPRINT_IMPLEMENTATION-flow (PBI-46/47/50). Houdt status, branch en chained retries bij wanneer een run is gepauzeerd of mislukt.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| sprint_id | String | FK → sprints (Cascade) | |
| started_by_id | String | FK → users | Wie de run startte |
| status | Enum | `QUEUED \| RUNNING \| PAUSED \| DONE \| FAILED \| CANCELLED` | |
| pr_strategy | Enum | `SPRINT \| STORY \| SPRINT_BATCH` | Snapshot van de strategie bij start |
| branch | String? | | Werkbranch voor de run |
| pr_url | String? | | |
| started_at / finished_at | DateTime? | | |
| failure_reason | String? | | Vrij tekstveld bij FAILED/CANCELLED |
| failed_task_id | String? | FK → tasks (SetNull) | Eerste task die de run brak (cascade-FAIL) |
| pause_context | Json? | | Gevalideerd door Zod (`lib/sprint-run/pause-context.ts`) |
| previous_run_id | String? | unique, FK → sprint_runs (SetNull) | Chain naar een eerdere run |
| created_at / updated_at | DateTime | | |
**Indexes:** `(sprint_id, status)`, `(started_by_id, status)`
---
@ -156,51 +201,269 @@ related: [auth-and-sessions.md](./auth-and-sessions.md)
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| story_id | String | FK → stories (cascade delete) | |
| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries |
| title | String | not null, max 200 | |
| description | String | nullable, max 1000 | |
| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` |
| priority | Int | 14, not null | |
| story_id | String | FK → stories (Cascade) | |
| product_id | String | FK → products (Cascade) | Denormalisatie voor product-scoped queries |
| sprint_id | String? | FK → sprints | Denormalisatie; geërfd van story bij sprint-toevoeging |
| code | String | max 30, not null | Auto-gegenereerd `T-N` per product |
| title | String | not null | |
| description | String? | | |
| implementation_plan | String? | | Opgeslagen via Server Action of `PATCH /api/tasks/:id` |
| priority | Int | 14 | |
| sort_order | Float | not null | |
| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | |
| status | Enum | `TO_DO \| IN_PROGRESS \| REVIEW \| DONE \| FAILED \| EXCLUDED`, default `TO_DO` | `EXCLUDED` slaat verify-skip op tijdens een sprint-run |
| verify_only | Boolean | default false | Run-mode: alleen verifiëren, niet implementeren |
| verify_required | Enum | `ALIGNED \| ALIGNED_OR_PARTIAL \| ANY`, default `ALIGNED_OR_PARTIAL` | Drempel waarop een job's verify_result als acceptabel telt |
| repo_url | String? | | Optionele override van `product.repo_url` voor tasks die in een andere repo wonen |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |
**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)`
**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)`, `(product_id)`
**Constraint:** unique `(product_id, code)``code` blijft stabiel bij re-parenting (Jira-stijl)
---
### `todos`
### `claude_jobs`
Job-queue waarop `wait_for_job` (MCP) atomisch claimt via `FOR UPDATE SKIP LOCKED`. Eén rij per task-implementatie, idea-grill, idea-make-plan, plan-chat of sprint-run.
| 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 | |
| user_id | String | FK → users (Cascade) | |
| product_id | String | FK → products (Cascade) | |
| task_id | String? | FK → tasks (Cascade) | Bij `TASK_IMPLEMENTATION` of `SPRINT_IMPLEMENTATION` |
| idea_id | String? | FK → ideas (Cascade) | Bij `IDEA_GRILL` / `IDEA_MAKE_PLAN` |
| sprint_run_id | String? | FK → sprint_runs (SetNull) | Koppel naar de bovenliggende run |
| kind | Enum | `TASK_IMPLEMENTATION \| IDEA_GRILL \| IDEA_MAKE_PLAN \| PLAN_CHAT \| SPRINT_IMPLEMENTATION` | |
| status | Enum | `QUEUED \| CLAIMED \| RUNNING \| DONE \| FAILED \| CANCELLED \| SKIPPED` | |
| claimed_by_token_id | String? | FK → api_tokens (SetNull) | Auth-koppel voor `update_job_status` |
| claimed_at / started_at / finished_at / pushed_at | DateTime? | | Lifecycle-stempels |
| verify_result | Enum? | `ALIGNED \| PARTIAL \| EMPTY \| DIVERGENT` | Alleen voor task-/sprint-jobs |
| model_id | String? | | Anthropic model dat de agent rapporteerde |
| input_tokens / output_tokens / cache_read_tokens / cache_write_tokens | Int? | | Token-usage voor billing-overzicht |
| plan_snapshot | String? | | Bevroren plan op claim-moment |
| base_sha / head_sha / branch / pr_url | String? | | Git-context |
| summary | String? | | Vrije agent-samenvatting bij DONE |
| error | String? | | Reden bij FAILED |
| retry_count | Int | default 0 | |
| lease_until | DateTime? | | Stale-CLAIMED → terug naar QUEUED na 30 min |
| created_at / updated_at | DateTime | | |
**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product
**Indexes:** `(user_id, status)`, `(task_id, status)`, `(idea_id, status)`, `(sprint_run_id, status)`, `(status, claimed_at)`, `(status, finished_at)`, `(status, lease_until)`
---
### `sprint_task_executions`
Bevroren scope-snapshot per `SPRINT_IMPLEMENTATION`-claim (PBI-50). Bij claim wordt voor elke `TO_DO`-task in scope één `PENDING`-record gemaakt met `implementation_plan` + `verify_required` gesnapshot. Worker en gate werken uitsluitend op deze rows; latere wijzigingen aan Task hebben geen invloed op de lopende batch.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| sprint_job_id | String | FK → claude_jobs (Cascade) | De parent SPRINT_IMPLEMENTATION-job |
| task_id | String | FK → tasks (Cascade) | |
| order | Int | not null | Volgorde binnen de batch |
| plan_snapshot | String (Text) | not null | Het bevroren implementation_plan |
| verify_required_snapshot | Enum | `ALIGNED \| ALIGNED_OR_PARTIAL \| ANY` | |
| verify_only_snapshot | Boolean | default false | |
| base_sha / head_sha | String? | | |
| status | Enum | `PENDING \| RUNNING \| DONE \| FAILED \| SKIPPED` | |
| verify_result | Enum? | `ALIGNED \| PARTIAL \| EMPTY \| DIVERGENT` | |
| verify_summary / skip_reason | String? (Text) | | |
| started_at / finished_at | DateTime? | | |
| created_at / updated_at | DateTime | | |
**Constraint:** unique `(sprint_job_id, task_id)`
**Indexes:** `(sprint_job_id, order)`
---
### `model_prices`
Prijslookup voor Anthropic-modellen, gebruikt door de jobs-pagina om kosten te berekenen.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| model_id | String | unique | Anthropic model-id (bv. `claude-opus-4-7`) |
| input_price_per_1m | Decimal(12,6) | | USD per 1M tokens (default) |
| output_price_per_1m | Decimal(12,6) | | |
| cache_read_price_per_1m | Decimal(12,6) | | |
| cache_write_price_per_1m | Decimal(12,6) | | |
| currency | String | default `USD` | |
| created_at / updated_at | DateTime | | |
---
### `claude_workers`
Live-presence-record per actieve agent worker. Ingevoegd bij MCP-startup, geüpdatet via `worker_heartbeat` (5s), opgeruimd bij SIGTERM. NavBar telt actieve workers op `last_seen_at < now() - 15s`.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users (Cascade) | |
| token_id | String | unique, FK → api_tokens (Cascade) | Eén worker per token |
| product_id | String? | | Optioneel; gerapporteerd door de agent |
| started_at / last_seen_at | DateTime | default now() | |
| last_quota_pct | Int? | | M13 pre-flight quota-check |
| last_quota_check_at | DateTime? | | |
**Indexes:** `(user_id, last_seen_at)`
---
### `product_members`
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 hier opgeslagen — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| user_id | String | FK → users (cascade delete) | |
| product_id | String | FK → products (Cascade) | |
| user_id | String | FK → users (Cascade) | |
| 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
**Constraint:** unique `(product_id, user_id)`
**Indexes:** `(user_id)`
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.
---
### `ideas`
Idea-entity (M12) tussen losse notitie en PBI. Een idea wordt eerst _gegrilled_ (interactieve Q&A → `grill_md`), daarna gemateriaaliseerd tot een plan (`plan_md`) dat deterministisch geparseerd wordt naar PBI + stories + tasks. Vervangt de oude `todos`-tabel volledig (atomische migratie ST-1239 — todos zijn gedropt).
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users (Cascade) | |
| product_id | String? | FK → products (SetNull) | Primaire scope; null = unscoped capture |
| code | String | max 30, not null | Sequentieel per gebruiker via `idea_code_counter` |
| title | String | not null | |
| description | String? | max 4000 | Initiële tekst |
| grill_md | String? (Text) | | Output van IDEA_GRILL |
| plan_md | String? (Text) | | Output van IDEA_MAKE_PLAN |
| pbi_id | String? | unique, FK → pbis (SetNull) | Wordt gevuld na materialisatie |
| status | Enum | `DRAFT \| GRILLING \| GRILL_FAILED \| GRILLED \| PLANNING \| PLAN_FAILED \| PLAN_READY \| PLANNED`, default `DRAFT` | |
| archived | Boolean | default false | |
| created_at / updated_at | DateTime | | |
**Constraint:** unique `(user_id, code)`
**Indexes:** `(user_id, archived, status)`, `(user_id, product_id)`
---
### `idea_products`
Optionele secundaire producten waar een idea ook impact heeft.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| idea_id | String | FK → ideas (Cascade) | |
| product_id | String | FK → products (Cascade) | |
| created_at | DateTime | default now() | |
**Constraint:** unique `(idea_id, product_id)`
**Indexes:** `(product_id)`
---
### `idea_logs`
Activiteitenlog per idea — soortgelijke rol als `story_logs` voor stories.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| idea_id | String | FK → ideas (Cascade) | |
| type | Enum | `DECISION \| NOTE \| GRILL_RESULT \| PLAN_RESULT \| STATUS_CHANGE \| JOB_EVENT` | |
| content | String (Text) | not null | |
| metadata | Json? | | |
| created_at | DateTime | default now() | |
**Indexes:** `(idea_id, created_at)`
---
### `user_questions`
Vrije vragen die een gebruiker aan zichzelf stelt op een idea (M12). Wordt door de Idea-detail-UI ingelezen om te helpen bij het grillen.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| idea_id | String | FK → ideas (Cascade) | |
| user_id | String | not null | Eigenaar |
| question | String (Text) | not null | |
| answer | String? (Text) | | |
| status | Enum (lowercase) | `pending \| answered`, default `pending` | |
| created_at / updated_at | DateTime | | |
**Indexes:** `(idea_id, status)`, `(user_id)`
---
### `claude_questions`
Persistent vraag-antwoord-kanaal van een agent (via `mcp__scrum4me__ask_user_question`) richting de actieve gebruiker (M11). LISTEN/NOTIFY pusht het antwoord terug naar de wachtende agent. Zie [architecture/claude-question-channel.md](./claude-question-channel.md).
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| story_id | String? | FK → stories (Cascade) | Eén van story/task/idea is verplicht |
| task_id | String? | FK → tasks (SetNull) | |
| idea_id | String? | FK → ideas (Cascade) | |
| product_id | String | FK → products (Cascade) | Gedenormaliseerd voor het SSE-filter |
| asked_by | String | FK → users | Token-houder = Claude |
| question | String (Text) | not null | |
| options | Json? | | `string[]` voor multi-choice; null voor free-text |
| status | String | not null | `open \| answered \| cancelled \| expired` |
| answer | String? (Text) | | |
| answered_by | String? | FK → users | |
| answered_at | DateTime? | | |
| created_at | DateTime | default now() | |
| expires_at | DateTime | not null | Default `now() + 24h`, ingesteld door MCP-tool |
**Indexes:** `(story_id, status)`, `(idea_id, status)`, `(product_id, status)`, `(status, expires_at)`
---
### `login_pairings`
QR-pairing-flow (M10). Desktop start een pairing, telefoon scant en bevestigt; daarna kan de desktop ruilen voor een sessie.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| secret_hash | String | not null | Hash van het pairing-secret |
| desktop_token_hash | String | not null | Hash van het pre-auth desktop-token |
| status | String | not null | `pending \| approved \| consumed \| expired` |
| user_id | String? | FK → users (SetNull) | Gezet bij approval |
| desktop_ua | String? | max 255 | UA-string van de desktop-aanvraag |
| desktop_ip | String? | max 45 | IP voor audit |
| created_at | DateTime | default now() | |
| expires_at | DateTime | not null | TTL voor afhandeling |
| approved_at / consumed_at | DateTime? | | |
**Indexes:** `(expires_at)`, `(status, expires_at)`
---
### `push_subscriptions`
Web Push subscriptions per gebruiker, voor notificaties.
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users (Cascade) | |
| endpoint | String | unique, not null | Push-service endpoint |
| p256dh / auth | String | not null | VAPID keys |
| user_agent | String? | | UA bij subscribe |
| created_at / last_used_at | DateTime | default now() | |
**Indexes:** `(user_id)`
---
@ -211,7 +474,7 @@ 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.
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 (archiveren, teamleden beheren).
Schrijfoperaties volgen deze invarianten:
@ -225,236 +488,32 @@ Schrijfoperaties volgen deze invarianten:
---
## Prisma Schema (excerpt)
## Enums (overzicht)
```prisma
// prisma/schema.prisma
| Enum | Waarden |
|---|---|
| `Role` | `PRODUCT_OWNER`, `SCRUM_MASTER`, `DEVELOPER`, `ADMIN` |
| `PbiStatus` | `READY`, `BLOCKED`, `FAILED`, `DONE` |
| `StoryStatus` | `OPEN`, `IN_SPRINT`, `DONE`, `FAILED` |
| `TaskStatus` | `TO_DO`, `IN_PROGRESS`, `REVIEW`, `DONE`, `FAILED`, `EXCLUDED` |
| `SprintStatus` | `OPEN`, `CLOSED`, `ARCHIVED`, `FAILED` |
| `SprintRunStatus` | `QUEUED`, `RUNNING`, `PAUSED`, `DONE`, `FAILED`, `CANCELLED` |
| `PrStrategy` | `SPRINT`, `STORY`, `SPRINT_BATCH` |
| `LogType` | `IMPLEMENTATION_PLAN`, `TEST_RESULT`, `COMMIT` |
| `TestStatus` | `PASSED`, `FAILED` |
| `ClaudeJobStatus` | `QUEUED`, `CLAIMED`, `RUNNING`, `DONE`, `FAILED`, `CANCELLED`, `SKIPPED` |
| `ClaudeJobKind` | `TASK_IMPLEMENTATION`, `IDEA_GRILL`, `IDEA_MAKE_PLAN`, `PLAN_CHAT`, `SPRINT_IMPLEMENTATION` |
| `VerifyResult` | `ALIGNED`, `PARTIAL`, `EMPTY`, `DIVERGENT` |
| `VerifyRequired` | `ALIGNED`, `ALIGNED_OR_PARTIAL`, `ANY` |
| `SprintTaskExecutionStatus` | `PENDING`, `RUNNING`, `DONE`, `FAILED`, `SKIPPED` |
| `IdeaStatus` | `DRAFT`, `GRILLING`, `GRILL_FAILED`, `GRILLED`, `PLANNING`, `PLAN_FAILED`, `PLAN_READY`, `PLANNED` |
| `IdeaLogType` | `DECISION`, `NOTE`, `GRILL_RESULT`, `PLAN_RESULT`, `STATUS_CHANGE`, `JOB_EVENT` |
| `UserQuestionStatus` | `pending`, `answered` (lowercase, niet UPPER_SNAKE) |
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")
}
```
> API-grens: `TaskStatus` en `StoryStatus` worden tussen DB (UPPER_SNAKE) en API (lowercase) vertaald via `lib/task-status.ts` (zie [ADR-0004](../adr/0004-status-enum-mapping.md)).
---
## Prisma Schema
De volledige, levende definitie staat in [`prisma/schema.prisma`](../../prisma/schema.prisma). Genereer de ERD lokaal met `npm run db:erd` (zie [README — Database](../../README.md#database)). Het ERD-diagram zelf staat in [docs/assets/erd.svg](../assets/erd.svg).

View file

@ -3,18 +3,18 @@ title: "Scrum4Me — Architecture Overview"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
last_updated: 2026-05-08
related: [data-model.md](./data-model.md), [project-structure.md](./project-structure.md)
---
**Versie:** 0.1 — april 2026
**Versie:** 0.2 — mei 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.
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 door Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen verplichte 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. Realtime-updates voor Solo-bord en Backlog gaan via Postgres `LISTEN/NOTIFY` + Server-Sent Events; geen externe broker. Een eigen MCP-server (`scrum4me-mcp`) biedt agents een job-queue (`claude_jobs`) bovenop dezelfde database. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp.
---
@ -49,9 +49,9 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger
| 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 |
| WebSockets / externe realtime-broker (Pusher, Ably, Supabase Realtime) | Postgres `LISTEN/NOTIFY` + SSE dekt de realtime-behoefte zonder een tweede auth-laag of extra infrastructuur — zie `architecture/project-structure.md` |
| Redis | Geen caching- of queuerequirements; de `claude_jobs`-tabel met `FOR UPDATE SKIP LOCKED` doet het werk dat een queue-broker anders zou doen |
| Docker voor lokale dev | Voor lokale ontwikkeling volstaat Neon. Er is wél een opt-in Docker-deploy-flow (`scrum4me-docker`, native arm64 op Mac, NAS-flow opt-in) — die is voor deploy, niet voor dev |
| 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 |

View file

@ -3,7 +3,7 @@ title: "Project Structure, Stores, Realtime & Job Queue"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
last_updated: 2026-05-08
related: [data-model.md](./data-model.md)
---
@ -23,11 +23,20 @@ scrum4me/
│ │ │ └── [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
│ │ │ └── sprint/
│ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm)
│ │ │ └── planning/page.tsx # Redirect → /sprint
│ │ ├── solo/page.tsx # Solo board (top-level; Kanban per ingelogde gebruiker)
│ │ ├── ideas/ # Idea-laag (M12) — vervangt vroegere /todos
│ │ │ ├── page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── jobs/page.tsx # Job-queue inzicht (PBI-59)
│ │ ├── insights/page.tsx # Tokenkosten + run-statistieken
│ │ ├── manual/ # In-app developer manual (PBI-58)
│ │ │ ├── layout.tsx
│ │ │ ├── _components/
│ │ │ └── [[...slug]]/page.tsx # Catch-all route voor alle manual-secties
│ │ ├── admin/ # Admin-only schermen
│ │ └── settings/
│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens
│ │ └── tokens/page.tsx
@ -41,22 +50,21 @@ scrum4me/
│ │ └── products/[id]/
│ │ ├── page.tsx # Mobile Product Backlog (tab-mode op <1024px)
│ │ └── solo/page.tsx # Mobile Solo (3-koloms-kanban)
│ ├── 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
│ ├── api/ # REST API voor Claude Code en interne SSE
│ │ ├── auth/pair/ # QR-pairing endpoints (start/claim/stream)
│ │ ├── cron/ # cleanup-agent-artifacts, expire-questions
│ │ ├── debug/ # emit-test-notify, realtime-stream (dev-only)
│ │ ├── health/route.ts # Liveness + ?db=1 ping
│ │ ├── ideas/ # Idea CRUD + grill/plan trigger
│ │ ├── internal/push/ # Web-push send + test-send (INTERNAL_PUSH_SECRET)
│ │ ├── jobs/[id]/sub-tasks/ # SprintTaskExecution-rows uitlezen
│ │ ├── products/[id]/ # next-story, claude-context
│ │ ├── profile/avatar/route.ts # POST upload + GET serve profielfoto
│ │ ├── realtime/ # SSE-streams: backlog, jobs, notifications, solo
│ │ ├── sprints/[id]/tasks/ # GET sprint-taken
│ │ ├── stories/[id]/ # log + tasks/reorder
│ │ ├── tasks/[id]/route.ts # PATCH status + implementation_plan
│ │ └── users/[id]/avatar/ # GET avatar van een specifieke user
├── components/
│ ├── ui/ # shadcn/ui primitieven
│ ├── split-pane/ # Gesplitst scherm component
@ -65,7 +73,9 @@ scrum4me/
│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton
│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton
│ ├── mobile/ # LandscapeGuard, MobileTabBar, LogoutButton
│ └── dnd/ # dnd-kit wrappers
│ ├── ideas/ # Idea-detail, grill-chat, plan-preview
│ ├── jobs/ # Job-card, filter-popover, view-switch
│ └── dnd/ # dnd-kit wrappers
├── lib/
│ ├── prisma.ts # Prisma Client singleton
│ ├── session.ts # iron-session configuratie