diff --git a/actions/settings.ts b/actions/settings.ts index 07c4889..17b8a8e 100644 --- a/actions/settings.ts +++ b/actions/settings.ts @@ -11,6 +11,26 @@ async function getSession() { return getIronSession(await cookies(), sessionOptions) } +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 } +} + export async function updateMinQuotaPctAction(value: number) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/actions/todos.ts b/actions/todos.ts deleted file mode 100644 index 02e4864..0000000 --- a/actions/todos.ts +++ /dev/null @@ -1,316 +0,0 @@ -'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 } -} diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts deleted file mode 100644 index 6a682e5..0000000 --- a/app/api/todos/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { z } from 'zod' - -const bodySchema = z.object({ - title: z.string().min(1, 'Titel is verplicht').max(500), - description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(), - product_id: z.string().min(1, 'Product is verplicht'), -}) - -export async function POST(request: Request) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - if (auth.isDemo) { - return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) - } - - let body: unknown - try { - body = await request.json() - } catch { - return Response.json({ error: 'Malformed JSON' }, { status: 400 }) - } - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return Response.json({ error: parsed.error.flatten() }, { status: 422 }) - } - - const product = await prisma.product.findFirst({ - where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const description = parsed.data.description?.trim() || null - - const todo = await prisma.todo.create({ - data: { - user_id: auth.userId, - product_id: parsed.data.product_id, - title: parsed.data.title, - description, - }, - }) - - return Response.json( - { id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at }, - { status: 201 }, - ) -} diff --git a/components/settings/role-manager.tsx b/components/settings/role-manager.tsx index 23fd59b..934c291 100644 --- a/components/settings/role-manager.tsx +++ b/components/settings/role-manager.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { updateRolesAction } from '@/actions/todos' +import { updateRolesAction } from '@/actions/settings' const ALL_ROLES = [ { value: 'PRODUCT_OWNER', label: 'Product Owner' },