'use server' import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' import { normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) } // Return types for TaskDialog actions export type SaveTaskResult = | { ok: true; task: { id: string; title: string; status: string } } | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } | { ok: false; code: 429; error: 'rate_limited' } | { ok: false; code: 500; error: 'server_error' } export type DeleteTaskResult = | { ok: true } | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } | { ok: false; code: 500; error: 'server_error' } // Unified create/edit action used by TaskDialog. // context.taskId present → edit; context.storyId present → create. export async function saveTask( input: TaskInput, context: { taskId?: string; storyId?: string; productId: string }, ): Promise { const session = await getSession() if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } // Rate-limit alleen op create-path; edits zijn laag-volume. if (!context.taskId) { const limited = enforceUserRateLimit('create-task', session.userId) if (limited) return { ok: false, code: 429, error: 'rate_limited' } } const parsed = sharedTaskSchema.safeParse(input) if (!parsed.success) { return { ok: false, code: 422, error: 'validation', fieldErrors: parsed.error.flatten().fieldErrors as Record, } } const { title, description, implementation_plan, priority, status, code: rawCode } = parsed.data const inputCode = normalizeCode(rawCode ?? null) const scope = productAccessFilter(session.userId) try { if (context.taskId) { const existing = await prisma.task.findFirst({ where: { id: context.taskId, story: { product: scope } }, select: { id: true, status: true }, }) if (!existing) return { ok: false, code: 403, error: 'forbidden' } const taskId = context.taskId const statusChanged = status !== undefined && status !== existing.status const task = await prisma.$transaction(async (tx) => { const updated = await tx.task.update({ where: { id: taskId }, data: { title, description: description ?? null, implementation_plan: implementation_plan ?? null, priority, }, select: { id: true, title: true, status: true }, }) if (statusChanged) { const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) return { id: result.task.id, title: result.task.title, status: result.task.status } } return updated }) revalidatePath(`/products/${context.productId}/sprint`) revalidatePath(`/products/${context.productId}`) return { ok: true, task: { ...task, status: task.status.toString() } } } if (!context.storyId) { return { ok: false, code: 422, error: 'validation', fieldErrors: { storyId: ['Verplicht'] } } } const story = await prisma.story.findFirst({ where: { id: context.storyId, product: scope }, select: { sprint_id: true, product_id: true }, }) if (!story) return { ok: false, code: 403, error: 'forbidden' } const last = await prisma.task.findFirst({ where: { story_id: context.storyId }, orderBy: { sort_order: 'desc' }, select: { sort_order: true }, }) const productId = story.product_id const sprintId = story.sprint_id ?? null const sortOrder = (last?.sort_order ?? 0) + 1.0 const storyId = context.storyId const task = await createWithCodeRetry( () => (inputCode ? Promise.resolve(inputCode) : generateNextTaskCode(productId)), (code) => prisma.task.create({ data: { story_id: storyId, product_id: productId, sprint_id: sprintId, code, title, description: description ?? null, implementation_plan: implementation_plan ?? null, priority, sort_order: sortOrder, status: 'TO_DO', }, select: { id: true, title: true, status: true }, }), ) revalidatePath(`/products/${context.productId}/sprint`) revalidatePath(`/products/${context.productId}`) return { ok: true, task: { ...task, status: task.status.toString() } } } catch (e) { if (inputCode && isCodeUniqueConflict(e)) { return { ok: false, code: 422, error: 'validation', fieldErrors: { code: ['Code bestaat al binnen dit product'] }, } } return { ok: false, code: 500, error: 'server_error' } } } // Delete action used by TaskDialog (context-aware revalidation). export async function deleteTask( taskId: string, context: { productId: string }, ): Promise { const session = await getSession() if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } try { const task = await prisma.task.findFirst({ where: { id: taskId, story: { product: productAccessFilter(session.userId) } }, }) if (!task) return { ok: false, code: 403, error: 'forbidden' } await prisma.task.delete({ where: { id: taskId } }) revalidatePath(`/products/${context.productId}/sprint`) revalidatePath(`/products/${context.productId}`) return { ok: true } } catch { return { ok: false, code: 500, error: 'server_error' } } } const taskSchema = z.object({ title: z.string().min(1, 'Titel is verplicht').max(200), description: z.string().max(1000).optional(), priority: z.coerce.number().int().min(1).max(4), }) export async function createTaskAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const limited = enforceUserRateLimit('create-task', session.userId) if (limited) return limited const storyId = formData.get('storyId') as string const sprintId = formData.get('sprintId') as string const parsed = taskSchema.safeParse({ title: formData.get('title'), description: formData.get('description') || undefined, priority: formData.get('priority') ?? 2, }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const story = await prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } const last = await prisma.task.findFirst({ where: { story_id: storyId }, orderBy: { sort_order: 'desc' }, }) const productId = story.product_id const task = await createWithCodeRetry( () => generateNextTaskCode(productId), (code) => prisma.task.create({ data: { story_id: storyId, product_id: productId, sprint_id: sprintId || null, code, title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, sort_order: (last?.sort_order ?? 0) + 1.0, status: 'TO_DO', }, }), ) revalidatePath(`/products/${story.product_id}/sprint/planning`) return { success: true, task } } export async function updateTaskAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const id = formData.get('id') as string const parsed = taskSchema.safeParse({ title: formData.get('title'), description: formData.get('description') || undefined, priority: formData.get('priority'), }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const task = await prisma.task.findFirst({ where: { id, story: { product: productAccessFilter(session.userId) } }, include: { story: true }, }) if (!task) return { error: 'Taak niet gevonden' } await prisma.task.update({ where: { id }, data: { title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority }, }) revalidatePath(`/products/${task.story.product_id}/sprint/planning`) return { success: true } } export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_PROGRESS' | 'DONE') { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const task = await prisma.task.findFirst({ where: { id, story: { product: productAccessFilter(session.userId) } }, include: { story: true }, }) if (!task) return { error: 'Taak niet gevonden' } await updateTaskStatusWithStoryPromotion(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic // store-updates + realtime echo (M8). Sprint/planning heeft geen // realtime en moet wèl revalidaten. revalidatePath(`/products/${task.story.product_id}/sprint/planning`) return { success: true } } export async function deleteTaskAction(id: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const task = await prisma.task.findFirst({ where: { id, story: { product: productAccessFilter(session.userId) } }, include: { story: true }, }) if (!task) return { error: 'Taak niet gevonden' } await prisma.task.delete({ where: { id } }) revalidatePath(`/products/${task.story.product_id}/sprint/planning`) return { success: true } } const updateTaskPlanSchema = z.object({ taskId: z.string().min(1), productId: z.string().min(1), implementationPlan: z.string().max(10000), }) export async function updateTaskPlanAction(taskId: string, productId: string, implementationPlan: string) { try { await requireProductWriter(productId) } catch (e) { return { error: e instanceof Error ? e.message : 'Niet geautoriseerd' } } const parsed = updateTaskPlanSchema.safeParse({ taskId, productId, implementationPlan }) if (!parsed.success) return { error: 'Ongeldige invoer' } const task = await prisma.task.findFirst({ where: { id: taskId, story: { product_id: productId } }, include: { story: true }, }) if (!task) return { error: 'Taak niet gevonden' } await prisma.task.update({ where: { id: taskId }, data: { implementation_plan: implementationPlan || null }, }) // /solo bewust niet revalideren — zie updateTaskStatusAction. revalidatePath(`/products/${productId}/sprint/planning`) return { success: true } } export async function reorderTasksAction(storyId: string, orderedIds: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const story = await prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } await prisma.$transaction( orderedIds.map((id, i) => prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) ) ) revalidatePath(`/products/${story.product_id}/sprint/planning`) return { success: true } }