Scrum4Me/CLAUDE.md
janpeter visser 7f94bb6359 feat: ST-001–ST-005 foundation — scaffolding, Prisma, schema, seed, env
- ST-001: Next.js 16 + React 19 + TypeScript strict + Tailwind + shadcn/ui + all deps
- ST-002: Prisma v7 setup with better-sqlite3 adapter (local) and pg adapter (cloud)
- ST-003: Full schema migration (users, pbis, stories, sprints, tasks, todos, api_tokens)
- ST-004: Seed with 9 PBIs, ~40 stories, demo user (demo/demo1234), lars user
- ST-005: Zod-validated env vars, .env.example, lib/session, lib/auth, lib/api-auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:04:48 +02:00

588 lines
16 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.

# CLAUDE.md — Scrum4Me
Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt.
---
## Wat is Scrum4Me?
Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API zodat implementatieplannen, testresultaten en commits automatisch vastgelegd worden in stories.
---
## Specificatiedocumenten
Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements.
| Document | Gebruik voor |
|---|---|
| `scrum4me-functional-spec.md` | Acceptatiecriteria, randgevallen, user flows per feature |
| `scrum4me-architecture.md` | Stack, datamodel, Prisma schema, Zustand stores, projectstructuur |
| `scrum4me-backlog.md` | Welke task bouwen, in welke volgorde, "done when"-criteria |
| `scrum4me-personas.md` | Lars (primaire gebruiker), Dina, Remi — gebruik bij UI-beslissingen |
| `scrum4me-product-backlog.md` | Testdata voor de seed — PBI's en stories van Scrum4Me zelf |
---
## Waar te beginnen
Volg de backlog strikt op volgorde. Start bij **ST-001**. Sla geen milestone over.
```
M0 (ST-001008) → M1 (ST-101110) → M2 (ST-201210)
→ M3 (ST-301312) → M4 (ST-401410) → M5 (ST-501506)
→ M6 (ST-601612)
```
Per task:
1. Lees de task in `scrum4me-backlog.md`
2. Zoek de bijbehorende feature-spec op in `scrum4me-functional-spec.md`
3. Bouw — test — verifieer de "Done when"-criteria
4. Commit met de task-ID in het commit-bericht: `feat: ST-001 project scaffolding`
---
## Tech stack (samenvatting)
```
Next.js 15 (App Router) + React 19
TypeScript strict
Tailwind CSS + shadcn/ui
Zustand (client state)
dnd-kit (drag-and-drop)
Prisma v7 (ORM)
PostgreSQL via Neon (cloud) | SQLite (lokaal)
iron-session (auth cookies)
bcrypt (wachtwoord hashing)
Zod (validatie)
Sonner (toasts)
```
---
## Exacte dependencies (package.json)
```json
{
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.0.0",
"tailwindcss": "^3.4.0",
"zustand": "^5.0.0",
"@dnd-kit/core": "^6.3.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.0",
"prisma": "^7.0.0",
"@prisma/client": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0",
"iron-session": "^8.0.0",
"bcryptjs": "^2.4.3",
"zod": "^3.22.0",
"sonner": "^1.5.0",
"pg": "^8.11.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.11.0",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^8.0.0",
"eslint-config-next": "^15.0.0"
}
}
```
---
## shadcn/ui componenten om te installeren
Voer deze uit na `npx shadcn@latest init`:
```bash
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add textarea
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add badge
npx shadcn@latest add tooltip
npx shadcn@latest add separator
npx shadcn@latest add sheet # voor story slide-over
npx shadcn@latest add select
npx shadcn@latest add alert-dialog # voor bevestigingsdialogen
npx shadcn@latest add skeleton
npx shadcn@latest add toast
```
---
## Projectstructuur
```
scrum4me/
├── app/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── (app)/
│ │ ├── layout.tsx # Auth-check + navigatie
│ │ ├── dashboard/page.tsx
│ │ ├── products/
│ │ │ ├── new/page.tsx
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Product Backlog
│ │ │ └── sprint/
│ │ │ ├── page.tsx # Sprint Backlog
│ │ │ └── planning/page.tsx
│ │ ├── todos/page.tsx
│ │ └── settings/
│ │ ├── page.tsx
│ │ └── tokens/page.tsx
│ └── api/
│ ├── 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 (auto-gegenereerd)
│ ├── split-pane/
│ │ └── split-pane.tsx
│ ├── backlog/
│ │ ├── pbi-list.tsx
│ │ ├── pbi-item.tsx
│ │ ├── story-grid.tsx
│ │ └── story-block.tsx
│ ├── sprint/
│ │ ├── sprint-backlog.tsx
│ │ └── sprint-story-item.tsx
│ ├── planning/
│ │ ├── task-list.tsx
│ │ └── task-item.tsx
│ └── shared/
│ ├── panel-nav-bar.tsx
│ ├── confirm-dialog.tsx
│ └── story-log.tsx
├── stores/
│ ├── planner-store.ts
│ ├── selection-store.ts
│ └── sprint-store.ts
├── lib/
│ ├── prisma.ts
│ ├── session.ts
│ ├── auth.ts
│ ├── api-auth.ts
│ └── env.ts
├── actions/
│ ├── products.ts
│ ├── pbis.ts
│ ├── stories.ts
│ ├── sprints.ts
│ ├── tasks.ts
│ └── todos.ts
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seed.ts
├── middleware.ts
├── prisma.config.ts
└── .env.example
```
---
## Kritieke implementatiepatronen
### 1. iron-session configuratie
```ts
// lib/session.ts
import { SessionOptions } from 'iron-session'
export interface SessionData {
userId: string
isDemo: boolean
}
export const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: 'scrum4me-session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
},
}
```
```ts
// Gebruik in Server Action of Route Handler:
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { SessionData, sessionOptions } from '@/lib/session'
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
if (!session.userId) redirect('/login')
```
### 2. Prisma Client singleton
```ts
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
```
### 3. prisma.config.ts (Prisma v7 vereiste)
```ts
// prisma.config.ts
import 'dotenv/config'
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
})
```
### 4. API Bearer token authenticatie
```ts
// lib/api-auth.ts
import { createHash } from 'crypto'
import { prisma } from '@/lib/prisma'
export async function authenticateApiRequest(request: Request) {
const authHeader = request.headers.get('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return { error: 'Unauthorized', status: 401 }
}
const token = authHeader.slice(7)
const tokenHash = createHash('sha256').update(token).digest('hex')
const apiToken = await prisma.apiToken.findUnique({
where: { token_hash: tokenHash },
include: { user: true },
})
if (!apiToken || apiToken.revoked_at) {
return { error: 'Unauthorized', status: 401 }
}
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
}
// Gebruik in Route Handler:
export async function GET(request: Request) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
// auth.userId beschikbaar
}
```
### 5. Float sort_order — drag-and-drop volgorde
```ts
// Bereken nieuwe sort_order bij tussenvoeging:
function getSortOrder(before: number | null, after: number | null): number {
if (before === null && after === null) return 1.0
if (before === null) return after! / 2
if (after === null) return before + 1.0
return (before + after) / 2
}
// Herindexeer als precisie opraakt (< 0.001 verschil):
async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
const minGap = Math.min(...items.slice(1).map((item, i) =>
item.sort_order - items[i].sort_order
))
if (minGap < 0.001) {
// Herindexeer: 1.0, 2.0, 3.0, ...
await Promise.all(items.map((item, i) =>
prisma.pbi.update({ where: { id: item.id }, data: { sort_order: i + 1.0 } })
))
}
}
```
### 6. Zustand store patroon (optimistische update + rollback)
```ts
// Gebruik in dnd-kit onDragEnd:
const { pbiOrder, reorderPbis, rollbackPbis } = usePlannerStore()
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const prevOrder = [...pbiOrder[productId]]
const newOrder = arrayMove(prevOrder, oldIndex, newIndex)
// 1. Optimistisch updaten
reorderPbis(productId, newOrder)
// 2. Server Action aanroepen
const result = await reorderPbisAction(productId, newOrder)
// 3. Rollback bij fout
if (!result.success) {
rollbackPbis(productId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
}
```
### 7. Server Action patroon
```ts
// actions/pbis.ts
'use server'
import { revalidatePath } from 'next/cache'
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
const createPbiSchema = z.object({
productId: z.string().cuid(),
title: z.string().min(1).max(200),
priority: z.number().int().min(1).max(4),
})
export async function createPbi(formData: FormData) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = createPbiSchema.safeParse({
productId: formData.get('productId'),
title: formData.get('title'),
priority: Number(formData.get('priority')),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
// Valideer eigenaarschap
const product = await prisma.product.findFirst({
where: { id: parsed.data.productId, user_id: session.userId }
})
if (!product) return { error: 'Product niet gevonden' }
// Bepaal sort_order (onderaan de prioriteitsgroep)
const last = await prisma.pbi.findFirst({
where: { product_id: parsed.data.productId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
})
const sort_order = (last?.sort_order ?? 0) + 1.0
const pbi = await prisma.pbi.create({
data: { ...parsed.data, product_id: parsed.data.productId, sort_order },
})
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true, pbi }
}
```
### 8. Route Handler patroon (REST API)
```ts
// app/api/products/[id]/next-story/route.ts
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id } = await params
// Valideer eigenaarschap
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE', product: { user_id: auth.userId } },
})
if (!sprint) {
return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
}
const story = await prisma.story.findFirst({
where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: { tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] } },
})
if (!story) {
return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 })
}
return Response.json(story)
}
```
---
## Middleware patroon
```ts
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings']
const authRoutes = ['/login', '/register']
export async function middleware(request: NextRequest) {
const response = NextResponse.next()
const session = await getIronSession<SessionData>(request.cookies, sessionOptions)
const isProtected = protectedRoutes.some(r => request.nextUrl.pathname.startsWith(r))
const isAuthRoute = authRoutes.some(r => request.nextUrl.pathname.startsWith(r))
if (isProtected && !session.userId) {
return NextResponse.redirect(new URL('/login', request.url))
}
if (isAuthRoute && session.userId) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return response
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
```
---
## Scrum-terminologie (gebruik consistent)
| Correct | Niet gebruiken |
|---|---|
| Product Backlog Item (PBI) | Feature, Epic, Issue |
| Story | User Story, Ticket |
| Sprint Goal | Sprint Objective |
| Sprint Planning | Sprint Meeting |
| Scrum Team | Team |
| Definition of Done | DoD criteria |
---
## Env vars
```bash
# .env.local
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"
SESSION_SECRET="genereer-met-openssl-rand-base64-32"
# Lokaal (SQLite):
# DATABASE_URL="file:./dev.db"
# DIRECT_URL niet nodig bij SQLite
```
---
## Lokale setup (quickstart)
```bash
git clone <repo>
cd scrum4me
npm install
cp .env.example .env.local
# Vul SESSION_SECRET in .env.local
# SQLite lokaal:
npx prisma db push
npx prisma db seed
npm run dev
```
---
## Demo-gebruiker credentials
Na seeding:
- **Gebruikersnaam:** `demo`
- **Wachtwoord:** `demo1234`
De demo-gebruiker heeft read-only rechten. Alle schrijfacties geven een 403 of zijn uitgeschakeld in de UI.
---
## REST API — alle endpoints
| Methode | Endpoint | Doel |
|---|---|---|
| GET | `/api/products` | Actieve producten ophalen |
| GET | `/api/products/:id/next-story` | Hoogst geprioriteerde open story |
| GET | `/api/sprints/:id/tasks?limit=10` | Eerste N taken van de Sprint |
| PATCH | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen |
| POST | `/api/stories/:id/log` | Plan / testresultaat / commit vastleggen |
| PATCH | `/api/tasks/:id` | Taakstatus bijwerken |
| POST | `/api/todos` | Todo aanmaken |
Alle endpoints vereisen: `Authorization: Bearer <token>`
### POST /api/stories/:id/log — body schema
```json
// Implementatieplan:
{ "type": "IMPLEMENTATION_PLAN", "content": "string" }
// Testresultaat:
{ "type": "TEST_RESULT", "content": "string", "status": "PASSED" | "FAILED" }
// Commit:
{ "type": "COMMIT", "content": "string", "commit_hash": "string", "commit_message": "string" }
```
---
## Conventies
- **Commit-berichten:** `feat: ST-XXX beschrijving` / `fix: ST-XXX beschrijving`
- **Branch-namen:** `feat/ST-001-scaffolding`
- **Server Actions:** altijd in `actions/[domein].ts`, nooit inline in page.tsx
- **Validatie:** altijd Zod, nooit handmatige checks
- **Eigenaarschap:** elke Server Action en Route Handler controleert dat de resource bij de geverifieerde gebruiker hoort
- **Demo-check:** elke Server Action controleert `session.isDemo` vóór schrijven
- **Foutberichten:** altijd in het Nederlands voor eindgebruikers
- **Comments in code:** Engels
---
## Definitie of Done (project)
De MVP is klaar wanneer:
- [ ] Alle 62 tasks (ST-001 t/m ST-612) zijn afgerond
- [ ] Volledige Lars-flow doorlopen zonder fouten (ST-612)
- [ ] Alle 7 API-endpoints werken via curl
- [ ] Demo-gebruiker heeft geen schrijfrechten
- [ ] App lokaal opzetbaar via README zonder extra hulp
- [ ] CI/CD actief — falende build blokkeert merge
- [ ] Beveiligingsreview API geslaagd (cross-user toegang onmogelijk)