Scrum4Me/actions/ideas.ts
Madhura68 5f410d3b10 actions: ideas CRUD + grill_md/plan_md edit + download (M12 T-496)
actions/ideas.ts (strikt user_id-only, geen productAccessFilter):
- createIdeaAction(input) — atomic nextIdeaCode + idea.create in $transaction
- updateIdeaAction(id, input) — guards on isIdeaEditable
- archiveIdeaAction / unarchiveIdeaAction
- deleteIdeaAction — refuses when pbi_id linked
- updateGrillMdAction — only in GRILLED|PLAN_READY; logs IdeaLog{NOTE}
- updatePlanMdAction — only in PLAN_READY; runs parsePlanMd; 422 with details on fail
- downloadIdeaMdAction — read-only, demo allowed

Added rate-limit configs: create-idea, edit-idea-md, start-idea-job,
materialize-idea.

Tests: 19 cases covering auth (401), demo (403), zod (422), status guards
(422), 404 cross-user-scope, plan-md parse-fail with details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:47:30 +02:00

287 lines
9.4 KiB
TypeScript

'use server'
// Server-actions voor de Idea-entity (M12). Volgt docs/patterns/server-action.md:
// auth → demo-guard → rate-limit → zod-validate → user_id-scope-check → write
// → revalidatePath. Idee is strikt user_id-only (zie M12 grill-keuze 8) — er
// is GEEN productAccessFilter; idee is privé voor de eigenaar, ook als-ie
// gekoppeld is aan een team-product.
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { enforceUserRateLimit } from '@/lib/rate-limit'
import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea'
import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } from '@/lib/idea-status'
import { nextIdeaCode } from '@/lib/idea-code-server'
import { parsePlanMd } from '@/lib/idea-plan-parser'
import type { Idea } from '@prisma/client'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts.
type ActionResult<T = void> =
| { success: true; data?: T }
| { error: string; code?: number; details?: unknown }
// ---------------------------------------------------------------------------
// CRUD
export async function createIdeaAction(input: {
title: string
description?: string | null
product_id?: string | null
}): Promise<ActionResult<{ id: string; code: 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('create-idea', session.userId)
if (limited) return limited
const parsed = ideaCreateSchema.safeParse(input)
if (!parsed.success) {
return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors }
}
const userId = session.userId
// Atomair: code + create in dezelfde transactie zodat een crash tussenin geen
// counter-gat veroorzaakt zonder bijbehorend idee.
const idea = await prisma.$transaction(async (tx) => {
const code = await nextIdeaCode(userId, tx)
return tx.idea.create({
data: {
user_id: userId,
product_id: parsed.data.product_id ?? null,
code,
title: parsed.data.title,
description: parsed.data.description ?? null,
status: 'DRAFT',
},
select: { id: true, code: true },
})
})
revalidatePath('/ideas')
return { success: true, data: idea }
}
export async function updateIdeaAction(
id: string,
input: { title?: string; description?: string | null; product_id?: string | null },
): 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 parsed = ideaUpdateSchema.safeParse(input)
if (!parsed.success) {
return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors }
}
const idea = await prisma.idea.findFirst({
where: { id, user_id: session.userId },
select: { id: true, status: true },
})
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (!isIdeaEditable(idea.status)) {
return { error: `Idee is niet bewerkbaar in status ${idea.status}`, code: 422 }
}
await prisma.idea.update({
where: { id },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}),
},
})
revalidatePath('/ideas')
revalidatePath(`/ideas/${id}`)
return { success: true }
}
export async function archiveIdeaAction(id: string): Promise<ActionResult> {
return setArchived(id, true)
}
export async function unarchiveIdeaAction(id: string): Promise<ActionResult> {
return setArchived(id, false)
}
async function setArchived(id: string, archived: boolean): 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 found = await prisma.idea.findFirst({
where: { id, user_id: session.userId },
select: { id: true },
})
if (!found) return { error: 'Idee niet gevonden', code: 404 }
await prisma.idea.update({ where: { id }, data: { archived } })
revalidatePath('/ideas')
revalidatePath(`/ideas/${id}`)
return { success: true }
}
export async function deleteIdeaAction(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, pbi_id: true },
})
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (idea.pbi_id !== null) {
return {
error: 'Verwijder eerst de gekoppelde PBI; daarna kun je het idee weggooien.',
code: 422,
}
}
await prisma.idea.delete({ where: { id } })
revalidatePath('/ideas')
return { success: true }
}
// ---------------------------------------------------------------------------
// Markdown-edits (grill_md & plan_md handmatig fine-tunen)
export async function updateGrillMdAction(
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('edit-idea-md', session.userId)
if (limited) return limited
const idea = await loadOwnedIdea(id, session.userId, ['status'])
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (!isGrillMdEditable(idea.status)) {
return {
error: `grill_md alleen bewerkbaar in GRILLED of PLAN_READY (huidige status: ${idea.status})`,
code: 422,
}
}
await prisma.$transaction([
prisma.idea.update({ where: { id }, data: { grill_md: markdown } }),
prisma.ideaLog.create({
data: {
idea_id: id,
type: 'NOTE',
content: 'User-edited grill_md',
metadata: { length: markdown.length },
},
}),
])
revalidatePath(`/ideas/${id}`)
return { success: true }
}
export async function updatePlanMdAction(
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('edit-idea-md', session.userId)
if (limited) return limited
const idea = await loadOwnedIdea(id, session.userId, ['status'])
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (!isPlanMdEditable(idea.status)) {
return {
error: `plan_md alleen bewerkbaar in PLAN_READY (huidige status: ${idea.status})`,
code: 422,
}
}
// Validate frontmatter — voorkomt dat een onparseerbaar plan in de DB belandt
// en bij Materialiseer pas faalt.
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 } }),
prisma.ideaLog.create({
data: {
idea_id: id,
type: 'NOTE',
content: 'User-edited plan_md',
metadata: { length: markdown.length },
},
}),
])
revalidatePath(`/ideas/${id}`)
return { success: true }
}
// ---------------------------------------------------------------------------
// Download — geeft de raw markdown terug; UI bouwt een Blob.
export async function downloadIdeaMdAction(
id: string,
kind: 'grill' | 'plan',
): Promise<ActionResult<{ filename: string; markdown: string }>> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
// Demo MAG downloaden — read-only operatie, geen mutatie.
const idea = await loadOwnedIdea(id, session.userId, ['code', 'grill_md', 'plan_md'])
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
const md = kind === 'grill' ? idea.grill_md : idea.plan_md
if (!md) {
return { error: `Geen ${kind}_md beschikbaar voor dit idee`, code: 404 }
}
return {
success: true,
data: { filename: `${idea.code}-${kind}.md`, markdown: md },
}
}
// ---------------------------------------------------------------------------
// Helpers
type IdeaSelect = Array<keyof Idea>
async function loadOwnedIdea<S extends IdeaSelect>(
id: string,
userId: string,
fields: S,
): Promise<Pick<Idea, S[number]> | null> {
const select = Object.fromEntries(fields.map((f) => [f, true])) as {
[K in S[number]]: true
}
return prisma.idea.findFirst({
where: { id, user_id: userId },
select,
}) as Promise<Pick<Idea, S[number]> | null>
}
// Re-export voor zustandshelp tijdens testing — geen runtime-import.
export const __test__ = { canTransition }