From 6fee0394c5105b79b1bf1e36f360cb3c8a5f40cd Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:51:18 +0200 Subject: [PATCH] actions: materializeIdeaPlanAction + relinkIdeaPlanAction (M12 T-498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/ideas.ts: - materializeIdeaPlanAction(id): - guard: status===PLAN_READY, plan_md present, product linked, demo-403 - parsePlanMd → 422 with line-info on fail - Prisma.\$transaction: - SELECT max(code) for PBI/Story/Task within product - INSERT PBI with sort_order = lastPbi+1 within priority - per story: INSERT (sequential ST-NNN), per task: INSERT (T-N) - UPDATE idea SET pbi_id, status=PLANNED - INSERT IdeaLog{PLAN_RESULT, metadata} - returns 409 on P2002 (concurrent-materialize race) - relinkIdeaPlanAction(id): - guard: status===PLANNED && pbi_id===null (PBI manually deleted via SetNull FK) - reverts to PLAN_READY + IdeaLog{NOTE} Tests: 39 cases total (8 new for materialize + relink): happy creates entities, status-mismatch-422, parse-fail-422 with details, demo-403, P2002→409, relink happy + invalid-precondition guards. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/ideas-crud.test.ts | 151 +++++++++++++++++++ actions/ideas.ts | 209 +++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 543b42c..525c56f 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -32,6 +32,19 @@ vi.mock('@/lib/prisma', () => ({ claudeWorker: { count: vi.fn(), }, + pbi: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + }, + story: { + findMany: vi.fn(), + create: vi.fn(), + }, + task: { + findMany: vi.fn(), + create: vi.fn(), + }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), }, @@ -49,6 +62,8 @@ import { startGrillJobAction, startMakePlanJobAction, cancelIdeaJobAction, + materializeIdeaPlanAction, + relinkIdeaPlanAction, } from '@/actions/ideas' type MockIdea = { @@ -56,6 +71,9 @@ type MockIdea = { ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } + story: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } @@ -358,6 +376,139 @@ describe('cancelIdeaJobAction', () => { }) }) +describe('materializeIdeaPlanAction', () => { + const VALID_PLAN = `--- +pbi: + title: New PBI + priority: 2 +stories: + - title: Story A + priority: 2 + tasks: + - title: Task A1 + priority: 2 + implementation_plan: "1. Doe X" + - title: Task A2 + priority: 2 + - title: Story B + priority: 3 + tasks: + - title: Task B1 + priority: 3 +--- + +body +` + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + m.pbi.findMany.mockResolvedValue([]) + m.story.findMany.mockResolvedValue([]) + m.task.findMany.mockResolvedValue([]) + m.pbi.findFirst.mockResolvedValue(null) + m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' }) + m.story.create + .mockResolvedValueOnce({ id: 's-A' }) + .mockResolvedValueOnce({ id: 's-B' }) + m.task.create + .mockResolvedValueOnce({ id: 't-A1' }) + .mockResolvedValueOnce({ id: 't-A2' }) + .mockResolvedValueOnce({ id: 't-B1' }) + }) + + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => { + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ + success: true, + data: { + pbi_id: 'pbi-1', + pbi_code: 'PBI-1', + story_ids: ['s-A', 's-B'], + task_ids: ['t-A1', 't-A2', 't-B1'], + }, + }) + expect(m.pbi.create).toHaveBeenCalledTimes(1) + expect(m.story.create).toHaveBeenCalledTimes(2) + expect(m.task.create).toHaveBeenCalledTimes(3) + }) + + it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.pbi.create).not.toHaveBeenCalled() + }) + + it('returns 422 with details on parse-fail', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: '# no frontmatter', + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + }) + + it('returns 409 on P2002 race', async () => { + m.$transaction.mockImplementationOnce(async () => { + throw new Error('Unique constraint failed (P2002)') + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) +}) + +describe('relinkIdeaPlanAction', () => { + it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('blocks when pbi still linked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: 'pbi-1', + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when not PLANNED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + describe('downloadIdeaMdAction', () => { it('returns grill_md when present', async () => { m.idea.findFirst.mockResolvedValueOnce({ diff --git a/actions/ideas.ts b/actions/ideas.ts index 9d1438b..dea31c7 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -459,6 +459,215 @@ export async function cancelIdeaJobAction(id: string): Promise { return { success: true } } +// --------------------------------------------------------------------------- +// Materialize: parse plan_md → INSERT PBI + stories + taken (atomic) + +const PBI_AUTO_RE = /^PBI-(\d+)$/ +const STORY_AUTO_RE = /^ST-(\d+)$/ +const TASK_AUTO_RE = /^T-(\d+)$/ + +function nextNumber(existing: (string | null)[], re: RegExp): number { + let max = 0 + for (const c of existing) { + if (!c) continue + const m = c.match(re) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return max + 1 +} + +export async function materializeIdeaPlanAction( + id: string, +): Promise> { + 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 } + + const limited = enforceUserRateLimit('materialize-idea', session.userId) + if (limited) return limited + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, product_id: true, plan_md: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLAN_READY') { + return { + error: `Materialiseren alleen toegestaan in PLAN_READY (huidige status: ${idea.status})`, + code: 422, + } + } + if (!idea.product_id) { + return { error: 'Idee mist een gekoppeld product', code: 422 } + } + if (!idea.plan_md) { + return { error: 'Idee heeft geen plan_md', code: 422 } + } + + const parsed = parsePlanMd(idea.plan_md) + if (!parsed.ok) { + return { error: 'plan_md is niet parseerbaar', code: 422, details: parsed.errors } + } + + const productId = idea.product_id + const plan = parsed.plan + + try { + const result = await prisma.$transaction(async (tx) => { + // Codes: één keer SELECT max per type binnen de transactie. Bij P2002 + // (race met andere materialize) abort de transactie en gooien we 409. + const [existingPbis, existingStories, existingTasks] = await Promise.all([ + tx.pbi.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.story.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.task.findMany({ where: { product_id: productId }, select: { code: true } }), + ]) + let nextPbiN = nextNumber(existingPbis.map((p) => p.code), PBI_AUTO_RE) + let nextStoryN = nextNumber(existingStories.map((s) => s.code), STORY_AUTO_RE) + let nextTaskN = nextNumber(existingTasks.map((t) => t.code), TASK_AUTO_RE) + + // sort_order: vraag de huidige max binnen het product op (per priority) + const lastPbi = await tx.pbi.findFirst({ + where: { product_id: productId, priority: plan.pbi.priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + const pbiSortOrder = (lastPbi?.sort_order ?? 0) + 1.0 + + const pbi = await tx.pbi.create({ + data: { + product_id: productId, + code: `PBI-${nextPbiN++}`, + title: plan.pbi.title, + description: plan.pbi.description ?? null, + priority: plan.pbi.priority, + sort_order: pbiSortOrder, + }, + select: { id: true, code: true }, + }) + + const storyIds: string[] = [] + const taskIds: string[] = [] + + for (let si = 0; si < plan.stories.length; si++) { + const s = plan.stories[si] + const story = await tx.story.create({ + data: { + pbi_id: pbi.id, + product_id: productId, + code: `ST-${String(nextStoryN++).padStart(3, '0')}`, + title: s.title, + description: s.description ?? null, + acceptance_criteria: s.acceptance_criteria ?? null, + priority: s.priority, + sort_order: si + 1, // sequential within PBI + status: 'OPEN', + }, + select: { id: true }, + }) + storyIds.push(story.id) + + for (let ti = 0; ti < s.tasks.length; ti++) { + const t = s.tasks[ti] + const task = await tx.task.create({ + data: { + story_id: story.id, + product_id: productId, + code: `T-${nextTaskN++}`, + title: t.title, + description: t.description ?? null, + implementation_plan: t.implementation_plan ?? null, + priority: t.priority, + sort_order: ti + 1, + status: 'TO_DO', + verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', + verify_only: t.verify_only ?? false, + }, + select: { id: true }, + }) + taskIds.push(task.id) + } + } + + // Link idea → PBI + status PLANNED + await tx.idea.update({ + where: { id }, + data: { pbi_id: pbi.id, status: 'PLANNED' }, + }) + + // Audit log + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'PLAN_RESULT', + content: `Materialized into ${pbi.code} (${plan.stories.length} stories, ${taskIds.length} tasks)`, + metadata: { + pbi_id: pbi.id, + pbi_code: pbi.code, + story_count: storyIds.length, + task_count: taskIds.length, + }, + }, + }) + + return { pbi_id: pbi.id, pbi_code: pbi.code, story_ids: storyIds, task_ids: taskIds } + }) + + revalidatePath(`/ideas/${id}`) + revalidatePath(`/products/${productId}/backlog`) + return { success: true, data: result } + } catch (err) { + // P2002 op code = race met andere materialize. Andere fouten = bug. + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('P2002') || msg.includes('Unique constraint')) { + return { + error: 'Code-conflict tijdens materialiseren (race). Probeer opnieuw.', + code: 409, + } + } + throw err + } +} + +// --------------------------------------------------------------------------- +// Re-link: een idee in PLANNED waarvan de PBI handmatig is verwijderd +// (Pbi.id → null door de SetNull-FK). Gebruiker klikt expliciet "Re-link plan" +// om terug naar PLAN_READY te gaan en eventueel opnieuw te materialiseren. + +export async function relinkIdeaPlanAction(id: string): Promise { + 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 } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, pbi_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLANNED' || idea.pbi_id !== null) { + return { + error: 'Re-link kan alleen wanneer status=PLANNED én PBI is verwijderd', + code: 422, + } + } + + await prisma.$transaction([ + prisma.idea.update({ where: { id }, data: { status: 'PLAN_READY' } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'PBI was deleted; relinked to PLAN_READY', + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + // --------------------------------------------------------------------------- // Helpers