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
|
|
@ -32,6 +32,19 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
claudeWorker: {
|
claudeWorker: {
|
||||||
count: vi.fn(),
|
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(),
|
$transaction: vi.fn(),
|
||||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
|
|
@ -49,6 +62,8 @@ import {
|
||||||
startGrillJobAction,
|
startGrillJobAction,
|
||||||
startMakePlanJobAction,
|
startMakePlanJobAction,
|
||||||
cancelIdeaJobAction,
|
cancelIdeaJobAction,
|
||||||
|
materializeIdeaPlanAction,
|
||||||
|
relinkIdeaPlanAction,
|
||||||
} from '@/actions/ideas'
|
} from '@/actions/ideas'
|
||||||
|
|
||||||
type MockIdea = {
|
type MockIdea = {
|
||||||
|
|
@ -56,6 +71,9 @@ type MockIdea = {
|
||||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: 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> }
|
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>
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
$executeRaw: 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', () => {
|
describe('downloadIdeaMdAction', () => {
|
||||||
it('returns grill_md when present', async () => {
|
it('returns grill_md when present', async () => {
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
m.idea.findFirst.mockResolvedValueOnce({
|
||||||
|
|
|
||||||
209
actions/ideas.ts
209
actions/ideas.ts
|
|
@ -459,6 +459,215 @@ export async function cancelIdeaJobAction(id: string): Promise<ActionResult> {
|
||||||
return { success: true }
|
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
|
// Helpers
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue