- 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>
588 lines
16 KiB
Markdown
588 lines
16 KiB
Markdown
# 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-001–008) → M1 (ST-101–110) → M2 (ST-201–210)
|
||
→ M3 (ST-301–312) → M4 (ST-401–410) → M5 (ST-501–506)
|
||
→ M6 (ST-601–612)
|
||
```
|
||
|
||
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)
|