From 8018920caec4b9058a1db2187d323c9cd24b9d56 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 13:29:48 +0200 Subject: [PATCH] feat(solo): previewEnqueueAllAction met blocker-detectie Voeg previewEnqueueAllAction toe aan actions/claude-jobs.ts: haalt taken op in PBI-volgorde, filtert actieve jobs, detecteert eerste blocker (REVIEW taak of BLOCKED PBI). Retourneert tasks[], blockerIndex en blockerReason. Tests: 7 nieuwe cases voor alle blocker-scenario's en demo-blokkering. --- __tests__/actions/claude-jobs.test.ts | 102 ++++++++++++++++++++++++++ actions/claude-jobs.ts | 92 +++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 1da99ef..c631b0a 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -49,6 +49,7 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, + previewEnqueueAllAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -193,6 +194,107 @@ describe('enqueueAllTodoJobsAction', () => { }) }) +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' }), + }) + ) + }) +}) + describe('cancelClaudeJobAction', () => { it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 17c55d3..304d0e8 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -16,6 +16,19 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } +export type PreviewTask = { + id: string + title: string + status: string + story_title: string + pbi_id: string + pbi_status: string +} + +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' } @@ -125,6 +138,85 @@ 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 + + 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 + } + } + + 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 } +} + export async function cancelClaudeJobAction(jobId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' }