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>
This commit is contained in:
parent
4d2e4b0b4b
commit
5f410d3b10
3 changed files with 537 additions and 0 deletions
244
__tests__/actions/ideas-crud.test.ts
Normal file
244
__tests__/actions/ideas-crud.test.ts
Normal file
|
|
@ -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<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }; ideaLog: { create: ReturnType<typeof vi.fn> }; $transaction: ReturnType<typeof vi.fn> }
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
287
actions/ideas.ts
Normal file
287
actions/ideas.ts
Normal file
|
|
@ -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<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 }
|
||||||
|
|
@ -26,6 +26,12 @@ const CONFIGS: Record<string, RateLimitConfig> = {
|
||||||
'log-story': { windowMs: 60_000, max: 60 },
|
'log-story': { windowMs: 60_000, max: 60 },
|
||||||
'upload-avatar': { windowMs: 3_600_000, max: 20 },
|
'upload-avatar': { windowMs: 3_600_000, max: 20 },
|
||||||
'answer-question': { windowMs: 60_000, max: 30 },
|
'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 }
|
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue