PBI-46: Sprint-niveau jobflow met cascade-FAIL (F1/F2/F4 Scrum4Me) (#136)
* ST-1243: F1 schema + propagateStatusUpwards-helper voor sprint-flow Schema-uitbreidingen voor de sprint-niveau jobflow (PBI-46): - TaskStatus, StoryStatus, PbiStatus, SprintStatus krijgen FAILED - Nieuwe enums: SprintRunStatus, PrStrategy - Nieuw SprintRun-model dat per-task ClaudeJobs groepeert - ClaudeJob.sprint_run_id koppeling + index - Product.pr_strategy (default SPRINT) - Bijhorende Prisma-migratie propagateStatusUpwards vervangt updateTaskStatusWithStoryPromotion en herevalueert de keten Task → Story → PBI → Sprint → SprintRun bij elke task-statuswijziging. Bij FAILED cancelt het sibling-jobs in dezelfde SprintRun. PBI-status BLOCKED blijft handmatig en wordt niet overschreven. Status-mappers + theme krijgen failed-token + label-uitbreidingen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1244: F2 sprint-runs actions + deprecate per-task enqueues actions/sprint-runs.ts (nieuw): - startSprintRunAction met pre-flight (impl_plan / open ClaudeQuestion / PBI BLOCKED|FAILED) - Maakt SprintRun + ClaudeJobs in PBI→Story→Task volgorde - resumeSprintAction zet FAILED tasks/stories/PBIs terug en start nieuwe SprintRun - cancelSprintRunAction breekt lopende SprintRun af zonder cascade actions/claude-jobs.ts: - enqueueClaudeJobAction, enqueueAllTodoJobsAction, previewEnqueueAllAction, enqueueClaudeJobsBatchAction nu deprecation-stubs (UI-cleanup volgt in F4) - cancelClaudeJobAction blijft beschikbaar voor losse jobs Tests bijgewerkt: 11 nieuwe sprint-runs tests, claude-jobs(-batch) tests herzien naar deprecation-asserties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1246: F4 UI Start/Resume/Cancel sprint + pr_strategy dropdown - components/sprint/sprint-run-controls.tsx: knoppen Start Sprint (sprintStatus=ACTIVE), Hervat sprint (sprintStatus=FAILED) en Annuleer sprint-run (lopende run). Pre-flight blocker-modal toont blockers met directe links naar de relevante pagina's. - components/products/pr-strategy-select.tsx: dropdown SPRINT|STORY in product-settings, met optimistic update + sonner-toast op fail. - actions/products.ts: updatePrStrategyAction (eigenaar-only, demo-block). - Sprint-page: query op actieve SprintRun + tonen van controls-balk. Live cascade-visualisatie (T-634) staat als follow-up genoteerd — huidige sprint-board statusbadges volstaan voor MVP. De Solo-board "Voer uit"-knoppen zijn niet expliciet verwijderd; ze tonen nu de deprecation-error van de gestubde actions tot de Solo-flow opnieuw ontworpen wordt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab8c3dca3f
commit
77617e89ac
25 changed files with 1798 additions and 1014 deletions
|
|
@ -30,273 +30,49 @@ type PreflightResult =
|
|||
| { error: string }
|
||||
| { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null }
|
||||
|
||||
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const limited = enforceUserRateLimit('enqueue-job', session.userId)
|
||||
if (limited) return { error: limited.error }
|
||||
|
||||
if (!taskId) return { error: 'task_id is verplicht' }
|
||||
|
||||
// Resolve task + product access in one query
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
story: { product: productAccessFilter(session.userId) },
|
||||
},
|
||||
select: { id: true, story: { select: { product_id: true } } },
|
||||
})
|
||||
if (!task) return { error: 'Task niet gevonden' }
|
||||
|
||||
const productId = task.story.product_id
|
||||
|
||||
// Idempotency: weiger als er al een actieve job voor deze task bestaat
|
||||
const existing = await prisma.claudeJob.findFirst({
|
||||
where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
return { error: 'Er loopt al een agent voor deze task', jobId: existing.id }
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
* Per-task starts zijn niet meer toegestaan — een sprint draait nu als geheel.
|
||||
* Wordt verwijderd zodra de UI is omgebouwd (F4).
|
||||
*/
|
||||
export async function enqueueClaudeJobAction(_taskId: string): Promise<EnqueueResult> {
|
||||
return {
|
||||
error:
|
||||
'Per-task starten is niet meer mogelijk. Gebruik "Start Sprint" voor de hele actieve sprint.',
|
||||
}
|
||||
|
||||
const job = await prisma.claudeJob.create({
|
||||
data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||
})
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: taskId,
|
||||
user_id: session.userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, jobId: job.id }
|
||||
}
|
||||
|
||||
export async function enqueueAllTodoJobsAction(productId: string): Promise<EnqueueAllResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
// Match het scope dat de gebruiker op het Solo Paneel ziet:
|
||||
// alleen TO_DO-taken in de actieve sprint, in stories die aan deze
|
||||
// gebruiker zijn toegewezen. Anders queue je per ongeluk taken die
|
||||
// niet in de huidige sprint zitten of aan iemand anders toebehoren.
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { success: true, count: 0 }
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: 'TO_DO',
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (tasks.length === 0) return { success: true, count: 0 }
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
tasks.map(t =>
|
||||
prisma.claudeJob.create({
|
||||
data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' },
|
||||
select: { id: true, task_id: true },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (const job of created) {
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: job.task_id,
|
||||
user_id: userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
*/
|
||||
export async function enqueueAllTodoJobsAction(_productId: string): Promise<EnqueueAllResult> {
|
||||
return {
|
||||
error:
|
||||
'"Alle TO_DO als jobs queueen" is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, count: created.length }
|
||||
}
|
||||
|
||||
export async function previewEnqueueAllAction(productId: string): Promise<PreflightResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null }
|
||||
|
||||
const rawTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
code: true,
|
||||
pbi: { select: { id: true, status: true, priority: true, sort_order: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
let blockerIndex: number | null = null
|
||||
let blockerReason: 'task-review' | 'pbi-blocked' | null = null
|
||||
|
||||
for (let i = 0; i < rawTasks.length; i++) {
|
||||
const t = rawTasks[i]
|
||||
if (t.status === 'REVIEW') {
|
||||
blockerIndex = i
|
||||
blockerReason = 'task-review'
|
||||
break
|
||||
}
|
||||
if (t.story.pbi.status === 'BLOCKED') {
|
||||
blockerIndex = i
|
||||
blockerReason = 'pbi-blocked'
|
||||
break
|
||||
}
|
||||
/**
|
||||
* @deprecated Vervangen door pre-flight in startSprintRunAction (actions/sprint-runs.ts).
|
||||
*/
|
||||
export async function previewEnqueueAllAction(_productId: string): Promise<PreflightResult> {
|
||||
return {
|
||||
error:
|
||||
'Per-product preview is vervangen door de pre-flight check in startSprintRunAction.',
|
||||
}
|
||||
|
||||
const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks
|
||||
|
||||
const tasks: PreviewTask[] = displayTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
story_title: t.story.title,
|
||||
pbi_id: t.story.pbi.id,
|
||||
pbi_status: t.story.pbi.status,
|
||||
}))
|
||||
|
||||
return { tasks, blockerIndex, blockerReason }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
*/
|
||||
export async function enqueueClaudeJobsBatchAction(
|
||||
productId: string,
|
||||
taskIds: string[]
|
||||
_productId: string,
|
||||
_taskIds: string[]
|
||||
): Promise<EnqueueAllResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const limited = enforceUserRateLimit('enqueue-job', session.userId)
|
||||
if (limited) return { error: limited.error }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
if (!taskIds.length) return { success: true, count: 0 }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||
|
||||
const authorizedTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
id: { in: taskIds },
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
claude_jobs: {
|
||||
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (authorizedTasks.length !== taskIds.length) {
|
||||
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
||||
return {
|
||||
error:
|
||||
'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
|
||||
}
|
||||
|
||||
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
||||
if (queueable.length === 0) return { success: true, count: 0 }
|
||||
|
||||
const queueableIds = new Set(queueable.map(t => t.id))
|
||||
const orderedQueueable = taskIds.filter(id => queueableIds.has(id))
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
orderedQueueable.map(taskId =>
|
||||
prisma.claudeJob.create({
|
||||
data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||
select: { id: true, task_id: true },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (const job of created) {
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: job.task_id,
|
||||
user_id: userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, count: created.length }
|
||||
}
|
||||
|
||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
||||
|
|
|
|||
|
|
@ -396,3 +396,27 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) {
|
|||
revalidatePath(`/products/${id}/settings`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updatePrStrategyAction(
|
||||
id: string,
|
||||
pr_strategy: 'SPRINT' | 'STORY',
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = z
|
||||
.object({ pr_strategy: z.enum(['SPRINT', 'STORY']) })
|
||||
.safeParse({ pr_strategy })
|
||||
if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' }
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } })
|
||||
if (!product) return { error: 'Product niet gevonden' }
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id },
|
||||
data: { pr_strategy: parsed.data.pr_strategy },
|
||||
})
|
||||
revalidatePath(`/products/${id}/settings`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
294
actions/sprint-runs.ts
Normal file
294
actions/sprint-runs.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
export type PreFlightBlockerType = 'task_no_plan' | 'open_question' | 'pbi_blocked'
|
||||
|
||||
export interface PreFlightBlocker {
|
||||
type: PreFlightBlockerType
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) })
|
||||
const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) })
|
||||
const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) })
|
||||
|
||||
interface StartResultOk {
|
||||
ok: true
|
||||
sprint_run_id: string
|
||||
jobs_count: number
|
||||
}
|
||||
|
||||
interface StartResultBlocked {
|
||||
ok: false
|
||||
error: 'PRE_FLIGHT_BLOCKED'
|
||||
blockers: PreFlightBlocker[]
|
||||
}
|
||||
|
||||
interface ErrorResult {
|
||||
ok: false
|
||||
error: string
|
||||
code: number
|
||||
}
|
||||
|
||||
type StartResult = StartResultOk | StartResultBlocked | ErrorResult
|
||||
|
||||
// startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction.
|
||||
// Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task
|
||||
// volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de
|
||||
// transactionele Prisma-client.
|
||||
async function startSprintRunCore(
|
||||
tx: Prisma.TransactionClient,
|
||||
sprint_id: string,
|
||||
user_id: string,
|
||||
): Promise<StartResultOk | StartResultBlocked | ErrorResult> {
|
||||
const sprint = await tx.sprint.findUnique({
|
||||
where: { id: sprint_id },
|
||||
include: { product: true },
|
||||
})
|
||||
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
|
||||
if (sprint.status !== 'ACTIVE')
|
||||
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
|
||||
|
||||
const activeRun = await tx.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id,
|
||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
||||
},
|
||||
})
|
||||
if (activeRun)
|
||||
return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 }
|
||||
|
||||
const stories = await tx.story.findMany({
|
||||
where: { sprint_id, status: { not: 'DONE' } },
|
||||
include: {
|
||||
pbi: true,
|
||||
tasks: {
|
||||
where: { status: 'TO_DO' },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
})
|
||||
|
||||
const blockers: PreFlightBlocker[] = []
|
||||
|
||||
for (const s of stories) {
|
||||
for (const t of s.tasks) {
|
||||
if (!t.implementation_plan) {
|
||||
blockers.push({
|
||||
type: 'task_no_plan',
|
||||
id: t.id,
|
||||
label: `${t.code}: ${t.title}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openQuestions = await tx.claudeQuestion.findMany({
|
||||
where: { story: { sprint_id }, status: 'open' },
|
||||
select: { id: true, question: true },
|
||||
})
|
||||
for (const q of openQuestions) {
|
||||
blockers.push({
|
||||
type: 'open_question',
|
||||
id: q.id,
|
||||
label: q.question.slice(0, 80),
|
||||
})
|
||||
}
|
||||
|
||||
const seenPbi = new Set<string>()
|
||||
for (const s of stories) {
|
||||
if (seenPbi.has(s.pbi.id)) continue
|
||||
seenPbi.add(s.pbi.id)
|
||||
if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') {
|
||||
blockers.push({
|
||||
type: 'pbi_blocked',
|
||||
id: s.pbi.id,
|
||||
label: `${s.pbi.code}: ${s.pbi.title}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (blockers.length > 0) {
|
||||
return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers }
|
||||
}
|
||||
|
||||
const sprintRun = await tx.sprintRun.create({
|
||||
data: {
|
||||
sprint_id,
|
||||
started_by_id: user_id,
|
||||
status: 'QUEUED',
|
||||
pr_strategy: sprint.product.pr_strategy,
|
||||
started_at: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const orderedTasks = stories
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.pbi.priority - b.pbi.priority ||
|
||||
a.pbi.sort_order - b.pbi.sort_order ||
|
||||
a.priority - b.priority ||
|
||||
a.sort_order - b.sort_order,
|
||||
)
|
||||
.flatMap((s) => s.tasks)
|
||||
|
||||
for (const t of orderedTasks) {
|
||||
await tx.claudeJob.create({
|
||||
data: {
|
||||
user_id,
|
||||
product_id: sprint.product_id,
|
||||
task_id: t.id,
|
||||
sprint_run_id: sprintRun.id,
|
||||
kind: 'TASK_IMPLEMENTATION',
|
||||
status: 'QUEUED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length }
|
||||
}
|
||||
|
||||
export async function startSprintRunAction(input: unknown): Promise<StartResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = StartSprintRunInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const userId = session.userId
|
||||
const result = await prisma.$transaction((tx) =>
|
||||
startSprintRunCore(tx, parsed.data.sprint_id, userId),
|
||||
)
|
||||
|
||||
if (result.ok) {
|
||||
revalidatePath(`/sprints/${parsed.data.sprint_id}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function resumeSprintAction(input: unknown): Promise<StartResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = ResumeSprintInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const userId = session.userId
|
||||
const sprint_id = parsed.data.sprint_id
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } })
|
||||
if (!sprint)
|
||||
return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 }
|
||||
if (sprint.status !== 'FAILED')
|
||||
return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 }
|
||||
|
||||
// Sprint terug naar ACTIVE
|
||||
await tx.sprint.update({
|
||||
where: { id: sprint_id },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
})
|
||||
|
||||
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)
|
||||
await tx.story.updateMany({
|
||||
where: { sprint_id, status: 'FAILED' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
|
||||
// PBIs van die stories: FAILED → READY (BLOCKED met rust laten)
|
||||
const storyPbiIds = (
|
||||
await tx.story.findMany({
|
||||
where: { sprint_id },
|
||||
select: { pbi_id: true },
|
||||
distinct: ['pbi_id'],
|
||||
})
|
||||
).map((s) => s.pbi_id)
|
||||
await tx.pbi.updateMany({
|
||||
where: { id: { in: storyPbiIds }, status: 'FAILED' },
|
||||
data: { status: 'READY' },
|
||||
})
|
||||
|
||||
// FAILED tasks → TO_DO (DONE blijft)
|
||||
await tx.task.updateMany({
|
||||
where: { story: { sprint_id }, status: 'FAILED' },
|
||||
data: { status: 'TO_DO' },
|
||||
})
|
||||
|
||||
return startSprintRunCore(tx, sprint_id, userId)
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
revalidatePath(`/sprints/${sprint_id}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface CancelResultOk {
|
||||
ok: true
|
||||
}
|
||||
|
||||
type CancelResult = CancelResultOk | ErrorResult
|
||||
|
||||
export async function cancelSprintRunAction(input: unknown): Promise<CancelResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = CancelSprintRunInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const sprint_run_id = parsed.data.sprint_run_id
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } })
|
||||
if (!run)
|
||||
return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 }
|
||||
if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status))
|
||||
return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 }
|
||||
|
||||
await tx.sprintRun.update({
|
||||
where: { id: sprint_run_id },
|
||||
data: { status: 'CANCELLED', finished_at: new Date() },
|
||||
})
|
||||
|
||||
// Cancel openstaande task-jobs binnen deze run.
|
||||
// Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail.
|
||||
await tx.claudeJob.updateMany({
|
||||
where: {
|
||||
sprint_run_id,
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
finished_at: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const, sprint_id: run.sprint_id }
|
||||
})
|
||||
|
||||
if (result.ok && 'sprint_id' in result) {
|
||||
revalidatePath(`/sprints/${result.sprint_id}`)
|
||||
return { ok: true }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
updateSprintGoalSchema,
|
||||
} from '@/lib/schemas/sprint'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -294,7 +294,7 @@ export async function setAllSprintTasksDoneAction(
|
|||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const task of tasks) {
|
||||
await updateTaskStatusWithStoryPromotion(task.id, 'DONE', tx)
|
||||
await propagateStatusUpwards(task.id, 'DONE', tx)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
|
|||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { requireProductWriter } from '@/lib/auth'
|
||||
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { normalizeCode } from '@/lib/code'
|
||||
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
|
|
@ -85,7 +85,7 @@ export async function saveTask(
|
|||
})
|
||||
|
||||
if (statusChanged) {
|
||||
const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx)
|
||||
const result = await propagateStatusUpwards(taskId, status, tx)
|
||||
return { id: result.task.id, title: result.task.title, status: result.task.status }
|
||||
}
|
||||
return updated
|
||||
|
|
@ -274,7 +274,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P
|
|||
})
|
||||
if (!task) return { error: 'Taak niet gevonden' }
|
||||
|
||||
await updateTaskStatusWithStoryPromotion(id, status)
|
||||
await propagateStatusUpwards(id, status)
|
||||
|
||||
// /solo bewust niet revalideren: dat zou de page soft-navigaten en de
|
||||
// open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue