diff --git a/actions/tasks.ts b/actions/tasks.ts index f70c452..3cfd203 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -8,11 +8,134 @@ 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' 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: 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' } + + 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 } = parsed.data + const scope = productAccessFilter(session.userId) + + try { + if (context.taskId) { + const existing = await prisma.task.findFirst({ + where: { id: context.taskId, story: { product: scope } }, + }) + if (!existing) return { ok: false, code: 403, error: 'forbidden' } + + const task = await prisma.task.update({ + where: { id: context.taskId }, + data: { + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + ...(status !== undefined ? { status } : {}), + }, + 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() } } + } + + 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 }, + }) + 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 task = await prisma.task.create({ + data: { + story_id: context.storyId, + sprint_id: story.sprint_id ?? null, + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + 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 { + 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(),