From 76c2efd27c4aa9cbda7f9089c6afbd5d5a8bc7f9 Mon Sep 17 00:00:00 2001
From: Janpeter Visser <30029041+madhura68@users.noreply.github.com>
Date: Wed, 13 May 2026 13:56:16 +0000
Subject: [PATCH] =?UTF-8?q?feat(ideas):=20upload-plan=20knop=20=E2=80=94?=
=?UTF-8?q?=20short-circuit=20van=20Make-Plan=20AI-flow=20(#197)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel
list als idea-detail). Klik → file picker → kies .md → server-side parse +
opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande
'Maak PBI' knop voor materialize.
Server (uploadPlanMdAction):
- Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY
- DRAFT → skip-grill: status gaat direct naar PLAN_READY
- PLAN_READY overschrijft het bestaande plan (consistent met
updatePlanMdAction, geen confirmation)
- Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd)
- Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan
nooit in de DB belandt)
- Empty / >100k chars → 422
- Schrijft IdeaLog NOTE met from_status + length
- Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde
patroon als updatePlanMdAction)
UI (idea-row-actions.tsx):
- Hidden
- FileReader → text → action
- Toast bij success + router.refresh()
- Blocked-tooltip in andere statussen
Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor:
happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks
(PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404.
Full suite groen: 849/849.
Co-authored-by: Claude Opus 4.7 (1M context)
---
__tests__/actions/ideas-crud.test.ts | 92 +++++++++++++++++++++++++++
actions/ideas.ts | 67 +++++++++++++++++++
components/ideas/idea-row-actions.tsx | 66 ++++++++++++++++++-
3 files changed, 224 insertions(+), 1 deletion(-)
diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts
index 5f4889c..a19c663 100644
--- a/__tests__/actions/ideas-crud.test.ts
+++ b/__tests__/actions/ideas-crud.test.ts
@@ -65,6 +65,7 @@ import {
deleteIdeaAction,
updateGrillMdAction,
updatePlanMdAction,
+ uploadPlanMdAction,
downloadIdeaMdAction,
startGrillJobAction,
startMakePlanJobAction,
@@ -251,6 +252,97 @@ body
})
})
+describe('uploadPlanMdAction', () => {
+ const VALID_PLAN = `---
+pbi:
+ title: Uploaded
+ priority: 2
+stories:
+ - title: S1
+ priority: 2
+ tasks:
+ - title: T1
+ priority: 2
+---
+
+body
+`
+
+ it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toEqual({ success: true })
+ expect(m.$transaction).toHaveBeenCalled()
+ const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined
+ expect(txnArg).toBeDefined()
+ // The first call in the transaction is the update — confirm status=PLAN_READY.
+ expect(m.idea.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }),
+ }),
+ )
+ })
+
+ it('happy: uploads from GRILLED', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toEqual({ success: true })
+ })
+
+ it('happy: overwrites existing plan from PLAN_READY', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toEqual({ success: true })
+ })
+
+ it('happy: uploads from PLAN_FAILED (retry)', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toEqual({ success: true })
+ })
+
+ it('rejects from PLANNED (already materialized)', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toMatchObject({ code: 422 })
+ expect(m.$transaction).not.toHaveBeenCalled()
+ })
+
+ it('rejects from GRILLING (job running)', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' })
+ const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
+ expect(r).toMatchObject({ code: 422 })
+ })
+
+ it('rejects empty markdown', async () => {
+ const r = await uploadPlanMdAction('idea-1', ' \n ')
+ expect(r).toMatchObject({ code: 422 })
+ // Should fail before touching DB
+ expect(m.idea.findFirst).not.toHaveBeenCalled()
+ })
+
+ it('rejects oversized markdown', async () => {
+ const huge = 'a'.repeat(100_001)
+ const r = await uploadPlanMdAction('idea-1', huge)
+ expect(r).toMatchObject({ code: 422 })
+ expect(m.idea.findFirst).not.toHaveBeenCalled()
+ })
+
+ it('rejects invalid yaml (parse-fail 422 with details)', async () => {
+ m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
+ const r = await uploadPlanMdAction('idea-1', '# no frontmatter')
+ expect(r).toMatchObject({ code: 422 })
+ expect((r as { details?: unknown }).details).toBeDefined()
+ expect(m.$transaction).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when idea not found', async () => {
+ m.idea.findFirst.mockResolvedValueOnce(null)
+ const r = await uploadPlanMdAction('nope', VALID_PLAN)
+ expect(r).toMatchObject({ code: 404 })
+ })
+})
+
describe('startGrillJobAction', () => {
const idea = {
id: 'idea-1',
diff --git a/actions/ideas.ts b/actions/ideas.ts
index bb5269e..94dfc4d 100644
--- a/actions/ideas.ts
+++ b/actions/ideas.ts
@@ -311,6 +311,73 @@ export async function updatePlanMdAction(
return { success: true }
}
+// ---------------------------------------------------------------------------
+// Upload — gebruiker plakt/uploadt zelf een plan.md in plaats van de Make-Plan
+// AI-flow. Skipt grill als gewenst. Status springt direct naar PLAN_READY.
+// Bij parse-failure: NIET opslaan (return 422), zodat een onparseerbaar plan
+// nooit in de DB belandt. Geen worker nodig — synchrone parser.
+
+const UPLOAD_PLAN_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'PLAN_FAILED', 'PLAN_READY']
+const MAX_PLAN_MD_LENGTH = 100_000
+
+export async function uploadPlanMdAction(
+ id: string,
+ markdown: string,
+): Promise {
+ 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('upload-idea-plan', session.userId)
+ if (limited) return limited
+
+ if (typeof markdown !== 'string' || markdown.trim().length === 0) {
+ return { error: 'plan_md is leeg', code: 422 }
+ }
+ if (markdown.length > MAX_PLAN_MD_LENGTH) {
+ return {
+ error: `plan_md is te groot (${markdown.length} > ${MAX_PLAN_MD_LENGTH} chars)`,
+ code: 422,
+ }
+ }
+
+ const idea = await loadOwnedIdea(id, session.userId, ['status'])
+ if (!idea) return { error: 'Idee niet gevonden', code: 404 }
+ if (!UPLOAD_PLAN_FROM.includes(idea.status)) {
+ return {
+ error: `Upload plan alleen toegestaan vanuit ${UPLOAD_PLAN_FROM.join('/')} (huidige status: ${idea.status})`,
+ code: 422,
+ }
+ }
+
+ const parsed = parsePlanMd(markdown)
+ if (!parsed.ok) {
+ return {
+ error: 'plan_md is niet parseerbaar',
+ code: 422,
+ details: parsed.errors,
+ }
+ }
+
+ await prisma.$transaction([
+ prisma.idea.update({
+ where: { id },
+ data: { plan_md: markdown, status: 'PLAN_READY' },
+ }),
+ prisma.ideaLog.create({
+ data: {
+ idea_id: id,
+ type: 'NOTE',
+ content: 'User-uploaded plan_md',
+ metadata: { length: markdown.length, from_status: idea.status },
+ },
+ }),
+ ])
+
+ revalidatePath(`/ideas/${id}`)
+ return { success: true }
+}
+
// ---------------------------------------------------------------------------
// Download — geeft de raw markdown terug; UI bouwt een Blob.
diff --git a/components/ideas/idea-row-actions.tsx b/components/ideas/idea-row-actions.tsx
index 1a9350d..37b8226 100644
--- a/components/ideas/idea-row-actions.tsx
+++ b/components/ideas/idea-row-actions.tsx
@@ -14,7 +14,7 @@
// Demo-tooltip om elke muteer-knop. connectedWorkers wordt gelezen uit
// useSoloStore (M12 grill-keuze 16 — geen lift voor v1).
-import { useTransition } from 'react'
+import { useRef, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import {
Archive,
@@ -24,6 +24,7 @@ import {
Layers,
RotateCw,
Sparkles,
+ Upload,
} from 'lucide-react'
import { toast } from 'sonner'
@@ -41,6 +42,7 @@ import {
startGrillJobAction,
startMakePlanJobAction,
materializeIdeaPlanAction,
+ uploadPlanMdAction,
} from '@/actions/ideas'
import type { IdeaDto } from '@/lib/idea-dto'
@@ -90,6 +92,48 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps)
})()
const materializeEnabled = !materializeBlockedReason && !isDemo && !pending
+ // ---- Upload plan ----
+ // Synchrone server-action (parse + DB), geen worker nodig. Mag vanuit
+ // DRAFT (skip-grill), GRILLED, PLAN_FAILED of PLAN_READY (overschrijft het
+ // bestaande plan zonder confirmation — consistent met updatePlanMdAction).
+ const uploadPlanAllowedStates = ['draft', 'grilled', 'plan_failed', 'plan_ready']
+ const uploadPlanBlockedReason = (() => {
+ if (uploadPlanAllowedStates.includes(status)) return null
+ if (status === 'grilling' || status === 'planning') return 'Job loopt al'
+ if (status === 'planned') return 'Idee is al gepland'
+ return null
+ })()
+ const uploadPlanEnabled = !uploadPlanBlockedReason && !isDemo && !pending
+ const fileInputRef = useRef(null)
+
+ function handleUploadPlanClick() {
+ fileInputRef.current?.click()
+ }
+
+ function handlePlanFileChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]
+ // Reset zodat dezelfde file na een fout opnieuw gekozen kan worden.
+ e.target.value = ''
+ if (!file) return
+
+ startTransition(async () => {
+ let text: string
+ try {
+ text = await file.text()
+ } catch {
+ toast.error('Kon bestand niet lezen')
+ return
+ }
+ const r = await uploadPlanMdAction(idea.id, text)
+ if ('error' in r) {
+ toast.error(r.error)
+ return
+ }
+ toast.success('Plan geüpload — idee staat nu op PLAN_READY')
+ router.refresh()
+ })
+ }
+
// ---- Failed-states tonen "Probeer opnieuw" ----
const isFailedState = status === 'grill_failed' || status === 'plan_failed'
@@ -172,6 +216,26 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps)
/>
+ {/* Upload plan — synchrone short-circuit van de Make-Plan AI-flow */}
+
+ }
+ enabled={uploadPlanEnabled}
+ blockedReason={uploadPlanBlockedReason}
+ isDemo={isDemo}
+ onClick={handleUploadPlanClick}
+ />
+
+
+
{/* Materialiseer */}