Scrum4Me/docs/architecture/data-model.md

15 KiB
Raw Blame History


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 14, 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 14, not null
sort_order Float not null
status Enum OPEN | IN_SPRINT | DONE
created_at DateTime default now()
updated_at DateTime auto-update

Indexes: (pbi_id, priority, sort_order), (sprint_id, sort_order), (product_id, status)

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 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 14, not null
sort_order Float not null
status Enum TO_DO | IN_PROGRESS | REVIEW | DONE
created_at DateTime default now()
updated_at DateTime auto-update

Indexes: (story_id, priority, sort_order), (sprint_id, status)


todos

Kolom Type Constraints Noten
id String (cuid) PK
user_id String FK → users, not null
product_id String? FK → products, nullable Optioneel in UI; SetNull bij verwijderen product
title String not null
done Boolean default false
archived Boolean default false
created_at DateTime default now()
updated_at DateTime auto-update

Indexes: (user_id, done, archived) — standaard weergave filtert op actieve todo's; (user_id, product_id) — filteren per product


product_members

Kolom Type Constraints Noten
id String (cuid) PK
product_id String FK → products (cascade delete)
user_id String FK → users (cascade delete)
created_at DateTime default now()

Indexes: (user_id) — opzoeken van producten waarbij een gebruiker lid is Constraint: unique (product_id, user_id) — één lidmaatschap per gebruiker per product

Koppelt Developer-gebruikers aan een product backlog. De eigenaar (products.user_id) heeft altijd volledige toegang; via product_members kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet user_roles. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol DEVELOPER heeft.


Toegangsmodel en schrijfbeveiliging

Producttoegang is centraal gedefinieerd als:

  • eigenaar: products.user_id === gebruiker.id
  • teamlid: product_members bevat (product_id, user_id)

Code gebruikt hiervoor productAccessFilter(userId) uit lib/product-access.ts. Route Handlers en Server Actions mogen geen eigenaar-only filter (user_id) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren.

Schrijfoperaties volgen deze invarianten:

  • Controleer eerst authenticatie en session.isDemo.
  • Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging.
  • Controleer de parent-resource met productAccessFilter.
  • Vertrouw bulk-ID's nooit los: haal de records eerst op met id in (...) plus de parent-scope (product_id, pbi_id, sprint_id of story_id) en weiger de operatie als aantallen niet exact overeenkomen.
  • Weiger dubbele IDs in reorder- en beslissingslijsten.
  • Leid denormalized foreign keys af van de database-parent (pbi.product_id, sprint.product_id) en niet van form-data of JSON body.
  • Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped deleteMany/updateMany wanneer een unique delete anders onveilig zou zijn.

Prisma Schema (excerpt)

// prisma/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")
}