'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' import { enforceUserRateLimit } from '@/lib/rate-limit' 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 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' } 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 } } 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) ` } 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 } } 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 enqueueClaudeJobsBatchAction( 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' } } 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' } 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 } }