From 5f410d3b1044a54fb66ad0d35183ad55b274d193 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:47:30 +0200 Subject: [PATCH] actions: ideas CRUD + grill_md/plan_md edit + download (M12 T-496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/actions/ideas-crud.test.ts | 244 +++++++++++++++++++++++ actions/ideas.ts | 287 +++++++++++++++++++++++++++ lib/rate-limit.ts | 6 + 3 files changed, 537 insertions(+) create mode 100644 __tests__/actions/ideas-crud.test.ts create mode 100644 actions/ideas.ts diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts new file mode 100644 index 0000000..6ceba0e --- /dev/null +++ b/__tests__/actions/ideas-crud.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSession } = vi.hoisted(() => ({ + mockSession: { userId: 'user-1', isDemo: false }, +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockImplementation(async () => mockSession), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' }, +})) +vi.mock('@/lib/idea-code-server', () => ({ + nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + idea: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + ideaLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { + createIdeaAction, + updateIdeaAction, + archiveIdeaAction, + deleteIdeaAction, + updateGrillMdAction, + updatePlanMdAction, + downloadIdeaMdAction, +} from '@/actions/ideas' + +type MockIdea = { idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType }; ideaLog: { create: ReturnType }; $transaction: ReturnType } +const m = prisma as unknown as MockIdea + +beforeEach(() => { + vi.clearAllMocks() + mockSession.userId = 'user-1' + mockSession.isDemo = false + // Default: $transaction passes its callback through with our mocked prisma + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: unknown) => unknown)(m) + } + return arg + }) +}) + +describe('createIdeaAction', () => { + it('happy path: creates DRAFT idea with auto-generated code', async () => { + m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' }) + + const r = await createIdeaAction({ title: 'Plant-watering reminder' }) + expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } }) + expect(m.idea.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + user_id: 'user-1', + code: 'IDEA-001', + title: 'Plant-watering reminder', + status: 'DRAFT', + }), + }), + ) + }) + + it('rejects unauthenticated', async () => { + mockSession.userId = '' + const r = await createIdeaAction({ title: 'x' }) + expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) + + it('rejects demo-user', async () => { + mockSession.isDemo = true + const r = await createIdeaAction({ title: 'x' }) + expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) + + it('rejects invalid title (zod 422)', async () => { + const r = await createIdeaAction({ title: ' ' }) + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) +}) + +describe('updateIdeaAction', () => { + it('happy: updates editable idea (DRAFT)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) + m.idea.update.mockResolvedValueOnce({}) + + const r = await updateIdeaAction('idea-1', { title: 'Updated' }) + expect(r).toEqual({ success: true }) + expect(m.idea.update).toHaveBeenCalledWith({ + where: { id: 'idea-1' }, + data: { title: 'Updated' }, + }) + }) + + it('blocks update on PLANNED (status-mismatch 422)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.update).not.toHaveBeenCalled() + }) + + it('blocks update during GRILLING', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' }) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 422 }) + }) + + it('returns 404 when idea belongs to another user', async () => { + m.idea.findFirst.mockResolvedValueOnce(null) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 404 }) + }) +}) + +describe('deleteIdeaAction', () => { + it('happy: deletes idea without pbi', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null }) + const r = await deleteIdeaAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } }) + }) + + it('blocks deletion when PBI is linked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' }) + const r = await deleteIdeaAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.delete).not.toHaveBeenCalled() + }) +}) + +describe('archiveIdeaAction', () => { + it('archives owned idea', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' }) + const r = await archiveIdeaAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.idea.update).toHaveBeenCalledWith({ + where: { id: 'idea-1' }, + data: { archived: true }, + }) + }) +}) + +describe('updateGrillMdAction', () => { + it('happy: updates grill_md in GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) + const r = await updateGrillMdAction('idea-1', '# Updated grill') + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('blocks in DRAFT', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) + const r = await updateGrillMdAction('idea-1', 'x') + expect(r).toMatchObject({ code: 422 }) + expect(m.$transaction).not.toHaveBeenCalled() + }) +}) + +describe('updatePlanMdAction', () => { + const VALID_PLAN = `--- +pbi: + title: Test + priority: 2 +stories: + - title: S1 + priority: 2 + tasks: + - title: T1 + priority: 2 +--- + +body +` + + it('happy: updates plan_md in PLAN_READY with valid yaml', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) + const r = await updatePlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + }) + + it('rejects invalid yaml (parse-fail 422 with details)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) + const r = await updatePlanMdAction('idea-1', '# no frontmatter') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + }) + + it('blocks in PLANNED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) + const r = await updatePlanMdAction('idea-1', VALID_PLAN) + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('downloadIdeaMdAction', () => { + it('returns grill_md when present', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: '# Idee\nscope', + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'grill') + expect(r).toMatchObject({ + success: true, + data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' }, + }) + }) + + it('404 when md not yet generated', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: null, + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'plan') + expect(r).toMatchObject({ code: 404 }) + }) + + it('demo MAY download (read-only operation)', async () => { + mockSession.isDemo = true + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: 'x', + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'grill') + expect(r).toMatchObject({ success: true }) + }) +}) diff --git a/actions/ideas.ts b/actions/ideas.ts new file mode 100644 index 0000000..ec05f74 --- /dev/null +++ b/actions/ideas.ts @@ -0,0 +1,287 @@ +'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(await cookies(), sessionOptions) +} + +// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts. +type ActionResult = + | { 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> { + 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 { + 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 { + return setArchived(id, true) +} + +export async function unarchiveIdeaAction(id: string): Promise { + return setArchived(id, false) +} + +async function setArchived(id: string, archived: boolean): 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 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 { + 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 { + 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 { + 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> { + 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 + +async function loadOwnedIdea( + id: string, + userId: string, + fields: S, +): Promise | 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 | null> +} + +// Re-export voor zustandshelp tijdens testing — geen runtime-import. +export const __test__ = { canTransition } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 8193fad..a1d5311 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -26,6 +26,12 @@ const CONFIGS: Record = { 'log-story': { windowMs: 60_000, max: 60 }, 'upload-avatar': { windowMs: 3_600_000, max: 20 }, 'answer-question': { windowMs: 60_000, max: 30 }, + + // M12 — Idea entity (zie docs/plans/M12-ideas.md) + 'create-idea': { windowMs: 60_000, max: 30 }, + 'edit-idea-md': { windowMs: 60_000, max: 60 }, // grill_md / plan_md edits + 'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers + 'materialize-idea': { windowMs: 60_000, max: 5 }, } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }