From e7f1c6452208ed9a8c1392fa3facb0f9c4f511fc Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 16:29:37 +0200 Subject: [PATCH] ST-1244: F2 sprint-runs actions + deprecate per-task enqueues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/actions/claude-jobs-batch.test.ts | 239 +---------- __tests__/actions/claude-jobs.test.ts | 420 ++------------------ __tests__/actions/sprint-runs.test.ts | 303 ++++++++++++++ actions/claude-jobs.ts | 286 ++----------- actions/sprint-runs.ts | 294 ++++++++++++++ 5 files changed, 688 insertions(+), 854 deletions(-) create mode 100644 __tests__/actions/sprint-runs.test.ts create mode 100644 actions/sprint-runs.ts diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts index fc4e5e7..50c9be0 100644 --- a/__tests__/actions/claude-jobs-batch.test.ts +++ b/__tests__/actions/claude-jobs-batch.test.ts @@ -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') }) }) }) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 120124c..5e95878 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -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') }) }) }) diff --git a/__tests__/actions/sprint-runs.test.ts b/__tests__/actions/sprint-runs.test.ts new file mode 100644 index 0000000..8b39061 --- /dev/null +++ b/__tests__/actions/sprint-runs.test.ts @@ -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 + +type Mocked = { + sprint: { findUnique: ReturnType; update: ReturnType } + sprintRun: { + findFirst: ReturnType + findUnique: ReturnType + create: ReturnType + update: ReturnType + } + story: { + findMany: ReturnType + updateMany: ReturnType + } + pbi: { updateMany: ReturnType } + task: { updateMany: ReturnType } + claudeQuestion: { findMany: ReturnType } + claudeJob: { + create: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType +} +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) => 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' }) + }) +}) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index f39c8ff..75b15fa 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts new file mode 100644 index 0000000..31bb51d --- /dev/null +++ b/actions/sprint-runs.ts @@ -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(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 { + 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() + 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 { + 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 { + 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 { + 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 +}