chore: documentatie naar docs/, iconen bijgewerkt, theme.css verplaatst
- scrum4me-*.md en MD3_Color_Scheme_Documentation.md verplaatst naar docs/ - Srum4MeIcons.html verplaatst naar docs/icons.html - theme.css verplaatst van root naar app/styles/theme.css - Import in globals.css bijgewerkt - Alle app-iconen vervangen door nieuw logo (icon-master-light.svg) - AppIcon component bijgewerkt met nieuw SVG - CLAUDE.md verwijzingen bijgewerkt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e0513e07c
commit
b4371f5afb
17 changed files with 53 additions and 37 deletions
|
|
@ -1,726 +0,0 @@
|
|||
# Scrum4Me — Technische Architectuur
|
||||
|
||||
**Versie:** 0.1 — april 2026
|
||||
**Volgt op:** Functionele Specificatie v0.2
|
||||
|
||||
---
|
||||
|
||||
## Architectuursamenvatting
|
||||
|
||||
Scrum4Me is een desktop-first Next.js 15 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon (cloud) of SQLite (lokaal), 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. De volledige app draait ook lokaal zonder externe accounts.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Laag | Technologie | Rationale |
|
||||
|---|---|---|
|
||||
| Frontend framework | Next.js 15 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management |
|
||||
| UI runtime | React 19 | Standaard bij Next.js 15; 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; eenvoudig uitbreidbaar naar v2 teamgebruik |
|
||||
| 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 |
|
||||
| Database (lokaal) | SQLite via Prisma SQLite-adapter | Geen externe accounts nodig voor lokale ontwikkeling; `prisma db push` initialiseert schema |
|
||||
| ORM | Prisma v7 | Type-safe queries; ondersteunt zowel PostgreSQL als SQLite 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 |
|
||||
| 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) | Prisma + SQLite maakt lokale setup trivial zonder containers |
|
||||
| Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak |
|
||||
| tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients |
|
||||
|
||||
---
|
||||
|
||||
## Datamodel
|
||||
|
||||
### `users`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | Gegenereerd door Prisma |
|
||||
| username | String | unique, not null, min 3 | Inlognaam |
|
||||
| password_hash | String | not null | bcrypt hash (cost factor 12) |
|
||||
| is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten |
|
||||
| created_at | DateTime | default now() | |
|
||||
| updated_at | DateTime | auto-update | |
|
||||
|
||||
**Indexes:** `username` (unique lookup bij inloggen)
|
||||
|
||||
---
|
||||
|
||||
### `user_roles`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| user_id | String | FK → users, not null | |
|
||||
| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | |
|
||||
|
||||
**Indexes:** `(user_id)` — meerdere rollen per gebruiker
|
||||
**Constraint:** unique `(user_id, role)`
|
||||
|
||||
---
|
||||
|
||||
### `api_tokens`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| user_id | String | FK → users, not null | |
|
||||
| token_hash | String | not null | SHA-256 hash van het token |
|
||||
| label | String | nullable | Bijv. "Claude Code — laptop" |
|
||||
| created_at | DateTime | default now() | |
|
||||
| revoked_at | DateTime | nullable | Null = actief |
|
||||
|
||||
**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn)
|
||||
|
||||
---
|
||||
|
||||
### `products`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| user_id | String | FK → users, not null | |
|
||||
| name | String | not null, max 200 | Uniek per gebruiker |
|
||||
| description | String | nullable, max 1000 | |
|
||||
| repo_url | String | nullable | Gevalideerde URL |
|
||||
| definition_of_done | String | not null, max 500 | Vaste instelling per product |
|
||||
| archived | Boolean | default false | |
|
||||
| created_at | DateTime | default now() | |
|
||||
| updated_at | DateTime | auto-update | |
|
||||
|
||||
**Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten
|
||||
**Constraint:** unique `(user_id, name)`
|
||||
|
||||
---
|
||||
|
||||
### `pbis`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| product_id | String | FK → products (cascade delete) | |
|
||||
| title | String | not null, max 200 | |
|
||||
| description | String | nullable, max 2000 | |
|
||||
| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag |
|
||||
| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering |
|
||||
| created_at | DateTime | default now() | |
|
||||
| updated_at | DateTime | auto-update | |
|
||||
|
||||
**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm
|
||||
|
||||
---
|
||||
|
||||
### `stories`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| pbi_id | String | FK → pbis (cascade delete) | |
|
||||
| product_id | String | FK → products | Denormalisatie voor snellere queries |
|
||||
| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog |
|
||||
| title | String | not null, max 200 | |
|
||||
| description | String | nullable, max 2000 | |
|
||||
| acceptance_criteria | String | nullable, max 2000 | |
|
||||
| priority | Int | 1–4, not null | |
|
||||
| sort_order | Float | not null | |
|
||||
| status | Enum | OPEN \| IN_SPRINT \| DONE | |
|
||||
| created_at | DateTime | default now() | |
|
||||
| updated_at | DateTime | auto-update | |
|
||||
|
||||
**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`
|
||||
|
||||
---
|
||||
|
||||
### `story_logs`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| story_id | String | FK → stories (cascade delete) | |
|
||||
| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | |
|
||||
| content | String | not null | Tekst van plan of testuitvoer |
|
||||
| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT |
|
||||
| commit_hash | String | nullable | Alleen bij type COMMIT |
|
||||
| commit_message | String | nullable | Alleen bij type COMMIT |
|
||||
| created_at | DateTime | default now() | |
|
||||
|
||||
**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI
|
||||
|
||||
---
|
||||
|
||||
### `sprints`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| product_id | String | FK → products (cascade delete) | |
|
||||
| sprint_goal | String | not null, max 500 | |
|
||||
| status | Enum | ACTIVE \| COMPLETED | |
|
||||
| created_at | DateTime | default now() | |
|
||||
| completed_at | DateTime | nullable | |
|
||||
|
||||
**Indexes:** `(product_id, status)` — query voor actieve Sprint per product
|
||||
**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag)
|
||||
|
||||
---
|
||||
|
||||
### `tasks`
|
||||
|
||||
| Kolom | Type | Constraints | Noten |
|
||||
|---|---|---|---|
|
||||
| id | String (cuid) | PK | |
|
||||
| story_id | String | FK → stories (cascade delete) | |
|
||||
| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries |
|
||||
| title | String | not null, max 200 | |
|
||||
| description | String | nullable, max 1000 | |
|
||||
| priority | Int | 1–4, not null | |
|
||||
| sort_order | Float | not null | |
|
||||
| status | Enum | TO_DO \| IN_PROGRESS \| 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 | |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## 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 TaskStatus {
|
||||
TO_DO
|
||||
IN_PROGRESS
|
||||
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)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
todos Todo[]
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@unique([user_id, name])
|
||||
@@index([user_id, archived])
|
||||
}
|
||||
|
||||
model Pbi {
|
||||
id String @id @default(cuid())
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
title String
|
||||
description String?
|
||||
priority Int
|
||||
sort_order Float
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
stories Story[]
|
||||
|
||||
@@index([product_id, priority, sort_order])
|
||||
}
|
||||
|
||||
model Story {
|
||||
id String @id @default(cuid())
|
||||
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
||||
pbi_id String
|
||||
product Product @relation(fields: [product_id], references: [id])
|
||||
product_id String
|
||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||
sprint_id String?
|
||||
title String
|
||||
description String?
|
||||
acceptance_criteria String?
|
||||
priority Int
|
||||
sort_order Float
|
||||
status StoryStatus @default(OPEN)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
logs StoryLog[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([pbi_id, priority, sort_order])
|
||||
@@index([sprint_id, sort_order])
|
||||
@@index([product_id, status])
|
||||
}
|
||||
|
||||
model StoryLog {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
story_id String
|
||||
type LogType
|
||||
content String
|
||||
status TestStatus?
|
||||
commit_hash String?
|
||||
commit_message String?
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@index([story_id, created_at])
|
||||
}
|
||||
|
||||
model Sprint {
|
||||
id String @id @default(cuid())
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
sprint_goal String
|
||||
status SprintStatus @default(ACTIVE)
|
||||
created_at DateTime @default(now())
|
||||
completed_at DateTime?
|
||||
stories Story[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([product_id, status])
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
story_id String
|
||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||
sprint_id String?
|
||||
title String
|
||||
description String?
|
||||
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
|
||||
title String
|
||||
done Boolean @default(false)
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@index([user_id, done, archived])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
middleware.ts → iron-session cookie uitlezen → user_id + is_demo in request
|
||||
→ beschermde routes: redirect /login als geen geldige sessie
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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]/
|
||||
│ │ │ ├── page.tsx # Product Backlog (gesplitst scherm)
|
||||
│ │ │ ├── sprint/
|
||||
│ │ │ │ ├── page.tsx # Sprint Backlog (gesplitst scherm)
|
||||
│ │ │ │ └── planning/page.tsx # Sprint Planning (gesplitst scherm)
|
||||
│ │ ├── todos/page.tsx
|
||||
│ │ └── settings/
|
||||
│ │ ├── page.tsx
|
||||
│ │ └── tokens/page.tsx
|
||||
│ └── api/ # REST API voor Claude Code
|
||||
│ ├── products/
|
||||
│ │ └── [id]/
|
||||
│ │ └── next-story/route.ts
|
||||
│ ├── 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
|
||||
│ └── 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
|
||||
│ └── env.ts # Zod-gevalideerde env vars
|
||||
├── stores/ # Zustand stores
|
||||
│ ├── planner-store.ts # Optimistische drag-and-drop volgorde
|
||||
│ ├── selection-store.ts # Geselecteerd PBI / story
|
||||
│ └── sprint-store.ts # Sprint Backlog interacties
|
||||
│ ├── products.ts
|
||||
│ ├── pbis.ts
|
||||
│ ├── stories.ts
|
||||
│ ├── sprints.ts
|
||||
│ ├── tasks.ts
|
||||
│ └── todos.ts
|
||||
├── prisma/
|
||||
│ ├── schema.prisma
|
||||
│ ├── migrations/
|
||||
│ └── seed.ts # Testdata uit Product Backlog document
|
||||
├── middleware.ts # Sessiecheck op beschermde routes
|
||||
├── 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:** Drie 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
|
||||
setSelectedPbi: (id: string | null) => void
|
||||
setSelectedStory: (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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variabele | Doel | Waar te vinden |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard (cloud) of lokaal `file:./dev.db` (SQLite) |
|
||||
| `DIRECT_URL` | Directe verbinding voor migraties (Neon) | 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"
|
||||
# Lokaal (SQLite): DATABASE_URL="file:./dev.db"
|
||||
|
||||
# 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):** SQLite — `npx prisma db push` initialiseert schema
|
||||
**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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue