- 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>
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
export interface PropagationResult {
|
|
task: {
|
|
id: string
|
|
title: string
|
|
status: TaskStatus
|
|
story_id: string
|
|
implementation_plan: string | null
|
|
}
|
|
storyId: string
|
|
storyChanged: boolean
|
|
pbiChanged: boolean
|
|
sprintChanged: boolean
|
|
sprintRunChanged: boolean
|
|
}
|
|
|
|
// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten
|
|
// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie.
|
|
//
|
|
// Regels:
|
|
// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE,
|
|
// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN
|
|
// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY
|
|
// (BLOCKED is handmatig en wordt niet overschreven door deze helper)
|
|
// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED,
|
|
// ELSE ALL PBIs van die stories DONE → COMPLETED,
|
|
// ELSE ACTIVE
|
|
// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk +
|
|
// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders
|
|
// blijft SprintRun ongewijzigd.
|
|
export async function propagateStatusUpwards(
|
|
taskId: string,
|
|
newStatus: TaskStatus,
|
|
client?: Prisma.TransactionClient,
|
|
): Promise<PropagationResult> {
|
|
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
|
|
const task = await tx.task.update({
|
|
where: { id: taskId },
|
|
data: { status: newStatus },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
status: true,
|
|
story_id: true,
|
|
implementation_plan: true,
|
|
},
|
|
})
|
|
|
|
// Story herevalueren
|
|
const siblings = await tx.task.findMany({
|
|
where: { story_id: task.story_id },
|
|
select: { status: true },
|
|
})
|
|
const anyTaskFailed = siblings.some((s) => s.status === 'FAILED')
|
|
const allTasksDone =
|
|
siblings.length > 0 && siblings.every((s) => s.status === 'DONE')
|
|
|
|
const story = await tx.story.findUniqueOrThrow({
|
|
where: { id: task.story_id },
|
|
select: { id: true, status: true, pbi_id: true, sprint_id: true },
|
|
})
|
|
|
|
const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN'
|
|
let nextStoryStatus: StoryStatus
|
|
if (anyTaskFailed) nextStoryStatus = 'FAILED'
|
|
else if (allTasksDone) nextStoryStatus = 'DONE'
|
|
else nextStoryStatus = defaultActive
|
|
|
|
let storyChanged = false
|
|
if (nextStoryStatus !== story.status) {
|
|
await tx.story.update({
|
|
where: { id: story.id },
|
|
data: { status: nextStoryStatus },
|
|
})
|
|
storyChanged = true
|
|
}
|
|
|
|
// PBI herevalueren — BLOCKED met rust laten
|
|
const pbi = await tx.pbi.findUniqueOrThrow({
|
|
where: { id: story.pbi_id },
|
|
select: { id: true, status: true },
|
|
})
|
|
|
|
let pbiChanged = false
|
|
if (pbi.status !== 'BLOCKED') {
|
|
const pbiStories = await tx.story.findMany({
|
|
where: { pbi_id: pbi.id },
|
|
select: { status: true },
|
|
})
|
|
const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED')
|
|
const allStoriesDone =
|
|
pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE')
|
|
|
|
let nextPbiStatus: PbiStatus
|
|
if (anyStoryFailed) nextPbiStatus = 'FAILED'
|
|
else if (allStoriesDone) nextPbiStatus = 'DONE'
|
|
else nextPbiStatus = 'READY'
|
|
|
|
if (nextPbiStatus !== pbi.status) {
|
|
await tx.pbi.update({
|
|
where: { id: pbi.id },
|
|
data: { status: nextPbiStatus },
|
|
})
|
|
pbiChanged = true
|
|
}
|
|
}
|
|
|
|
// Sprint herevalueren — alleen als deze story aan een sprint hangt
|
|
let sprintChanged = false
|
|
let nextSprintStatus: SprintStatus | null = null
|
|
if (story.sprint_id) {
|
|
const sprint = await tx.sprint.findUniqueOrThrow({
|
|
where: { id: story.sprint_id },
|
|
select: { id: true, status: true },
|
|
})
|
|
|
|
const sprintPbiRows = await tx.story.findMany({
|
|
where: { sprint_id: sprint.id },
|
|
select: { pbi_id: true },
|
|
distinct: ['pbi_id'],
|
|
})
|
|
const sprintPbis = await tx.pbi.findMany({
|
|
where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } },
|
|
select: { status: true },
|
|
})
|
|
const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED')
|
|
const allPbisDone =
|
|
sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE')
|
|
|
|
let nextStatus: SprintStatus
|
|
if (anyPbiFailed) nextStatus = 'FAILED'
|
|
else if (allPbisDone) nextStatus = 'CLOSED'
|
|
else nextStatus = 'OPEN'
|
|
|
|
if (nextStatus !== sprint.status) {
|
|
await tx.sprint.update({
|
|
where: { id: sprint.id },
|
|
data: {
|
|
status: nextStatus,
|
|
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
|
|
},
|
|
})
|
|
sprintChanged = true
|
|
nextSprintStatus = nextStatus
|
|
}
|
|
}
|
|
|
|
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
|
|
let sprintRunChanged = false
|
|
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
|
|
const job = await tx.claudeJob.findFirst({
|
|
where: { task_id: taskId, sprint_run_id: { not: null } },
|
|
orderBy: { created_at: 'desc' },
|
|
select: { id: true, sprint_run_id: true },
|
|
})
|
|
|
|
if (job?.sprint_run_id) {
|
|
const sprintRun = await tx.sprintRun.findUnique({
|
|
where: { id: job.sprint_run_id },
|
|
select: { id: true, status: true },
|
|
})
|
|
if (
|
|
sprintRun &&
|
|
(sprintRun.status === 'QUEUED' ||
|
|
sprintRun.status === 'RUNNING' ||
|
|
sprintRun.status === 'PAUSED')
|
|
) {
|
|
if (nextSprintStatus === 'FAILED') {
|
|
await tx.sprintRun.update({
|
|
where: { id: sprintRun.id },
|
|
data: {
|
|
status: 'FAILED',
|
|
finished_at: new Date(),
|
|
failed_task_id: taskId,
|
|
},
|
|
})
|
|
await tx.claudeJob.updateMany({
|
|
where: {
|
|
sprint_run_id: sprintRun.id,
|
|
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
|
id: { not: job.id },
|
|
},
|
|
data: {
|
|
status: 'CANCELLED',
|
|
finished_at: new Date(),
|
|
error: `Cancelled: task ${taskId} failed in same sprint run`,
|
|
},
|
|
})
|
|
sprintRunChanged = true
|
|
} else {
|
|
// COMPLETED
|
|
await tx.sprintRun.update({
|
|
where: { id: sprintRun.id },
|
|
data: { status: 'DONE', finished_at: new Date() },
|
|
})
|
|
sprintRunChanged = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
task,
|
|
storyId: task.story_id,
|
|
storyChanged,
|
|
pbiChanged,
|
|
sprintChanged,
|
|
sprintRunChanged,
|
|
}
|
|
}
|
|
|
|
if (client) return run(client)
|
|
return prisma.$transaction(run)
|
|
}
|