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

@ -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