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:
parent
33cbb6c2f4
commit
6fee0394c5
2 changed files with 360 additions and 0 deletions
209
actions/ideas.ts
209
actions/ideas.ts
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue