From 5cb3abbd3d9ab31bec06d4ea418097d06f69e783 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 15:27:43 +0200 Subject: [PATCH] Sprint: Idee regril mogelijkheid (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ST-cmovhveef): add PLANNED to GRILL_TRIGGERABLE_FROM and PLANNED→GRILLING transition - GRILL_TRIGGERABLE_FROM now includes 'PLANNED' in actions/ideas.ts - ALLOWED_TRANSITIONS PLANNED entry extended with 'GRILLING' in lib/idea-status.ts - Updated canTransition test to reflect the new re-grill-from-PLANNED behavior * test(ST-cmovhvef3): add exhaustive re-grill canTransition test covering PLANNED Adds a loop test that asserts canTransition(status, 'GRILLING') for all statuses in GRILL_TRIGGERABLE_FROM that support the transition, explicitly documenting PLANNED as a valid re-grill entry point. * feat(ST-cmovhvegf): add existingPbi pre-check in materializeIdeaPlanAction - Adds options.allowAlongside parameter to control behaviour when a PBI with executed tasks already exists. - Returns 409 PBI_HAS_ACTIVE_TASKS: when tasks are DONE/IN_PROGRESS and allowAlongside is not set. - Auto-deletes the old PBI inside the transaction when no tasks have been executed (atomic replace). - Alongside mode (allowAlongside=true) skips deletion and creates a new PBI. * test(ST-cmovhveh3): add pre-check integration tests for materializeIdeaPlanAction Three new scenarios in ideas-crud.test.ts: - auto-vervang: old PBI deleted in transaction when no executed tasks - conflict-409: returns PBI_HAS_ACTIVE_TASKS: with active tasks - alongside: skips delete and creates new PBI when allowAlongside=true Also adds task.count, pbi.findUnique, pbi.delete to prisma mock. * feat(ST-cmovhveih): remove PLANNED-blokkering in idea-row-actions, add inline Bekijk-PBI button - Removed grillBlockedReason guard for status==='planned', enabling re-grill from PLANNED - Removed the early return for PLANNED that hid all standard buttons - Added conditional 'Bekijk ' button at the start of the standard button set, visible only when status==='planned' and PBI + product_id are present * feat(ST-cmovhvej7): add PBI_HAS_ACTIVE_TASKS alongside-dialoog in materialize handler When materializeIdeaPlanAction returns code 409 with PBI_HAS_ACTIVE_TASKS:, a confirm dialog offers the user a choice: create new PBI alongside the existing one or cancel. Alongside=true retries the action; cancel leaves the idea in PLAN_READY. --- __tests__/actions/ideas-crud.test.ts | 70 ++++++++++++++++++++++++++- __tests__/lib/idea-status.test.ts | 12 ++++- actions/ideas.ts | 33 ++++++++++++- components/ideas/idea-row-actions.tsx | 47 ++++++++---------- lib/idea-status.ts | 2 +- 5 files changed, 130 insertions(+), 34 deletions(-) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 525c56f..6c038fc 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -35,7 +35,9 @@ vi.mock('@/lib/prisma', () => ({ pbi: { findFirst: vi.fn(), findMany: vi.fn(), + findUnique: vi.fn(), create: vi.fn(), + delete: vi.fn(), }, story: { findMany: vi.fn(), @@ -44,6 +46,7 @@ vi.mock('@/lib/prisma', () => ({ task: { findMany: vi.fn(), create: vi.fn(), + count: vi.fn(), }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), @@ -71,9 +74,9 @@ type MockIdea = { ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } - pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; findUnique: ReturnType; create: ReturnType; delete: ReturnType } story: { findMany: ReturnType; create: ReturnType } - task: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType; count: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } @@ -476,6 +479,69 @@ body }) }) +describe('materializeIdeaPlanAction — existing PBI pre-check', () => { + const VALID_PLAN = `--- +pbi: + title: New PBI + priority: 2 +stories: + - title: Story A + priority: 2 + tasks: + - title: Task A1 + priority: 2 +--- + +body +` + + beforeEach(() => { + // Use a distinct userId to avoid sharing the rate-limit bucket with the + // materializeIdeaPlanAction describe block above. + mockSession.userId = 'user-precheck' + m.idea.findFirst.mockResolvedValue({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: VALID_PLAN, + pbi_id: 'old-pbi', + }) + m.pbi.findMany.mockResolvedValue([]) + m.story.findMany.mockResolvedValue([]) + m.task.findMany.mockResolvedValue([]) + m.pbi.findFirst.mockResolvedValue(null) + m.pbi.findUnique.mockResolvedValue({ code: 'PBI-X' }) + m.pbi.create.mockResolvedValue({ id: 'pbi-new', code: 'PBI-2' }) + m.pbi.delete.mockResolvedValue({}) + m.story.create.mockResolvedValue({ id: 's-1' }) + m.task.create.mockResolvedValue({ id: 't-1' }) + }) + + it('auto-vervang: deletes old PBI in transaction when no tasks executed', async () => { + m.task.count.mockResolvedValueOnce(0) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) + expect(m.pbi.delete).toHaveBeenCalledWith({ where: { id: 'old-pbi' } }) + expect(m.pbi.create).toHaveBeenCalledTimes(1) + }) + + it('conflict-409: returns PBI_HAS_ACTIVE_TASKS when executed tasks exist', async () => { + m.task.count.mockResolvedValueOnce(1) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 409, error: 'PBI_HAS_ACTIVE_TASKS:PBI-X' }) + expect(m.pbi.create).not.toHaveBeenCalled() + expect(m.pbi.delete).not.toHaveBeenCalled() + }) + + it('alongside: skips old PBI delete and creates new PBI when allowAlongside=true', async () => { + m.task.count.mockResolvedValueOnce(1) + const r = await materializeIdeaPlanAction('idea-1', { allowAlongside: true }) + expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) + expect(m.pbi.delete).not.toHaveBeenCalled() + expect(m.pbi.create).toHaveBeenCalledTimes(1) + }) +}) + describe('relinkIdeaPlanAction', () => { it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => { m.idea.findFirst.mockResolvedValueOnce({ diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts index 0dfc3dc..9bedd32 100644 --- a/__tests__/lib/idea-status.test.ts +++ b/__tests__/lib/idea-status.test.ts @@ -53,12 +53,20 @@ describe('canTransition', () => { expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true) }) - it('only allows PLANNED → PLAN_READY (relink path)', () => { + it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => { expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true) - expect(canTransition('PLANNED', 'GRILLING')).toBe(false) + expect(canTransition('PLANNED', 'GRILLING')).toBe(true) expect(canTransition('PLANNED', 'DRAFT')).toBe(false) }) + it('canTransition to GRILLING from all statuses that allow re-grill', () => { + // DRAFT, GRILLED, GRILL_FAILED, PLANNED are in GRILL_TRIGGERABLE_FROM and support the transition. + const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLANNED'] as const + for (const status of regrill) { + expect(canTransition(status, 'GRILLING')).toBe(true) + } + }) + it('rejects invalid jumps', () => { expect(canTransition('DRAFT', 'PLANNED')).toBe(false) expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false) diff --git a/actions/ideas.ts b/actions/ideas.ts index dbfa806..859457b 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -338,7 +338,7 @@ export async function downloadIdeaMdAction( // --------------------------------------------------------------------------- // Job-triggers (Grill Me / Make Plan / Cancel) -const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY'] +const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] export async function startGrillJobAction(id: string): Promise> { @@ -540,6 +540,7 @@ function nextNumber(existing: (string | null)[], re: RegExp): number { export async function materializeIdeaPlanAction( id: string, + options?: { allowAlongside?: boolean }, ): Promise> { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd', code: 401 } @@ -550,7 +551,7 @@ export async function materializeIdeaPlanAction( const idea = await prisma.idea.findFirst({ where: { id, user_id: session.userId }, - select: { id: true, status: true, product_id: true, plan_md: true }, + select: { id: true, status: true, product_id: true, plan_md: true, pbi_id: true }, }) if (!idea) return { error: 'Idee niet gevonden', code: 404 } if (idea.status !== 'PLAN_READY') { @@ -574,8 +575,36 @@ export async function materializeIdeaPlanAction( const productId = idea.product_id const plan = parsed.plan + let oldPbiId: string | null = null + if (idea.pbi_id) { + const executedCount = await prisma.task.count({ + where: { + story: { pbi_id: idea.pbi_id }, + status: { in: ['DONE', 'IN_PROGRESS'] }, + }, + }) + if (executedCount > 0 && !options?.allowAlongside) { + const existingPbi = await prisma.pbi.findUnique({ + where: { id: idea.pbi_id }, + select: { code: true }, + }) + return { + error: `PBI_HAS_ACTIVE_TASKS:${existingPbi?.code ?? idea.pbi_id}`, + code: 409, + } + } + if (executedCount === 0) { + oldPbiId = idea.pbi_id + } + // executedCount > 0 && allowAlongside: doorgaan zonder delete + } + try { const result = await prisma.$transaction(async (tx) => { + if (oldPbiId) { + await tx.pbi.delete({ where: { id: oldPbiId } }) + } + // 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([ diff --git a/components/ideas/idea-row-actions.tsx b/components/ideas/idea-row-actions.tsx index 3ac7cc1..769a6cd 100644 --- a/components/ideas/idea-row-actions.tsx +++ b/components/ideas/idea-row-actions.tsx @@ -61,7 +61,6 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) // ---- Grill Me ---- const grillBlockedReason = (() => { if (status === 'grilling' || status === 'planning') return 'Job loopt al' - if (status === 'planned') return 'Idee is gepland — open de PBI' if (!hasProductWithRepo) return 'Idee heeft een product met repo nodig' if (!workerOk) return 'Geen Claude-worker actief' return null @@ -108,15 +107,24 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) function handleMaterialize() { if (!confirm('Plan materialiseren? Dit maakt PBI + stories + taken aan.')) return startTransition(async () => { - const r = await materializeIdeaPlanAction(idea.id) + let r = await materializeIdeaPlanAction(idea.id) + + if ('error' in r && r.code === 409 && r.error.startsWith('PBI_HAS_ACTIVE_TASKS:')) { + const pbiCode = r.error.split(':')[1] + const alongside = confirm( + `De bestaande PBI (${pbiCode}) heeft uitgevoerde taken.\n` + + `OK = nieuwe PBI naast bestaande aanmaken.\n` + + `Annuleren = stoppen.` + ) + if (!alongside) return + r = await materializeIdeaPlanAction(idea.id, { allowAlongside: true }) + } + if ('error' in r) { toast.error(r.error) return } toast.success(`Gematerialiseerd als ${r.data?.pbi_code}`) - // Navigeer naar de product-backlog. Anchor-scrolling per-PBI bestaat - // (nog) niet in pbi-list, dus gewoon naar de overview-pagina; de nieuwe - // PBI is de meest recente. if (idea.product_id) { router.push(`/products/${idea.product_id}`) } else { @@ -125,35 +133,20 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) }) } - // PLANNED-state: kortere variant met "Bekijk PBI"-link - if (status === 'planned' && idea.pbi && idea.product_id) { - return ( -
+ return ( +
+ {/* Bekijk PBI — alleen zichtbaar in PLANNED */} + {status === 'planned' && idea.pbi && idea.product_id && ( - -
- ) - } + )} - return ( -
{/* Grill Me */} > = { PLANNING: ['PLAN_READY', 'PLAN_FAILED'], PLAN_FAILED: ['PLANNING', 'GRILLED'], PLAN_READY: ['PLANNING', 'PLANNED'], - PLANNED: ['PLAN_READY'], // alleen via relinkIdeaPlanAction (PBI deleted) + PLANNED: ['PLAN_READY', 'GRILLING'], // PLAN_READY via relinkIdeaPlanAction; GRILLING via startGrillJobAction } export function canTransition(from: IdeaStatus, to: IdeaStatus): boolean {