From 9e45739786dd7fe7dd22d3ecf4bf8c09dbd85c39 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 6 May 2026 09:01:44 +0200 Subject: [PATCH] ST-9dymjaju: verplaats updateRolesAction naar actions/settings.ts en verwijder todos - Voeg updateRolesAction toe aan actions/settings.ts (hergebruikt getSession en imports) - Update import in components/settings/role-manager.tsx naar @/actions/settings - Verwijder actions/todos.ts (alle todo-server-actions komen te vervallen) - Verwijder __tests__/actions/todos-promote-idea.test.ts (test van verwijderde functie) Co-Authored-By: Claude Sonnet 4.6 --- __tests__/actions/todos-promote-idea.test.ts | 114 ------- actions/settings.ts | 20 ++ actions/todos.ts | 316 ------------------- components/settings/role-manager.tsx | 2 +- 4 files changed, 21 insertions(+), 431 deletions(-) delete mode 100644 __tests__/actions/todos-promote-idea.test.ts delete mode 100644 actions/todos.ts diff --git a/__tests__/actions/todos-promote-idea.test.ts b/__tests__/actions/todos-promote-idea.test.ts deleted file mode 100644 index 7ddb169..0000000 --- a/__tests__/actions/todos-promote-idea.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockSession } = vi.hoisted(() => ({ - mockSession: { userId: 'user-1', isDemo: false }, -})) - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockImplementation(async () => mockSession), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' }, -})) -vi.mock('@/lib/idea-code-server', () => ({ - nextIdeaCode: vi.fn().mockResolvedValue('IDEA-005'), -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) -vi.mock('@/lib/code-server', () => ({ - generateNextPbiCode: vi.fn(), - generateNextStoryCode: vi.fn(), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - todo: { - findFirst: vi.fn(), - update: vi.fn(), - }, - idea: { - create: vi.fn(), - }, - ideaLog: { create: vi.fn() }, - $transaction: vi.fn(), - }, -})) - -import { prisma } from '@/lib/prisma' -import { promoteTodoToIdeaAction } from '@/actions/todos' - -type M = { - todo: { findFirst: ReturnType; update: ReturnType } - idea: { create: ReturnType } - ideaLog: { create: ReturnType } - $transaction: ReturnType -} -const m = prisma as unknown as M - -beforeEach(() => { - vi.clearAllMocks() - mockSession.userId = 'user-1' - mockSession.isDemo = false - m.$transaction.mockImplementation(async (arg: unknown) => { - if (typeof arg === 'function') { - return (arg as (tx: unknown) => unknown)(m) - } - return arg - }) -}) - -describe('promoteTodoToIdeaAction', () => { - it('happy: archives todo, creates DRAFT idea, returns idea_id', async () => { - m.todo.findFirst.mockResolvedValueOnce({ - id: 'todo-1', - title: 'My idea', - description: 'desc', - product_id: null, - archived: false, - }) - m.idea.create.mockResolvedValueOnce({ id: 'idea-9', code: 'IDEA-005' }) - - const r = await promoteTodoToIdeaAction('todo-1') - expect(r).toMatchObject({ success: true, idea_id: 'idea-9', idea_code: 'IDEA-005' }) - expect(m.todo.update).toHaveBeenCalledWith({ - where: { id: 'todo-1' }, - data: { archived: true }, - }) - }) - - it('rejects unauthenticated', async () => { - mockSession.userId = '' - const r = await promoteTodoToIdeaAction('todo-1') - expect(r).toMatchObject({ code: 401 }) - }) - - it('rejects demo-user', async () => { - mockSession.isDemo = true - const r = await promoteTodoToIdeaAction('todo-1') - expect(r).toMatchObject({ code: 403 }) - }) - - it('returns 404 when todo belongs to another user', async () => { - m.todo.findFirst.mockResolvedValueOnce(null) - const r = await promoteTodoToIdeaAction('todo-1') - expect(r).toMatchObject({ code: 404 }) - }) - - it('rejects already-archived todo', async () => { - m.todo.findFirst.mockResolvedValueOnce({ - id: 'todo-1', - title: 'x', - description: null, - product_id: null, - archived: true, - }) - const r = await promoteTodoToIdeaAction('todo-1') - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.create).not.toHaveBeenCalled() - }) -}) diff --git a/actions/settings.ts b/actions/settings.ts index 07c4889..952b13a 100644 --- a/actions/settings.ts +++ b/actions/settings.ts @@ -27,3 +27,23 @@ export async function updateMinQuotaPctAction(value: number) { revalidatePath('/settings') return { success: true } } + +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/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/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' },