Scrum4Me/docs/architecture/data-model.md
Madhura68 10c52e8b8f chore: remove prisma-erd-generator and stale erd refs
Vercel detecteert @prisma/client en runt automatisch `prisma generate`
zonder --generator filter. Daardoor probeerde de erd-generator op Vercel
te draaien en faalde op libnss3.so (puppeteer/Chrome niet beschikbaar in
de build container). Cascading: de Prisma-client werd niet ge-update,
runtime kreeg oude enum-waarden (ACTIVE i.p.v. OPEN).

ERD is dev-only documentatie en niet meer in productie nodig. Generator
+ dependency + npm scripts + de gegenereerde svg verwijderd. README,
prisma-client pattern en architecture docs bijgewerkt.

Build script blijft `prisma generate && next build` zodat de client ook
bij Vercel build-cache-hits opnieuw wordt gegenereerd.
2026-05-08 14:45:39 +02:00

519 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

---
title: "Data Model & Prisma Schema"
status: active
audience: [maintainer, contributor]
language: nl
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 | |
| 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? | 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 | Cache-buster voor avatar-URL |
**Indexes:** `username` (unique), `email` (unique), `active_product_id`
---
### `user_roles`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| user_id | String | FK → users (Cascade) | |
| role | Enum | `PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER \| ADMIN` | |
**Constraint:** unique `(user_id, role)`
---
### `api_tokens`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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? | | Null = actief |
**Indexes:** `token_hash` (unique). Eén token kan max. één `claude_workers`-record hebben.
---
### `products`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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
**Constraints:** unique `(user_id, name)`, unique `(user_id, code)`
---
### `pbis`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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 \| 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)`, `(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, 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.
---
### `stories`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| pbi_id | String | FK → pbis (Cascade) | |
| product_id | String | FK → products | Denormalisatie voor snellere queries |
| 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 \| 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)`, `(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 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]`).
---
### `story_logs`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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)`
---
### `sprints`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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? | | Wordt gezet bij overgang naar CLOSED |
**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)`
---
### `tasks`
| Kolom | Type | Constraints | Noten |
|---|---|---|---|
| id | String (cuid) | PK | |
| 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 \| 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)`, `(product_id)`
**Constraint:** unique `(product_id, code)``code` blijft stabiel bij re-parenting (Jira-stijl)
---
### `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 (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, 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) | |
| user_id | String | FK → users (Cascade) | |
| created_at | DateTime | default now() | |
**Constraint:** unique `(product_id, user_id)`
**Indexes:** `(user_id)`
---
### `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)`
---
## 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 (archiveren, 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.
---
## Enums (overzicht)
| 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) |
> 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).