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/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index 877aac5..bc3236f 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -23,6 +23,24 @@ vi.mock('@/lib/prisma', () => ({ story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -44,6 +62,24 @@ const mockPrisma = prisma as unknown as { story: { findFirst: ReturnType findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -154,7 +190,14 @@ describe('saveTask — edit met status-promotie', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) const result = await saveTask( { ...VALID_INPUT, status: 'DONE' }, diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 6266cda..ca60059 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -8,10 +8,13 @@ vi.mock('@/lib/prisma', () => ({ }, sprint: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), update: vi.fn(), }, task: { @@ -19,6 +22,19 @@ vi.mock('@/lib/prisma', () => ({ update: vi.fn(), findMany: vi.fn(), }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), + update: vi.fn(), + }, storyLog: { create: vi.fn(), }, @@ -44,10 +60,15 @@ import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } - sprint: { findFirst: ReturnType } + sprint: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType + findMany: ReturnType update: ReturnType } task: { @@ -55,6 +76,19 @@ const mockPrisma = prisma as unknown as { update: ReturnType findMany: ReturnType } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -409,7 +443,14 @@ describe('PATCH /api/tasks/:id', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index 3b08da7..5862615 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -9,6 +9,24 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -31,6 +49,24 @@ const mockPrisma = prisma as unknown as { } story: { findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -75,7 +111,14 @@ describe('PATCH /api/tasks/:id', () => { }) // Default sibling state: only this task, already DONE → no story-promotion mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { return run(prisma) @@ -190,7 +233,14 @@ describe('PATCH /api/tasks/:id', () => { story_id: 'story-1', }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) const res = await patchTask(...makeRequest({ status: 'done' })) expect(res.status).toBe(200) diff --git a/__tests__/lib/task-status.test.ts b/__tests__/lib/task-status.test.ts index 870b632..9f08a85 100644 --- a/__tests__/lib/task-status.test.ts +++ b/__tests__/lib/task-status.test.ts @@ -78,8 +78,8 @@ describe('task-status mappers', () => { expect(pbiStatusFromApi('todo')).toBeNull() }) - it('exposes exactly three API values', () => { - expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done']) + it('exposes alle vier API values', () => { + expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done']) }) }) }) diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index 418caa7..814ec6b 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -8,6 +8,23 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -15,27 +32,35 @@ vi.mock('@/lib/prisma', () => ({ })) import { prisma } from '@/lib/prisma' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' -const mockPrisma = prisma as unknown as { - task: { - update: ReturnType - findMany: ReturnType - } +type MockedPrisma = { + task: { update: ReturnType; findMany: ReturnType } story: { findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType } -beforeEach(() => { - vi.clearAllMocks() - // Pass-through: $transaction(run) just calls run with the mocked prisma client. - mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { - return run(prisma) - }) -}) +const mockPrisma = prisma as unknown as MockedPrisma const TASK_BASE = { id: 'task-1', @@ -44,110 +69,267 @@ const TASK_BASE = { implementation_plan: null, } -describe('updateTaskStatusWithStoryPromotion', () => { - it('promotes story to DONE when last sibling task transitions to DONE', async () => { +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.$transaction.mockImplementation( + async (run: (tx: typeof prisma) => Promise) => run(prisma), + ) +}) + +describe('propagateStatusUpwards — story-niveau', () => { + it('zet story op DONE wanneer alle siblings DONE zijn', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'DONE') - expect(result.storyStatusChange).toBe('promoted') - expect(result.storyId).toBe('story-1') + expect(result.storyChanged).toBe(true) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'DONE' }, }) }) - it('does not promote when story is already DONE (idempotent)', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'FAILED' }, + { status: 'DONE' }, + { status: 'TO_DO' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'FAILED') - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'FAILED' }, + }) }) - it('does not promote when not all siblings are DONE', async () => { + it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, - { status: 'IN_PROGRESS' }, + { status: 'TO_DO' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'DONE') - expect(result.storyStatusChange).toBe(null) + expect(result.storyChanged).toBe(false) expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' }) mockPrisma.task.findMany.mockResolvedValue([ - { status: 'IN_PROGRESS' }, + { status: 'TO_DO' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'COMPLETED' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + const result = await propagateStatusUpwards('task-1', 'TO_DO') - expect(result.storyStatusChange).toBe('demoted') + expect(result.storyChanged).toBe(true) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'IN_SPRINT' }, }) }) - it('does not demote when story is not DONE', async () => { + it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() - }) - - it('updates the task regardless of story-status change', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - - expect(mockPrisma.task.update).toHaveBeenCalledWith({ - where: { id: 'task-1' }, - data: { status: 'IN_PROGRESS' }, - select: expect.any(Object), + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, }) - }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) - it('uses the provided transaction client when passed', async () => { - const tx = { - task: { update: vi.fn(), findMany: vi.fn() }, - story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, - } - tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) - - expect(result.storyStatusChange).toBe('promoted') - // $transaction should NOT be called when caller already provides a tx. - expect(mockPrisma.$transaction).not.toHaveBeenCalled() - expect(tx.story.update).toHaveBeenCalledWith({ + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, - data: { status: 'DONE' }, + data: { status: 'OPEN' }, }) }) }) + +describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => { + it('overschrijft een handmatig BLOCKED PBI niet', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' }) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.pbiChanged).toBe(false) + expect(mockPrisma.pbi.update).not.toHaveBeenCalled() + }) +}) + +describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => { + it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'FAILED' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'FAILED' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' }) + // findMany on pbi: + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + + const result = await propagateStatusUpwards('task-1', 'FAILED') + + expect(result.storyChanged).toBe(true) + expect(result.pbiChanged).toBe(true) + expect(result.sprintChanged).toBe(true) + expect(result.sprintRunChanged).toBe(true) + + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }), + })) + expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + sprint_run_id: 'run-1', + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: 'job-1' }, + }), + data: expect.objectContaining({ status: 'CANCELLED' }), + })) + }) + + it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'DONE' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.sprintRunChanged).toBe(true) + expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'sprint-1' }, + data: expect.objectContaining({ status: 'COMPLETED' }), + })) + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'DONE' }), + })) + }) +}) + +describe('propagateStatusUpwards — transactionele aanroep', () => { + it('gebruikt de meegegeven transaction client', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + pbi: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + sprint: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + claudeJob: { findFirst: vi.fn(), updateMany: vi.fn() }, + sprintRun: { findUnique: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'OPEN', + pbi_id: 'pbi-1', + sprint_id: null, + }) + tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any) + + expect(result.storyChanged).toBe(false) + // $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) +}) 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/products.ts b/actions/products.ts index 9a0856b..d25ede0 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -396,3 +396,27 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) { revalidatePath(`/products/${id}/settings`) return { success: true } } + +export async function updatePrStrategyAction( + id: string, + pr_strategy: 'SPRINT' | 'STORY', +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = z + .object({ pr_strategy: z.enum(['SPRINT', 'STORY']) }) + .safeParse({ pr_strategy }) + if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' } + + const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ + where: { id }, + data: { pr_strategy: parsed.data.pr_strategy }, + }) + revalidatePath(`/products/${id}/settings`) + return { success: true } +} 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 +} diff --git a/actions/sprints.ts b/actions/sprints.ts index 4b6acbf..2784334 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -12,7 +12,7 @@ import { updateSprintGoalSchema, } from '@/lib/schemas/sprint' import { enforceUserRateLimit } from '@/lib/rate-limit' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -294,7 +294,7 @@ export async function setAllSprintTasksDoneAction( await prisma.$transaction(async (tx) => { for (const task of tasks) { - await updateTaskStatusWithStoryPromotion(task.id, 'DONE', tx) + await propagateStatusUpwards(task.id, 'DONE', tx) } }) diff --git a/actions/tasks.ts b/actions/tasks.ts index c0210a6..7b83e03 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -9,7 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' import { normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -85,7 +85,7 @@ export async function saveTask( }) if (statusChanged) { - const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) + const result = await propagateStatusUpwards(taskId, status, tx) return { id: result.task.id, title: result.task.title, status: result.task.status } } return updated @@ -274,7 +274,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await updateTaskStatusWithStoryPromotion(id, status) + await propagateStatusUpwards(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx index 6be4800..046a994 100644 --- a/app/(app)/products/[id]/settings/page.tsx +++ b/app/(app)/products/[id]/settings/page.tsx @@ -8,6 +8,7 @@ import { ArchiveProductButton } from '@/components/products/archive-product-butt import { TeamManager } from '@/components/products/team-manager' import { updateProductFormAction } from '@/actions/products' import { AutoPrToggle } from '@/components/products/auto-pr-toggle' +import { PrStrategySelect } from '@/components/products/pr-strategy-select' import Link from 'next/link' interface Props { @@ -66,6 +67,17 @@ export default async function ProductSettingsPage({ params }: Props) { +
+
+

PR-strategie

+

+ Bepaalt hoe de sprint zijn werk oplevert: één PR voor de hele sprint + of een PR per story die automatisch wordt gemerged na groene CI. +

+
+ +
+

Team

diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 3e3a36c..ddedcce 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' import { SprintBoardClient } from '@/components/sprint/sprint-board-client' import { SprintHeader } from '@/components/sprint/sprint-header' +import { SprintRunControls } from '@/components/sprint/sprint-run-controls' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' import type { Task } from '@/components/sprint/task-list' import { TaskDialog } from '@/app/_components/tasks/task-dialog' @@ -33,7 +34,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { if (!product) notFound() const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE' }, + where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } }, select: { id: true, sprint_goal: true, @@ -44,6 +45,14 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { }) if (!sprint) redirect(`/products/${id}`) + const activeSprintRun = await prisma.sprintRun.findFirst({ + where: { + sprint_id: sprint.id, + status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, + }, + select: { id: true, status: true }, + }) + // Sprint stories with full task data and assignee const [sprintStories, productMembers] = await Promise.all([ prisma.story.findMany({ @@ -147,6 +156,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { sprintStories={sprintStoryItems} /> +
+ +
+
= { IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' }, REVIEW: { label: 'Review', dot: 'bg-status-review' }, DONE: { label: 'Klaar', dot: 'bg-status-done' }, + FAILED: { label: 'Gefaald', dot: 'bg-status-failed' }, } +// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar. const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] function StatusIndicator({ status }: { status: TaskStatus }) { diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index b80a811..4bb2611 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,7 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -111,7 +111,7 @@ export async function PATCH( : null if (dbStatus !== undefined && dbStatus !== null) { - const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) + const result = await propagateStatusUpwards(id, dbStatus, tx) return { id: result.task.id, status: result.task.status, diff --git a/app/styles/theme.css b/app/styles/theme.css index 071598a..a01769b 100644 --- a/app/styles/theme.css +++ b/app/styles/theme.css @@ -85,6 +85,7 @@ --status-review: #7b5ea7; --status-done: #006e1c; --status-blocked: #ba1a1a; + --status-failed: #93000a; --priority-critical: #ba1a1a; --priority-high: #c75300; @@ -196,6 +197,7 @@ --status-review: #c9b6ef; --status-done: #77db77; --status-blocked: #ffb4ab; + --status-failed: #ff8a80; --priority-critical: #ffb4ab; --priority-high: #ffb68d; @@ -301,6 +303,7 @@ --color-status-review: var(--status-review); --color-status-done: var(--status-done); --color-status-blocked: var(--status-blocked); + --color-status-failed: var(--status-failed); --color-priority-critical: var(--priority-critical); --color-priority-high: var(--priority-high); diff --git a/components/products/pr-strategy-select.tsx b/components/products/pr-strategy-select.tsx new file mode 100644 index 0000000..47327d8 --- /dev/null +++ b/components/products/pr-strategy-select.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useState, useTransition } from 'react' +import { toast } from 'sonner' +import { updatePrStrategyAction } from '@/actions/products' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select' + +type PrStrategy = 'SPRINT' | 'STORY' + +interface PrStrategySelectProps { + productId: string + initialValue: PrStrategy +} + +const STRATEGY_LABELS: Record = { + SPRINT: 'Per sprint — één PR voor de hele sprint, klaar voor review aan eind', + STORY: 'Per story — auto-merge na CI groen, één PR per story', +} + +export function PrStrategySelect({ productId, initialValue }: PrStrategySelectProps) { + const [value, setValue] = useState(initialValue) + const [isPending, startTransition] = useTransition() + + function handleChange(next: string | null) { + if (next !== 'SPRINT' && next !== 'STORY') return + if (next === value) return + const previous = value + setValue(next) + startTransition(async () => { + const result = await updatePrStrategyAction(productId, next) + if ('error' in result && result.error) { + setValue(previous) + toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') + } + }) + } + + return ( +
+ +
+ ) +} diff --git a/components/shared/pbi-status-select.tsx b/components/shared/pbi-status-select.tsx index ae93522..9730ef2 100644 --- a/components/shared/pbi-status-select.tsx +++ b/components/shared/pbi-status-select.tsx @@ -7,12 +7,14 @@ import type { PbiStatusApi } from '@/lib/task-status' export const PBI_STATUS_LABELS: Record = { ready: 'Klaar voor sprint', blocked: 'Geblokkeerd', + failed: 'Gefaald', done: 'Afgerond', } export const PBI_STATUS_COLORS: Record = { ready: 'bg-status-todo/15 text-status-todo border-status-todo/30', blocked: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', + failed: 'bg-status-failed/15 text-status-failed border-status-failed/30', done: 'bg-status-done/15 text-status-done border-status-done/30', } diff --git a/components/sprint/sprint-run-controls.tsx b/components/sprint/sprint-run-controls.tsx new file mode 100644 index 0000000..0ea8f47 --- /dev/null +++ b/components/sprint/sprint-run-controls.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useState, useTransition } from 'react' +import { toast } from 'sonner' +import { + startSprintRunAction, + resumeSprintAction, + cancelSprintRunAction, + type PreFlightBlocker, +} from '@/actions/sprint-runs' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED' +type SprintRunStatusValue = + | 'QUEUED' + | 'RUNNING' + | 'PAUSED' + | 'DONE' + | 'FAILED' + | 'CANCELLED' + | null + +interface Props { + sprintId: string + productId: string + sprintStatus: SprintStatusValue + activeSprintRunId: string | null + activeSprintRunStatus: SprintRunStatusValue + isDemo: boolean +} + +const BLOCKER_LABELS: Record = { + task_no_plan: 'Task zonder implementation plan', + open_question: 'Openstaande vraag aan jou', + pbi_blocked: 'PBI is geblokkeerd of gefaald', +} + +function blockerHref(productId: string, blocker: PreFlightBlocker): string { + switch (blocker.type) { + case 'task_no_plan': + return `/products/${productId}/sprint?editTask=${blocker.id}` + case 'open_question': + return `/products/${productId}/sprint` + case 'pbi_blocked': + return `/products/${productId}` + } +} + +export function SprintRunControls({ + sprintId, + productId, + sprintStatus, + activeSprintRunId, + activeSprintRunStatus, + isDemo, +}: Props) { + const [pending, startTransition] = useTransition() + const [blockers, setBlockers] = useState(null) + + const hasActiveRun = + activeSprintRunId !== null && + (activeSprintRunStatus === 'QUEUED' || + activeSprintRunStatus === 'RUNNING' || + activeSprintRunStatus === 'PAUSED') + + const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun + const canResume = sprintStatus === 'FAILED' + const canCancel = hasActiveRun + + function handleStart() { + startTransition(async () => { + const result = await startSprintRunAction({ sprint_id: sprintId }) + if (result.ok) { + toast.success(`Sprint gestart (${result.jobs_count} taak(s) klaar)`) + } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) { + setBlockers(result.blockers) + } else { + toast.error(result.error) + } + }) + } + + function handleResume() { + startTransition(async () => { + const result = await resumeSprintAction({ sprint_id: sprintId }) + if (result.ok) { + toast.success(`Sprint hervat (${result.jobs_count} taak(s) klaar)`) + } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) { + setBlockers(result.blockers) + } else { + toast.error(result.error) + } + }) + } + + function handleCancel() { + if (!activeSprintRunId) return + if (!confirm('Sprint annuleren? Openstaande taken blijven TO_DO.')) return + startTransition(async () => { + const result = await cancelSprintRunAction({ sprint_run_id: activeSprintRunId }) + if (result.ok) toast.success('Sprint geannuleerd') + else toast.error(result.error) + }) + } + + return ( + <> +
+ {canStart && ( + + )} + {canResume && ( + + )} + {canCancel && ( + + )} +
+ + { if (!open) setBlockers(null) }}> + + + Sprint kan nog niet starten + + Los eerst onderstaande punten op. Klik op een item om er direct naar + te navigeren. + + + +
    + {blockers?.map((b, i) => ( +
  • +
    + {BLOCKER_LABELS[b.type]} +
    + + {b.label} + +
  • + ))} +
+ + + + +
+
+ + ) +} diff --git a/lib/task-status.ts b/lib/task-status.ts index 3968042..01c64fc 100644 --- a/lib/task-status.ts +++ b/lib/task-status.ts @@ -1,13 +1,20 @@ // Bidirectionele case-mappers voor de REST API-boundary. // DB houdt UPPER_SNAKE; API exposeert lowercase. -import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client' +import type { + TaskStatus, + StoryStatus, + PbiStatus, + SprintStatus, + SprintRunStatus, +} from '@prisma/client' const TASK_DB_TO_API = { TO_DO: 'todo', IN_PROGRESS: 'in_progress', REVIEW: 'review', DONE: 'done', + FAILED: 'failed', } as const satisfies Record const TASK_API_TO_DB: Record = { @@ -15,35 +22,72 @@ const TASK_API_TO_DB: Record = { in_progress: 'IN_PROGRESS', review: 'REVIEW', done: 'DONE', + failed: 'FAILED', } const STORY_DB_TO_API = { OPEN: 'open', IN_SPRINT: 'in_sprint', DONE: 'done', + FAILED: 'failed', } as const satisfies Record const STORY_API_TO_DB: Record = { open: 'OPEN', in_sprint: 'IN_SPRINT', done: 'DONE', + failed: 'FAILED', } const PBI_DB_TO_API = { READY: 'ready', BLOCKED: 'blocked', + FAILED: 'failed', DONE: 'done', } as const satisfies Record const PBI_API_TO_DB: Record = { ready: 'READY', blocked: 'BLOCKED', + failed: 'FAILED', done: 'DONE', } +const SPRINT_DB_TO_API = { + ACTIVE: 'active', + COMPLETED: 'completed', + FAILED: 'failed', +} as const satisfies Record + +const SPRINT_API_TO_DB: Record = { + active: 'ACTIVE', + completed: 'COMPLETED', + failed: 'FAILED', +} + +const SPRINT_RUN_DB_TO_API = { + QUEUED: 'queued', + RUNNING: 'running', + PAUSED: 'paused', + DONE: 'done', + FAILED: 'failed', + CANCELLED: 'cancelled', +} as const satisfies Record + +const SPRINT_RUN_API_TO_DB: Record = { + queued: 'QUEUED', + running: 'RUNNING', + paused: 'PAUSED', + done: 'DONE', + failed: 'FAILED', + cancelled: 'CANCELLED', +} + export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus] export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus] export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus] +export type SprintStatusApi = typeof SPRINT_DB_TO_API[SprintStatus] +export type SprintRunStatusApi = typeof SPRINT_RUN_DB_TO_API[SprintRunStatus] export function taskStatusToApi(s: TaskStatus): TaskStatusApi { return TASK_DB_TO_API[s] @@ -69,6 +113,24 @@ export function pbiStatusFromApi(s: string): PbiStatus | null { return PBI_API_TO_DB[s.toLowerCase()] ?? null } +export function sprintStatusToApi(s: SprintStatus): SprintStatusApi { + return SPRINT_DB_TO_API[s] +} + +export function sprintStatusFromApi(s: string): SprintStatus | null { + return SPRINT_API_TO_DB[s.toLowerCase()] ?? null +} + +export function sprintRunStatusToApi(s: SprintRunStatus): SprintRunStatusApi { + return SPRINT_RUN_DB_TO_API[s] +} + +export function sprintRunStatusFromApi(s: string): SprintRunStatus | null { + return SPRINT_RUN_API_TO_DB[s.toLowerCase()] ?? null +} + export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API) export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API) export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API) +export const SPRINT_STATUS_API_VALUES = Object.values(SPRINT_DB_TO_API) +export const SPRINT_RUN_STATUS_API_VALUES = Object.values(SPRINT_RUN_DB_TO_API) diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts index ca273ca..30aaa6f 100644 --- a/lib/tasks-status-update.ts +++ b/lib/tasks-status-update.ts @@ -1,9 +1,7 @@ -import type { Prisma, TaskStatus } from '@prisma/client' +import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' -export type StoryStatusChange = 'promoted' | 'demoted' | null - -export interface UpdateTaskStatusResult { +export interface PropagationResult { task: { id: string title: string @@ -11,21 +9,33 @@ export interface UpdateTaskStatusResult { story_id: string implementation_plan: string | null } - storyStatusChange: StoryStatusChange storyId: string + storyChanged: boolean + pbiChanged: boolean + sprintChanged: boolean + sprintRunChanged: boolean } -// Update task.status atomically and auto-promote/demote the parent story: -// - All sibling tasks DONE → story.status = DONE -// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT -// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", -// which is a sprint-management action, not a status side-effect. -export async function updateTaskStatusWithStoryPromotion( +// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten +// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie. +// +// Regels: +// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE, +// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN +// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY +// (BLOCKED is handmatig en wordt niet overschreven door deze helper) +// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED, +// ELSE ALL PBIs van die stories DONE → COMPLETED, +// ELSE ACTIVE +// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk + +// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders +// blijft SprintRun ongewijzigd. +export async function propagateStatusUpwards( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, -): Promise { - const run = async (tx: Prisma.TransactionClient): Promise => { +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { const task = await tx.task.update({ where: { id: taskId }, data: { status: newStatus }, @@ -38,33 +48,167 @@ export async function updateTaskStatusWithStoryPromotion( }, }) + // Story herevalueren const siblings = await tx.task.findMany({ where: { story_id: task.story_id }, select: { status: true }, }) - const allDone = siblings.every((s) => s.status === 'DONE') + const anyTaskFailed = siblings.some((s) => s.status === 'FAILED') + const allTasksDone = + siblings.length > 0 && siblings.every((s) => s.status === 'DONE') const story = await tx.story.findUniqueOrThrow({ where: { id: task.story_id }, - select: { status: true }, + select: { id: true, status: true, pbi_id: true, sprint_id: true }, }) - let storyStatusChange: StoryStatusChange = null - if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { + const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN' + let nextStoryStatus: StoryStatus + if (anyTaskFailed) nextStoryStatus = 'FAILED' + else if (allTasksDone) nextStoryStatus = 'DONE' + else nextStoryStatus = defaultActive + + let storyChanged = false + if (nextStoryStatus !== story.status) { await tx.story.update({ - where: { id: task.story_id }, - data: { status: 'DONE' }, + where: { id: story.id }, + data: { status: nextStoryStatus }, }) - storyStatusChange = 'promoted' - } else if (newStatus !== 'DONE' && story.status === 'DONE') { - await tx.story.update({ - where: { id: task.story_id }, - data: { status: 'IN_SPRINT' }, - }) - storyStatusChange = 'demoted' + storyChanged = true } - return { task, storyStatusChange, storyId: task.story_id } + // PBI herevalueren — BLOCKED met rust laten + const pbi = await tx.pbi.findUniqueOrThrow({ + where: { id: story.pbi_id }, + select: { id: true, status: true }, + }) + + let pbiChanged = false + if (pbi.status !== 'BLOCKED') { + const pbiStories = await tx.story.findMany({ + where: { pbi_id: pbi.id }, + select: { status: true }, + }) + const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED') + const allStoriesDone = + pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE') + + let nextPbiStatus: PbiStatus + if (anyStoryFailed) nextPbiStatus = 'FAILED' + else if (allStoriesDone) nextPbiStatus = 'DONE' + else nextPbiStatus = 'READY' + + if (nextPbiStatus !== pbi.status) { + await tx.pbi.update({ + where: { id: pbi.id }, + data: { status: nextPbiStatus }, + }) + pbiChanged = true + } + } + + // Sprint herevalueren — alleen als deze story aan een sprint hangt + let sprintChanged = false + let nextSprintStatus: SprintStatus | null = null + if (story.sprint_id) { + const sprint = await tx.sprint.findUniqueOrThrow({ + where: { id: story.sprint_id }, + select: { id: true, status: true }, + }) + + const sprintPbiRows = await tx.story.findMany({ + where: { sprint_id: sprint.id }, + select: { pbi_id: true }, + distinct: ['pbi_id'], + }) + const sprintPbis = await tx.pbi.findMany({ + where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } }, + select: { status: true }, + }) + const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED') + const allPbisDone = + sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE') + + let nextStatus: SprintStatus + if (anyPbiFailed) nextStatus = 'FAILED' + else if (allPbisDone) nextStatus = 'COMPLETED' + else nextStatus = 'ACTIVE' + + if (nextStatus !== sprint.status) { + await tx.sprint.update({ + where: { id: sprint.id }, + data: { + status: nextStatus, + ...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}), + }, + }) + sprintChanged = true + nextSprintStatus = nextStatus + } + } + + // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task + let sprintRunChanged = false + if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') { + const job = await tx.claudeJob.findFirst({ + where: { task_id: taskId, sprint_run_id: { not: null } }, + orderBy: { created_at: 'desc' }, + select: { id: true, sprint_run_id: true }, + }) + + if (job?.sprint_run_id) { + const sprintRun = await tx.sprintRun.findUnique({ + where: { id: job.sprint_run_id }, + select: { id: true, status: true }, + }) + if ( + sprintRun && + (sprintRun.status === 'QUEUED' || + sprintRun.status === 'RUNNING' || + sprintRun.status === 'PAUSED') + ) { + if (nextSprintStatus === 'FAILED') { + await tx.sprintRun.update({ + where: { id: sprintRun.id }, + data: { + status: 'FAILED', + finished_at: new Date(), + failed_task_id: taskId, + }, + }) + await tx.claudeJob.updateMany({ + where: { + sprint_run_id: sprintRun.id, + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: job.id }, + }, + data: { + status: 'CANCELLED', + finished_at: new Date(), + error: `Cancelled: task ${taskId} failed in same sprint run`, + }, + }) + sprintRunChanged = true + } else { + // COMPLETED + await tx.sprintRun.update({ + where: { id: sprintRun.id }, + data: { status: 'DONE', finished_at: new Date() }, + }) + sprintRunChanged = true + } + } + } + } + + return { + task, + storyId: task.story_id, + storyChanged, + pbiChanged, + sprintChanged, + sprintRunChanged, + } } if (client) return run(client) diff --git a/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql b/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql new file mode 100644 index 0000000..b450a61 --- /dev/null +++ b/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql @@ -0,0 +1,71 @@ +-- Sprint-niveau jobflow met cascade-FAIL (PBI-46 / F1). +-- Voegt FAILED toe aan TaskStatus, StoryStatus, PbiStatus, SprintStatus. +-- Introduceert SprintRunStatus en PrStrategy enums. +-- Maakt sprint_runs tabel + ClaudeJob.sprint_run_id koppeling + Product.pr_strategy. +-- +-- Gegenereerd via: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma +-- (handmatig opgeschoond: todos-tabel wijzigingen weggelaten — zit in een separate migratie #131). + +-- CreateEnum +CREATE TYPE "SprintRunStatus" AS ENUM ('QUEUED', 'RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PrStrategy" AS ENUM ('SPRINT', 'STORY'); + +-- AlterEnum +ALTER TYPE "PbiStatus" ADD VALUE 'FAILED'; + +-- AlterEnum +ALTER TYPE "SprintStatus" ADD VALUE 'FAILED'; + +-- AlterEnum +ALTER TYPE "StoryStatus" ADD VALUE 'FAILED'; + +-- AlterEnum +ALTER TYPE "TaskStatus" ADD VALUE 'FAILED'; + +-- AlterTable +ALTER TABLE "claude_jobs" ADD COLUMN "sprint_run_id" TEXT; + +-- AlterTable +ALTER TABLE "products" ADD COLUMN "pr_strategy" "PrStrategy" NOT NULL DEFAULT 'SPRINT'; + +-- CreateTable +CREATE TABLE "sprint_runs" ( + "id" TEXT NOT NULL, + "sprint_id" TEXT NOT NULL, + "started_by_id" TEXT NOT NULL, + "status" "SprintRunStatus" NOT NULL DEFAULT 'QUEUED', + "pr_strategy" "PrStrategy" NOT NULL, + "branch" TEXT, + "pr_url" TEXT, + "started_at" TIMESTAMP(3), + "finished_at" TIMESTAMP(3), + "failure_reason" TEXT, + "failed_task_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sprint_runs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "sprint_runs_sprint_id_status_idx" ON "sprint_runs"("sprint_id", "status"); + +-- CreateIndex +CREATE INDEX "sprint_runs_started_by_id_status_idx" ON "sprint_runs"("started_by_id", "status"); + +-- CreateIndex +CREATE INDEX "claude_jobs_sprint_run_id_status_idx" ON "claude_jobs"("sprint_run_id", "status"); + +-- AddForeignKey +ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_started_by_id_fkey" FOREIGN KEY ("started_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_failed_task_id_fkey" FOREIGN KEY ("failed_task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_sprint_run_id_fkey" FOREIGN KEY ("sprint_run_id") REFERENCES "sprint_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61a1d2c..9fe73bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,11 +22,13 @@ enum StoryStatus { OPEN IN_SPRINT DONE + FAILED } enum PbiStatus { READY BLOCKED + FAILED DONE } @@ -58,6 +60,7 @@ enum TaskStatus { IN_PROGRESS REVIEW DONE + FAILED } enum LogType { @@ -74,6 +77,21 @@ enum TestStatus { enum SprintStatus { ACTIVE COMPLETED + FAILED +} + +enum SprintRunStatus { + QUEUED + RUNNING + PAUSED + DONE + FAILED + CANCELLED +} + +enum PrStrategy { + SPRINT + STORY } enum IdeaStatus { @@ -109,32 +127,33 @@ enum UserQuestionStatus { } model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - must_reset_password Boolean @default(false) - avatar_data Bytes? - active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - idea_code_counter Int @default(0) - min_quota_pct Int @default(20) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - ideas Idea[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") - login_pairings LoginPairing[] - asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") - answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") - claude_jobs ClaudeJob[] - claude_workers ClaudeWorker[] + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + must_reset_password Boolean @default(false) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + idea_code_counter Int @default(0) + min_quota_pct Int @default(20) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + ideas Idea[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + claude_jobs ClaudeJob[] + claude_workers ClaudeWorker[] + started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") @@index([active_product_id]) @@map("users") @@ -175,6 +194,7 @@ model Product { repo_url String? definition_of_done String auto_pr Boolean @default(false) + pr_strategy PrStrategy @default(SPRINT) archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -277,11 +297,36 @@ model Sprint { completed_at DateTime? stories Story[] tasks Task[] + sprint_runs SprintRun[] @@index([product_id, status]) @@map("sprints") } +model SprintRun { + id String @id @default(cuid()) + sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) + sprint_id String + started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) + started_by_id String + status SprintRunStatus @default(QUEUED) + pr_strategy PrStrategy + branch String? + pr_url String? + started_at DateTime? + finished_at DateTime? + failure_reason String? + failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) + failed_task_id String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + jobs ClaudeJob[] + + @@index([sprint_id, status]) + @@index([started_by_id, status]) + @@map("sprint_runs") +} + model Task { id String @id @default(cuid()) story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) @@ -308,6 +353,7 @@ model Task { updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] + sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@ -326,6 +372,8 @@ model ClaudeJob { task_id String? idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) idea_id String? + sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) + sprint_run_id String? kind ClaudeJobKind @default(TASK_IMPLEMENTATION) status ClaudeJobStatus @default(QUEUED) claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) @@ -352,31 +400,32 @@ model ClaudeJob { @@index([user_id, status]) @@index([task_id, status]) @@index([idea_id, status]) + @@index([sprint_run_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) @@map("claude_jobs") } model ModelPrice { - id String @id @default(cuid()) - model_id String @unique - input_price_per_1m Decimal @db.Decimal(12, 6) - output_price_per_1m Decimal @db.Decimal(12, 6) - cache_read_price_per_1m Decimal @db.Decimal(12, 6) - cache_write_price_per_1m Decimal @db.Decimal(12, 6) - currency String @default("USD") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + model_id String @unique + input_price_per_1m Decimal @db.Decimal(12, 6) + output_price_per_1m Decimal @db.Decimal(12, 6) + cache_read_price_per_1m Decimal @db.Decimal(12, 6) + cache_write_price_per_1m Decimal @db.Decimal(12, 6) + currency String @default("USD") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@map("model_prices") } model ClaudeWorker { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) - token_id String + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token_id String product_id String? started_at DateTime @default(now()) last_seen_at DateTime @default(now()) @@ -437,8 +486,8 @@ model IdeaProduct { product_id String created_at DateTime @default(now()) - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) @@unique([idea_id, product_id]) @@index([product_id]) @@ -468,7 +517,7 @@ model UserQuestion { created_at DateTime @default(now()) updated_at DateTime @updatedAt - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) @@index([idea_id, status]) @@index([user_id])