feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher (#161)

- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden)
- TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter)
- Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts)
- Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page
- NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie
- Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction)
- Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite)
- Version bump 1.0.0 → 1.2.0

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-08 00:15:04 +02:00 committed by GitHub
parent d68aa1e5e6
commit 4a9db57e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 966 additions and 290 deletions

42
actions/active-sprint.ts Normal file
View file

@ -0,0 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import { setActiveSprintCookie } from '@/lib/active-sprint'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const setSchema = z.object({
productId: z.string().min(1),
sprintId: z.string().min(1),
})
export async function setActiveSprintAction(productId: string, sprintId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = setSchema.safeParse({ productId, sprintId })
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId)
revalidatePath('/', 'layout')
return { success: true, sprintId: parsed.data.sprintId }
}

View file

@ -63,7 +63,7 @@ async function startSprintRunCore(
include: { product: true },
})
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
if (sprint.status !== 'ACTIVE')
if (sprint.status !== 'OPEN')
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
const activeRun = await tx.sprintRun.findFirst({
@ -80,6 +80,9 @@ async function startSprintRunCore(
include: {
pbi: true,
tasks: {
// EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
where: { status: 'TO_DO' },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
@ -246,7 +249,7 @@ export async function resumeSprintAction(input: unknown): Promise<StartResult> {
// Sprint terug naar ACTIVE
await tx.sprint.update({
where: { id: sprint_id },
data: { status: 'ACTIVE', completed_at: null },
data: { status: 'OPEN', completed_at: null },
})
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)

View file

@ -14,6 +14,8 @@ import {
import { enforceUserRateLimit } from '@/lib/rate-limit'
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
import { setActiveSprintCookie } from '@/lib/active-sprint'
import { z } from 'zod'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -51,7 +53,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
if (!product) return { error: 'Product niet gevonden', code: 403 }
const existing = await prisma.sprint.findFirst({
where: { product_id: parsed.data.productId, status: 'ACTIVE' },
where: { product_id: parsed.data.productId, status: 'OPEN' },
})
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
@ -63,7 +65,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
product_id: parsed.data.productId,
code,
sprint_goal: parsed.data.sprint_goal,
status: 'ACTIVE',
status: 'OPEN',
start_date: parsed.data.start_date,
end_date: parsed.data.end_date,
},
@ -271,7 +273,7 @@ export async function completeSprintAction(
),
prisma.sprint.update({
where: { id: sprintId },
data: { status: 'COMPLETED', completed_at: new Date() },
data: { status: 'CLOSED', completed_at: new Date() },
}),
])
@ -307,3 +309,76 @@ export async function setAllSprintTasksDoneAction(
revalidatePath(`/products/${sprint.product_id}/sprint`)
return { ok: true }
}
const createSprintWithPbisSchema = z.object({
productId: z.string().min(1),
sprint_goal: z.string().min(1).max(2000),
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
pbi_ids: z.array(z.string().min(1)).min(1),
})
function parseDate(value: string | null | undefined): Date | null {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
export async function createSprintWithPbisAction(input: {
productId: string
sprint_goal: string
start_date?: string | null
end_date?: string | null
pbi_ids: string[]
}): Promise<{ success: true; sprintId: string } | { error: string; code: number }> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('create-sprint', session.userId)
if (limited) return { error: limited.error, code: limited.code }
const parsed = createSprintWithPbisSchema.safeParse(input)
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden', code: 403 }
const pbis = await prisma.pbi.findMany({
where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId },
select: { id: true },
})
if (pbis.length !== parsed.data.pbi_ids.length) {
return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 }
}
const sprint = await createWithCodeRetry(
() => generateNextSprintCode(parsed.data.productId),
(code) =>
prisma.$transaction(async (tx) => {
const created = await tx.sprint.create({
data: {
product_id: parsed.data.productId,
code,
sprint_goal: parsed.data.sprint_goal,
status: 'OPEN',
start_date: parseDate(parsed.data.start_date),
end_date: parseDate(parsed.data.end_date),
},
})
await tx.story.updateMany({
where: { pbi_id: { in: parsed.data.pbi_ids } },
data: { sprint_id: created.id, status: 'IN_SPRINT' },
})
await tx.task.updateMany({
where: { story: { pbi_id: { in: parsed.data.pbi_ids } } },
data: { sprint_id: created.id },
})
return created
}),
)
await setActiveSprintCookie(parsed.data.productId, sprint.id)
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
return { success: true, sprintId: sprint.id }
}

View file

@ -349,7 +349,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
const userId = session.userId
const sprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
where: { product_id: productId, status: 'OPEN' },
})
if (!sprint) return { error: 'Geen actieve sprint gevonden' }