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 */}