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>
This commit is contained in:
Janpeter Visser 2026-04-22 21:04:48 +02:00
parent 4cf5833c1d
commit 7f94bb6359
32 changed files with 8653 additions and 183 deletions

589
CLAUDE.md
View file

@ -1 +1,588 @@
@AGENTS.md
# 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)