From 6904de9f2b40dd7ced1bcd670e04d177535da136 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:52:37 +0200 Subject: [PATCH] actions: promoteTodoToIdeaAction (M12 T-499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/todos.ts: - promoteTodoToIdeaAction(todoId): auth + demo + scope + already-archived guards. Atomic \$transaction creates DRAFT Idea (with auto IDEA-NNN code) and archives source Todo + IdeaLog{NOTE}. - Anders dan Todo→PBI/Story (die de todo deleten): we ARCHIVEREN. De idea wordt het nieuwe planningsartifact; de archived todo bewaart het vertrekpunt (zie M12 grill-keuze 12). Tests: 5 cases — happy, auth-401, demo-403, scope-404, already-archived-422. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/todos-promote-idea.test.ts | 114 +++++++++++++++++++ actions/todos.ts | 54 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 __tests__/actions/todos-promote-idea.test.ts diff --git a/__tests__/actions/todos-promote-idea.test.ts b/__tests__/actions/todos-promote-idea.test.ts new file mode 100644 index 0000000..7ddb169 --- /dev/null +++ b/__tests__/actions/todos-promote-idea.test.ts @@ -0,0 +1,114 @@ +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/todos.ts b/actions/todos.ts index 7720eb4..02e4864 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -241,6 +241,60 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo 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' }