diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts new file mode 100644 index 0000000..fa9a1e8 --- /dev/null +++ b/actions/claude-jobs.ts @@ -0,0 +1,97 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { productAccessFilter } from '@/lib/product-access' +import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' + +type EnqueueResult = + | { success: true; jobId: string } + | { error: string; jobId?: string } + +type CancelResult = { success: true } | { error: string } + +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 (!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 } + } + + 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 cancelClaudeJobAction(jobId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!jobId) return { error: 'job_id is verplicht' } + + const job = await prisma.claudeJob.findFirst({ + where: { id: jobId, user_id: session.userId }, + select: { id: true, status: true, task_id: true, product_id: true }, + }) + if (!job) return { error: 'Job niet gevonden' } + + if (!ACTIVE_JOB_STATUSES.includes(job.status)) { + return { error: 'Alleen actieve jobs kunnen geannuleerd worden' } + } + + await prisma.claudeJob.update({ + where: { id: jobId }, + data: { status: 'CANCELLED', finished_at: new Date() }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: jobId, + task_id: job.task_id, + user_id: session.userId, + product_id: job.product_id, + status: jobStatusToApi('CANCELLED'), + })}::text) + ` + + revalidatePath(`/products/${job.product_id}/solo`) + return { success: true } +}