diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index c631b0a..120124c 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -50,6 +50,7 @@ import { enqueueAllTodoJobsAction, cancelClaudeJobAction, previewEnqueueAllAction, + enqueueClaudeJobsBatchAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -295,6 +296,95 @@ describe('previewEnqueueAllAction', () => { }) }) +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('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 304d0e8..d7ba1e3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -217,6 +217,81 @@ 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' } + 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' } + } + + 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 { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' }