# Scrum4Me — Technische Architectuur **Versie:** 0.1 — april 2026 **Volgt op:** Functionele Specificatie v0.2 --- ## Architectuursamenvatting Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. --- ## Stack | Laag | Technologie | Rationale | |---|---|---| | Frontend framework | Next.js 16 (App Router) | Stabiel, wijdverbreid, naadloze Vercel-deployment; SSR vereist voor auth-cookie-management | | UI runtime | React 19 | Standaard bij Next.js 16; brengt `useActionState`, `useFormStatus` en de React Compiler (experimenteel) mee — minder boilerplate bij Server Actions | | Taal | TypeScript (strict) | Type-veiligheid is essentieel voor een solo developer zonder reviewlaag; vangt datamodel-mismatches vroeg | | Client state | Zustand | Minimale boilerplate voor ephemere UI-staat (selectie, optimistische drag-and-drop volgorde); leeft naast Server Components zonder conflict | | Styling | Tailwind CSS + shadcn/ui | Snelle iteratie; toegankelijke componentprimitieven; desktop-first layouts goed ondersteund | | Database (cloud) | PostgreSQL via Neon | Serverless Postgres, gratis tier voldoende voor MVP; native PostgreSQL zonder vendor lock-in | | ORM | Prisma v7 | Type-safe queries; PostgreSQL via adapter; migraties zijn deterministisch | | Authenticatie | Custom — iron-session + bcrypt | Username/password zonder e-mail vereist geen externe auth-provider; iron-session beheert versleutelde cookies server-side | | Drag-and-drop | dnd-kit | Actief onderhouden, React-native hooks, 60fps bij grote lijsten, ondersteuning voor meerdere containers | | REST API | Next.js Route Handlers (`/app/api/`) | Naast Server Actions nodig voor Claude Code-integratie; Route Handlers zijn volledig HTTP-compatibel | | Image processing | Sharp | Avataruploads worden gevalideerd, geschaald en als WebP opgeslagen in PostgreSQL | | Analytics | Vercel Analytics (`@vercel/analytics/next`) | Pageviews zonder extra client-configuratie; component staat in `app/layout.tsx` | | Hosting | Vercel | Zero-config Next.js deployment; preview-URLs per PR; gratis tier voldoende voor v1 | | CI/CD | GitHub Actions | Lint + typecheck + build op elke PR; Vercel handelt de daadwerkelijke deploy af | --- ## Wat we NIET gebruiken (en waarom) | Technologie | Afgewezen omdat | |---|---| | Supabase Auth | Username/password zonder e-mail past niet in Supabase Auth's flow; onnodige afhankelijkheid voor wat iron-session zelf afhandelt | | NextAuth / Auth.js | Overkill voor username/password zonder providers; voegt complexiteit toe zonder voordeel bij deze auth-vereisten | | Redux Toolkit | Te veel boilerplate (actions, reducers, slices, selectors, provider) voor deze schaal; Zustand doet hetzelfde met een kwart van de code | | Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | | React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | | Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | | WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | | Redis | Geen caching- of queuerequirements op deze schaal | | Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | | Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | | tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | --- ## 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) | | | 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 | | | implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | | priority | Int | 1–4, not null | | | sort_order | Float | not null | | | status | Enum | TO_DO \| IN_PROGRESS \| DONE | | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | **Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` --- ### `todos` | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | | user_id | String | FK → users, not null | | | product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | | title | String | not null | | | done | Boolean | default false | | | archived | Boolean | default false | | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | **Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product --- ### `product_members` | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | | product_id | String | FK → products (cascade delete) | | | user_id | String | FK → users (cascade delete) | | | created_at | DateTime | default now() | | **Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is **Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. --- ## Toegangsmodel en schrijfbeveiliging Producttoegang is centraal gedefinieerd als: - eigenaar: `products.user_id === gebruiker.id` - teamlid: `product_members` bevat `(product_id, user_id)` Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. Schrijfoperaties volgen deze invarianten: - Controleer eerst authenticatie en `session.isDemo`. - Valideer input met Zod, maar behandel TypeScript types niet als runtime-beveiliging. - Controleer de parent-resource met `productAccessFilter`. - Vertrouw bulk-ID's nooit los: haal de records eerst op met `id in (...)` plus de parent-scope (`product_id`, `pbi_id`, `sprint_id` of `story_id`) en weiger de operatie als aantallen niet exact overeenkomen. - Weiger dubbele IDs in reorder- en beslissingslijsten. - Leid denormalized foreign keys af van de database-parent (`pbi.product_id`, `sprint.product_id`) en niet van form-data of JSON body. - Delete of update alleen nadat de resource scoped is gevonden; gebruik scoped `deleteMany`/`updateMany` wanneer een unique `delete` anders onveilig zou zijn. --- ## Prisma Schema (excerpt) ```prisma // prisma/schema.prisma generator client { provider = "prisma-client-js" } // Database wordt bepaald via prisma.config.ts — niet hier enum Role { PRODUCT_OWNER SCRUM_MASTER DEVELOPER } enum StoryStatus { OPEN IN_SPRINT DONE } enum 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) 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 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? 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") } ``` --- ## Authenticatieflow ``` Registratie: POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB → iron-session cookie zetten → redirect /dashboard Inloggen: POST /login → gebruiker ophalen op username → bcrypt vergelijken → bij match: iron-session cookie zetten → redirect /dashboard → bij mismatch: generieke foutmelding (geen onderscheid) Sessie per request: proxy.ts → sessiecookie-aanwezigheid controleren → beschermde routes: redirect /login als geen sessiecookie aanwezig is → app layout valideert de volledige sessie server-side API-aanroepen (Claude Code): Authorization: Bearer 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 # Profiel, account, PB-overzicht, rollen, tokens │ │ └── tokens/page.tsx │ ├── api/ # REST API voor Claude Code │ │ ├── products/ │ │ │ └── [id]/ │ │ │ └── next-story/route.ts │ │ ├── profile/ │ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto │ │ ├── sprints/ │ │ │ └── [id]/ │ │ │ └── tasks/route.ts │ │ ├── stories/ │ │ │ └── [id]/ │ │ │ ├── log/route.ts │ │ │ └── tasks/reorder/route.ts │ │ ├── tasks/ │ │ │ └── [id]/route.ts │ │ └── todos/route.ts ├── components/ │ ├── ui/ # shadcn/ui primitieven │ ├── split-pane/ # Gesplitst scherm component │ ├── backlog/ # PBI- en story-componenten │ ├── sprint/ # Sprint-componenten │ ├── products/ # ProductForm, TeamManager, ArchiveProductButton │ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton │ └── dnd/ # dnd-kit wrappers ├── lib/ │ ├── prisma.ts # Prisma Client singleton │ ├── session.ts # iron-session configuratie │ ├── auth.ts # login/register/token helpers │ ├── api-auth.ts # Bearer token middleware voor API │ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) │ └── env.ts # Zod-gevalideerde env vars ├── stores/ # Zustand stores │ ├── 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 ├── proxy.ts # Next.js 16 proxy voor route protection ├── prisma.config.ts # Prisma v7 config (DATABASE_URL) └── .env.example ``` --- ## Sleutelarchitectuurbeslissingen ### Beslissing: iron-session in plaats van Auth.js / Supabase Auth **Keuze:** iron-session voor versleutelde server-side sessiecookies **Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. **Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. ### Beslissing: Route Handlers naast Server Actions **Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API **Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. **Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. ### Beslissing: Float voor sort_order **Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken **Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). **Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. ### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` **Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` **Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. **Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. ### Beslissing: Zustand voor client-side state management **Keuze:** 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 // productId → pbi-ids storyOrder: Record // pbiId → story-ids taskOrder: Record // 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 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 // 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 → Connection string (pooled) | | `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" # Sessie SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output" # Optioneel NODE_ENV="development" ``` --- ## Deployment **Hosting:** Vercel (Hobby — gratis voor v1) **CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` **Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap **Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema **Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/erd.svg` **Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document ### Deployment checklist (pre-launch) - [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings) - [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens) - [ ] `prisma migrate deploy` uitgevoerd op productiedatabase - [ ] Demo-gebruiker aangemaakt via seed of handmatig - [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products` - [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek - [ ] Vercel preview-deployments getest op een PR - [ ] `next build` lokaal geslaagd zonder TypeScript-fouten --- ## Kostenscattting | Service | Plan | Maandelijkse kosten | |---|---|---| | Vercel | Hobby | Gratis | | Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis | | GitHub | Free | Gratis | | Domein | Eigen domein (optioneel) | ~€1–2/maand | | **Totaal** | | **€0–2/maand** | > Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog.