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:
parent
d68aa1e5e6
commit
4a9db57e94
43 changed files with 966 additions and 290 deletions
42
actions/active-sprint.ts
Normal file
42
actions/active-sprint.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue