actions: materializeIdeaPlanAction + relinkIdeaPlanAction (M12 T-498)

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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 19:51:18 +02:00
parent 33cbb6c2f4
commit 6fee0394c5
2 changed files with 360 additions and 0 deletions

View file

@ -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<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
claudeWorker: { count: ReturnType<typeof vi.fn> }
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
$executeRaw: ReturnType<typeof vi.fn>
}
@ -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({

View file

@ -459,6 +459,215 @@ export async function cancelIdeaJobAction(id: string): Promise<ActionResult> {
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<ActionResult<{ pbi_id: string; pbi_code: string; story_ids: string[]; task_ids: string[] }>> {
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<ActionResult> {
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