feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow
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 <input type=file accept=".md,.markdown,text/markdown,text/plain"> - 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) <noreply@anthropic.com>
This commit is contained in:
parent
551550791e
commit
9f8d41518a
3 changed files with 224 additions and 1 deletions
|
|
@ -65,6 +65,7 @@ import {
|
||||||
deleteIdeaAction,
|
deleteIdeaAction,
|
||||||
updateGrillMdAction,
|
updateGrillMdAction,
|
||||||
updatePlanMdAction,
|
updatePlanMdAction,
|
||||||
|
uploadPlanMdAction,
|
||||||
downloadIdeaMdAction,
|
downloadIdeaMdAction,
|
||||||
startGrillJobAction,
|
startGrillJobAction,
|
||||||
startMakePlanJobAction,
|
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', () => {
|
describe('startGrillJobAction', () => {
|
||||||
const idea = {
|
const idea = {
|
||||||
id: 'idea-1',
|
id: 'idea-1',
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,73 @@ export async function updatePlanMdAction(
|
||||||
return { success: true }
|
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<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 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.
|
// Download — geeft de raw markdown terug; UI bouwt een Blob.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
// Demo-tooltip om elke muteer-knop. connectedWorkers wordt gelezen uit
|
// Demo-tooltip om elke muteer-knop. connectedWorkers wordt gelezen uit
|
||||||
// useSoloStore (M12 grill-keuze 16 — geen lift voor v1).
|
// useSoloStore (M12 grill-keuze 16 — geen lift voor v1).
|
||||||
|
|
||||||
import { useTransition } from 'react'
|
import { useRef, useTransition } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ import {
|
||||||
startGrillJobAction,
|
startGrillJobAction,
|
||||||
startMakePlanJobAction,
|
startMakePlanJobAction,
|
||||||
materializeIdeaPlanAction,
|
materializeIdeaPlanAction,
|
||||||
|
uploadPlanMdAction,
|
||||||
} from '@/actions/ideas'
|
} from '@/actions/ideas'
|
||||||
import type { IdeaDto } from '@/lib/idea-dto'
|
import type { IdeaDto } from '@/lib/idea-dto'
|
||||||
|
|
||||||
|
|
@ -90,6 +92,48 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps)
|
||||||
})()
|
})()
|
||||||
const materializeEnabled = !materializeBlockedReason && !isDemo && !pending
|
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<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleUploadPlanClick() {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlanFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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" ----
|
// ---- Failed-states tonen "Probeer opnieuw" ----
|
||||||
const isFailedState = status === 'grill_failed' || status === 'plan_failed'
|
const isFailedState = status === 'grill_failed' || status === 'plan_failed'
|
||||||
|
|
||||||
|
|
@ -172,6 +216,26 @@ export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps)
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Upload plan — synchrone short-circuit van de Make-Plan AI-flow */}
|
||||||
|
<span data-debug-id="idea-row-actions__upload-plan">
|
||||||
|
<ActionButton
|
||||||
|
label="Upload plan"
|
||||||
|
icon={<Upload className="size-3.5" />}
|
||||||
|
enabled={uploadPlanEnabled}
|
||||||
|
blockedReason={uploadPlanBlockedReason}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onClick={handleUploadPlanClick}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,.markdown,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handlePlanFileChange}
|
||||||
|
aria-label="Plan-markdown bestand kiezen"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Materialiseer */}
|
{/* Materialiseer */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Maak PBI"
|
label="Maak PBI"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue