'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 { generateNextPbiCode, generateNextStoryCode } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) } export async function createTodoAction(_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-todo', session.userId) if (limited) return limited const title = (formData.get('title') as string)?.trim() const description = (formData.get('description') as string)?.trim() || null const raw = (formData.get('productId') as string)?.trim() const productId = (raw && raw !== 'all') ? raw : null if (!title) return { error: 'Titel is verplicht' } if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } if (productId) { const product = await prisma.product.findFirst({ where: { id: productId, ...productAccessFilter(session.userId), archived: false }, }) if (!product) return { error: 'Product niet gevonden' } } await prisma.todo.create({ data: { user_id: session.userId, product_id: productId, title, description }, }) revalidatePath('/todos') return { success: true } } export async function toggleTodoAction(id: string, done: boolean) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const todo = await prisma.todo.findFirst({ where: { id, user_id: session.userId } }) if (!todo) return { error: 'Todo niet gevonden' } await prisma.todo.update({ where: { id }, data: { done } }) revalidatePath('/todos') return { success: true } } export async function archiveCompletedTodosAction() { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } await prisma.todo.updateMany({ where: { user_id: session.userId, done: true, archived: false }, data: { archived: true }, }) revalidatePath('/todos') return { success: true } } export async function updateTodoAction(_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)?.trim() const title = (formData.get('title') as string)?.trim() const description = (formData.get('description') as string)?.trim() || null const raw = (formData.get('productId') as string)?.trim() const productId = raw || null const done = formData.get('done') === 'on' if (!id) return { error: 'Ongeldige todo' } if (!title) return { error: 'Titel is verplicht' } if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } const todo = await prisma.todo.findFirst({ where: { id, user_id: session.userId }, }) if (!todo) return { error: 'Todo niet gevonden' } if (productId) { const product = await prisma.product.findFirst({ where: { id: productId, ...productAccessFilter(session.userId), archived: false }, }) if (!product) return { error: 'Product niet gevonden' } } await prisma.todo.update({ where: { id }, data: { title, description, product_id: productId, done }, }) revalidatePath('/todos') return { success: true } } export async function archiveSelectedTodosAction(ids: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (!ids.length) return { error: 'Geen todos geselecteerd' } const owned = await prisma.todo.findMany({ where: { id: { in: ids }, user_id: session.userId }, select: { id: true }, }) if (owned.length !== ids.length) return { error: 'Ongeldige selectie' } await prisma.todo.updateMany({ where: { id: { in: ids }, user_id: session.userId }, data: { archived: true }, }) revalidatePath('/todos') return { success: true } } const promotePbiSchema = z.object({ todoId: z.string(), productId: z.string(), title: z.string().min(1).max(200), priority: z.coerce.number().int().min(1).max(4), }) export async function promoteTodoToPbiAction(_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 parsed = promotePbiSchema.safeParse({ todoId: formData.get('todoId'), productId: formData.get('productId'), title: formData.get('title'), priority: formData.get('priority'), }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const product = await prisma.product.findFirst({ where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, }) if (!product) return { error: 'Product niet gevonden' } const todo = await prisma.todo.findFirst({ where: { id: parsed.data.todoId, user_id: session.userId }, }) if (!todo) return { error: 'Todo niet gevonden' } const last = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, priority: parsed.data.priority }, orderBy: { sort_order: 'desc' }, }) const pbiCode = await generateNextPbiCode(parsed.data.productId) await prisma.$transaction([ prisma.pbi.create({ data: { product_id: parsed.data.productId, code: pbiCode, title: parsed.data.title, priority: parsed.data.priority, sort_order: (last?.sort_order ?? 0) + 1.0, }, }), prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), ]) revalidatePath('/todos') revalidatePath(`/products/${parsed.data.productId}`) return { success: true } } const promoteStorySchema = z.object({ todoId: z.string(), productId: z.string(), pbiId: z.string(), title: z.string().min(1).max(200), priority: z.coerce.number().int().min(1).max(4), }) export async function promoteTodoToStoryAction(_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 parsed = promoteStorySchema.safeParse({ todoId: formData.get('todoId'), productId: formData.get('productId'), pbiId: formData.get('pbiId'), title: formData.get('title'), priority: formData.get('priority'), }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const todo = await prisma.todo.findFirst({ where: { id: parsed.data.todoId, user_id: session.userId }, }) if (!todo) return { error: 'Todo niet gevonden' } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } if (todo.product_id !== null && todo.product_id !== pbi.product_id) return { error: 'Todo hoort niet bij dit product' } const last = await prisma.story.findFirst({ where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, orderBy: { sort_order: 'desc' }, }) const storyCode = await generateNextStoryCode(pbi.product_id) await prisma.$transaction([ prisma.story.create({ data: { pbi_id: parsed.data.pbiId, product_id: pbi.product_id, code: storyCode, title: parsed.data.title, priority: parsed.data.priority, sort_order: (last?.sort_order ?? 0) + 1.0, status: 'OPEN', }, }), prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), ]) revalidatePath('/todos') revalidatePath(`/products/${pbi.product_id}`) return { success: true } } // M12: promote a Todo into a DRAFT Idea. Anders dan Todo→PBI/Story (die de // todo deleteert) ARCHIVEREN we de todo hier — het idee houdt zelf de // planningsgeschiedenis bij, en de archived todo bewaart het oorspronkelijke // vertrekpunt. export async function promoteTodoToIdeaAction(todoId: string): Promise< { success: true; idea_id: string; idea_code: string } | { error: string; code?: number } > { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd', code: 401 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } if (!todoId) return { error: 'todoId is verplicht', code: 422 } const todo = await prisma.todo.findFirst({ where: { id: todoId, user_id: session.userId }, select: { id: true, title: true, description: true, product_id: true, archived: true }, }) if (!todo) return { error: 'Todo niet gevonden', code: 404 } if (todo.archived) return { error: 'Todo is al gearchiveerd', code: 422 } const userId = session.userId // Lazy-import om dit server-only bestand niet te dwingen in een client bundle. const { nextIdeaCode } = await import('@/lib/idea-code-server') const idea = await prisma.$transaction(async (tx) => { const code = await nextIdeaCode(userId, tx) const created = await tx.idea.create({ data: { user_id: userId, product_id: todo.product_id, code, title: todo.title, description: todo.description ?? null, status: 'DRAFT', }, select: { id: true, code: true }, }) await tx.todo.update({ where: { id: todoId }, data: { archived: true } }) await tx.ideaLog.create({ data: { idea_id: created.id, type: 'NOTE', content: `Promoted from Todo ${todoId}`, metadata: { source_todo_id: todoId }, }, }) return created }) revalidatePath('/ideas') revalidatePath('/todos') return { success: true, idea_id: idea.id, idea_code: idea.code } } export async function updateRolesAction(roles: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER'] const filtered = roles.filter(r => validRoles.includes(r)) if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' } await prisma.$transaction([ prisma.userRole.deleteMany({ where: { user_id: session.userId } }), prisma.userRole.createMany({ data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })), }), ]) revalidatePath('/settings') return { success: true } }