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>
This commit is contained in:
Janpeter Visser 2026-05-06 16:29:37 +02:00
parent 3eaaacaeb8
commit e7f1c64522
5 changed files with 688 additions and 854 deletions

View file

@ -1,232 +1,29 @@
/**
* Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en
* enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data:
* 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde).
* Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction
* (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor
* backwards-compat met UI-componenten die in F4 worden vervangen.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstProduct,
mockFindFirstSprint,
mockFindManyTask,
mockTransaction,
mockExecuteRaw,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(),
mockFindManyTask: vi.fn(),
mockTransaction: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
}))
import { describe, it, expect, vi } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
claudeJob: { create: vi.fn() },
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
import {
previewEnqueueAllAction,
enqueueClaudeJobsBatchAction,
} from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const PRODUCT_ID = 'product-1'
const SPRINT_ID = 'sprint-1'
// --- Seed helpers ---
const makePbi1Task = (id: string, status = 'TO_DO') => ({
id,
title: `PBI-1 Taak ${id}`,
status,
story: {
id: 'story-pbi1',
title: 'Story van PBI 1',
code: 'ST-1',
pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 },
},
})
const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
id,
title: `PBI-2 Taak ${id}`,
status,
story: {
id: 'story-pbi2',
title: 'Story van PBI 2',
code: 'ST-2',
pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 },
},
})
const makeBatchTask = (id: string, hasActiveJob = false) => ({
id,
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})
// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2]
const SEED_ALL_TODO = [
makePbi1Task('pbi1-t1'),
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
]
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
})
// =============================================================
// previewEnqueueAllAction
// =============================================================
describe('previewEnqueueAllAction — 2 PBI scenario', () => {
it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => {
mockFindManyTask.mockResolvedValue(SEED_ALL_TODO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(4)
expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2'])
}
})
it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t1'),
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1', 'REVIEW'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(3)
expect(result.tasks[2].id).toBe('pbi2-t1')
}
})
it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t1', 'TO_DO'),
makePbi1Task('pbi1-t2', 'TO_DO'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t))
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
})
it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => {
// Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(3)
expect(result.tasks[0].id).toBe('pbi1-t2')
}
})
it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1', 'REVIEW'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' })
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
})
it('demo-user → error, findMany niet aangeroepen', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockFindManyTask).not.toHaveBeenCalled()
describe('previewEnqueueAllAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await previewEnqueueAllAction('prod-1')
expect(result).toMatchObject({ error: expect.stringContaining('vervangen') })
})
})
// =============================================================
// enqueueClaudeJobsBatchAction
// =============================================================
describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => {
mockFindManyTask.mockResolvedValue([
makeBatchTask('pbi1-t1'),
makeBatchTask('pbi2-t1'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => {
// Authorized tasks bevat maar 1 van de 2 gevraagde IDs
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task'])
expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => {
mockFindManyTask.mockResolvedValue([
makeBatchTask('pbi1-t1'),
makeBatchTask('pbi1-t2', true), // heeft actieve job → skip
makeBatchTask('pbi2-t1'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('demo-user → error, geen transaction', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockTransaction).not.toHaveBeenCalled()
describe('enqueueClaudeJobsBatchAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2'])
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})

View file

@ -1,47 +1,30 @@
/**
* Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft
* actief gebruikt voor het annuleren van losse jobs (bv. idea-jobs).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstTask,
mockFindManyTask,
mockFindFirstProduct,
mockFindFirstSprint,
mockFindFirstJob,
mockCreateJob,
mockUpdateJob,
mockExecuteRaw,
mockTransaction,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstTask: vi.fn(),
mockFindManyTask: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(),
mockFindFirstJob: vi.fn(),
mockCreateJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
mockTransaction: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
claudeJob: {
findFirst: mockFindFirstJob,
create: mockCreateJob,
update: mockUpdateJob,
},
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
@ -49,394 +32,75 @@ import {
enqueueClaudeJobAction,
enqueueAllTodoJobsAction,
cancelClaudeJobAction,
previewEnqueueAllAction,
enqueueClaudeJobsBatchAction,
} from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const TASK_ID = 'task-cuid-1'
const JOB_ID = 'job-cuid-1'
const PRODUCT_ID = 'product-cuid-1'
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
})
describe('enqueueClaudeJobAction', () => {
it('happy path: creates job with QUEUED status', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null)
mockCreateJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: JOB_ID })
expect(mockCreateJob).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
)
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('returns error when task not found', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(null)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Task niet gevonden' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('allows new enqueue after terminal (DONE) job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null) // no active job
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
describe('enqueueClaudeJobAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueClaudeJobAction('task-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})
describe('enqueueAllTodoJobsAction', () => {
it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'task-a' },
{ id: 'job-b', task_id: 'task-b' },
])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 2 })
expect(mockFindManyTask).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: 'TO_DO',
story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId },
}),
})
)
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('returns count=0 when product has no active sprint', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue(null)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 0 })
expect(mockFindManyTask).not.toHaveBeenCalled()
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 0 })
expect(mockTransaction).not.toHaveBeenCalled()
expect(mockExecuteRaw).not.toHaveBeenCalled()
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockTransaction).not.toHaveBeenCalled()
})
})
const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({
id,
title: `Task ${id}`,
status,
story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } },
})
describe('previewEnqueueAllAction', () => {
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns empty tasks when no active sprint', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue(null)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns all tasks with no blocker when only TO_DO tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO'),
makePbiTask('t2', 'TO_DO'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
})
it('detects REVIEW task as blocker at correct index', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO'),
makePbiTask('t2', 'TO_DO'),
makePbiTask('t3', 'REVIEW'),
makePbiTask('t4', 'TO_DO'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
if (!('error' in result)) expect(result.tasks).toHaveLength(3)
})
it('detects BLOCKED PBI as blocker at first task of that PBI', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO', 'BLOCKED'),
makePbiTask('t2', 'TO_DO', 'BLOCKED'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
})
it('queries without TO_DO filter to expose REVIEW tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([])
await previewEnqueueAllAction(PRODUCT_ID)
expect(mockFindManyTask).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.not.objectContaining({ status: 'TO_DO' }),
})
)
})
})
const makeBatchTask = (id: string, hasActiveJob = false) => ({
id,
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})
describe('enqueueClaudeJobsBatchAction', () => {
it('happy path: 3 taskIds → 3 jobs in input order', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makeBatchTask('t1'),
makeBatchTask('t2'),
makeBatchTask('t3'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-1', task_id: 't1' },
{ id: 'job-2', task_id: 't2' },
{ id: 'job-3', task_id: 't3' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
expect(result).toEqual({ success: true, count: 3 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(3)
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when task belongs to another user (IDOR)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
// Only 1 of 2 tasks authorized (other-user's task filtered out)
mockFindManyTask.mockResolvedValue([makeBatchTask('t1')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user'])
expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('skips tasks with active jobs (idempotent)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makeBatchTask('t1'),
makeBatchTask('t2', true), // has active job — skip
makeBatchTask('t3'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-1', task_id: 't1' },
{ id: 'job-3', task_id: 't3' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('returns count=0 for empty taskIds', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, [])
expect(result).toEqual({ success: true, count: 0 })
expect(mockFindFirstProduct).not.toHaveBeenCalled()
describe('enqueueAllTodoJobsAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueAllTodoJobsAction('prod-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})
describe('cancelClaudeJobAction', () => {
it('happy path: cancels QUEUED job', async () => {
it('cancelt een actieve job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
mockUpdateJob.mockResolvedValue({})
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'QUEUED',
task_id: 'task-1',
product_id: 'prod-1',
})
mockUpdateJob.mockResolvedValue(undefined)
const result = await cancelClaudeJobAction(JOB_ID)
const result = await cancelClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: JOB_ID },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
)
expect(mockUpdateJob).toHaveBeenCalledWith({
where: { id: 'job-1' },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
})
it('demo user is blocked', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
it('weigert demo-sessie', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
const result = await cancelClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('returns error when job not found (ownership check)', async () => {
it('retourneert error als job niet gevonden', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(null)
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Job niet gevonden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
const result = await cancelClaudeJobAction('nonexistent')
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
})
it('returns error when cancelling terminal (DONE) job', async () => {
it('weigert wanneer job niet meer actief is', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'DONE',
task_id: 'task-1',
product_id: 'prod-1',
})
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('returns error when cancelling FAILED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
const result = await cancelClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('actieve') })
})
})

View file

@ -0,0 +1,303 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn(),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findUnique: vi.fn(),
update: vi.fn(),
},
sprintRun: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
pbi: {
updateMany: vi.fn(),
},
task: {
updateMany: vi.fn(),
},
claudeQuestion: {
findMany: vi.fn(),
},
claudeJob: {
create: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { getIronSession } from 'iron-session'
import {
startSprintRunAction,
resumeSprintAction,
cancelSprintRunAction,
} from '@/actions/sprint-runs'
const mockSession = getIronSession as ReturnType<typeof vi.fn>
type Mocked = {
sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
sprintRun: {
findFirst: ReturnType<typeof vi.fn>
findUnique: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
pbi: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
claudeJob: {
create: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const mockPrisma = prisma as unknown as Mocked
const SPRINT_OK = {
id: 'sprint-1',
status: 'ACTIVE',
product_id: 'prod-1',
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
}
const STORY_OK = {
id: 'story-1',
pbi_id: 'pbi-1',
priority: 1,
sort_order: 1,
pbi: {
id: 'pbi-1',
code: 'PBI-1',
title: 'PBI',
status: 'READY',
priority: 1,
sort_order: 1,
},
tasks: [
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' },
{ id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' },
],
}
beforeEach(() => {
vi.clearAllMocks()
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.$transaction.mockImplementation(
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
)
})
describe('startSprintRunAction — happy path', () => {
it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' })
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 })
expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({
data: expect.objectContaining({
sprint_id: 'sprint-1',
started_by_id: 'user-1',
status: 'QUEUED',
pr_strategy: 'SPRINT',
}),
})
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2)
})
})
describe('startSprintRunAction — pre-flight blockers', () => {
it('blokkeert wanneer task geen implementation_plan heeft', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{
...STORY_OK,
tasks: [
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null },
],
},
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'task_no_plan',
id: 'task-1',
label: 'T-1: T1',
})
}
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
})
it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([
{ id: 'q-1', question: 'Welke route?' },
])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'open_question',
id: 'q-1',
label: 'Welke route?',
})
}
})
it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{ ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } },
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'pbi_blocked',
id: 'pbi-1',
label: 'PBI-1: PBI',
})
}
})
})
describe('startSprintRunAction — guards', () => {
it('weigert wanneer Sprint niet ACTIVE is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'COMPLETED' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
})
it('weigert wanneer er al een actieve SprintRun is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' })
})
it('weigert demo-sessie', async () => {
mockSession.mockResolvedValue({ userId: 'demo', isDemo: true })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, code: 403 })
})
})
describe('resumeSprintAction', () => {
it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => {
// Eerste findUnique (resume) ziet de sprint nog op FAILED;
// de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE.
mockPrisma.sprint.findUnique
.mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' })
.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => {
if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }]
return [STORY_OK]
})
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' })
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { status: 'ACTIVE', completed_at: null },
})
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
where: { sprint_id: 'sprint-1', status: 'FAILED' },
data: { status: 'IN_SPRINT' },
})
expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({
where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' },
data: { status: 'TO_DO' },
})
})
it('weigert als sprint niet FAILED is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'ACTIVE' })
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
})
})
describe('cancelSprintRunAction', () => {
it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => {
mockPrisma.sprintRun.findUnique.mockResolvedValue({
id: 'run-1',
status: 'RUNNING',
sprint_id: 'sprint-1',
})
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
expect(result).toEqual({ ok: true })
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({
where: { id: 'run-1' },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
sprint_run_id: 'run-1',
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
}),
data: expect.objectContaining({ status: 'CANCELLED' }),
}))
})
it('weigert wanneer SprintRun al DONE is', async () => {
mockPrisma.sprintRun.findUnique.mockResolvedValue({
id: 'run-1',
status: 'DONE',
sprint_id: 'sprint-1',
})
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' })
})
})

View file

@ -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> {

294
actions/sprint-runs.ts Normal file
View 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
}