From 7d76c3baee7e1907d4a34e12361f53bfc186e8f0 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 10:54:56 +0200 Subject: [PATCH] feat: enqueueAllTodoJobsAction voor batch-queueing van TO_DO-taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nieuwe Server Action die alle TO_DO-taken van een product zonder actieve ClaudeJob in één $transaction als QUEUED jobs aanmaakt en voor elk een pg_notify('claude_job_enqueued') stuurt zodat de SSE- stream de UI live bijwerkt. - Auth + demo-blokkade + product-access via productAccessFilter - Idempotent: tasks met status QUEUED/CLAIMED/RUNNING worden overgeslagen - 4 nieuwe tests (happy path, count=0, demo-blokkade, geen toegang) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/claude-jobs.test.ts | 64 ++++++++++++++++++++++++++- actions/claude-jobs.ts | 56 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index fea9d8c..28b8783 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -3,17 +3,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, mockFindFirstTask, + mockFindManyTask, + mockFindFirstProduct, mockFindFirstJob, mockCreateJob, mockUpdateJob, mockExecuteRaw, + mockTransaction, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockFindFirstTask: vi.fn(), + mockFindManyTask: vi.fn(), + mockFindFirstProduct: 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() })) @@ -24,17 +30,23 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/prisma', () => ({ prisma: { - task: { findFirst: mockFindFirstTask }, + task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, claudeJob: { findFirst: mockFindFirstJob, create: mockCreateJob, update: mockUpdateJob, }, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) -import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' +import { + enqueueClaudeJobAction, + enqueueAllTodoJobsAction, + cancelClaudeJobAction, +} from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -108,6 +120,54 @@ describe('enqueueClaudeJobAction', () => { }) }) +describe('enqueueAllTodoJobsAction', () => { + it('happy path: queues a job for every TO_DO task without active job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + 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(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 when no queueable tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + 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() + }) +}) + 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 fa9a1e8..1ed1fef 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -10,6 +10,10 @@ type EnqueueResult = | { success: true; jobId: string } | { error: string; jobId?: string } +type EnqueueAllResult = + | { success: true; count: number } + | { error: string } + type CancelResult = { success: true } | { error: string } export async function enqueueClaudeJobAction(taskId: string): Promise { @@ -59,6 +63,58 @@ 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' } + + 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 tasks = await prisma.task.findMany({ + where: { + status: 'TO_DO', + story: { product_id: productId }, + 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) + ` + } + + 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' }