diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts
new file mode 100644
index 0000000..1514f5d
--- /dev/null
+++ b/__tests__/lib/idea-schemas.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest'
+
+import {
+ ideaCreateSchema,
+ ideaUpdateSchema,
+ ideaPlanMdFrontmatterSchema,
+} from '@/lib/schemas/idea'
+
+describe('ideaCreateSchema', () => {
+ it('accepts minimal valid input', () => {
+ const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' })
+ expect(r.success).toBe(true)
+ })
+
+ it('trims and enforces non-empty title', () => {
+ const r = ideaCreateSchema.safeParse({ title: ' ' })
+ expect(r.success).toBe(false)
+ })
+
+ it('rejects oversized title and description', () => {
+ expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false)
+ expect(
+ ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success,
+ ).toBe(false)
+ })
+
+ it('accepts cuid-like product_id', () => {
+ const r = ideaCreateSchema.safeParse({
+ title: 'Idee',
+ product_id: 'cmohrysyj0000rd17clnjy4tc',
+ })
+ expect(r.success).toBe(true)
+ })
+
+ it('rejects non-cuid product_id', () => {
+ const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' })
+ expect(r.success).toBe(false)
+ })
+})
+
+describe('ideaUpdateSchema', () => {
+ it('allows empty object (no-op update)', () => {
+ expect(ideaUpdateSchema.safeParse({}).success).toBe(true)
+ })
+
+ it('allows partial title update', () => {
+ expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true)
+ })
+})
+
+describe('ideaPlanMdFrontmatterSchema', () => {
+ const validPlan = {
+ pbi: { title: 'Test PBI', priority: 2 },
+ stories: [
+ {
+ title: 'Eerste flow',
+ priority: 2,
+ tasks: [
+ { title: 'Setup', priority: 2, implementation_plan: '1. Doe X' },
+ ],
+ },
+ ],
+ }
+
+ it('accepts a minimal valid plan', () => {
+ expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true)
+ })
+
+ it('requires at least one story', () => {
+ const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] })
+ expect(r.success).toBe(false)
+ })
+
+ it('requires at least one task per story', () => {
+ const r = ideaPlanMdFrontmatterSchema.safeParse({
+ ...validPlan,
+ stories: [{ ...validPlan.stories[0], tasks: [] }],
+ })
+ expect(r.success).toBe(false)
+ })
+
+ it('validates priority bounds 1-4', () => {
+ expect(
+ ideaPlanMdFrontmatterSchema.safeParse({
+ ...validPlan,
+ pbi: { ...validPlan.pbi, priority: 5 },
+ }).success,
+ ).toBe(false)
+ expect(
+ ideaPlanMdFrontmatterSchema.safeParse({
+ ...validPlan,
+ pbi: { ...validPlan.pbi, priority: 0 },
+ }).success,
+ ).toBe(false)
+ })
+
+ it('accepts optional verify_required + verify_only', () => {
+ const r = ideaPlanMdFrontmatterSchema.safeParse({
+ ...validPlan,
+ stories: [
+ {
+ ...validPlan.stories[0],
+ tasks: [
+ {
+ title: 'Verify-only task',
+ priority: 2,
+ verify_required: 'ALIGNED_OR_PARTIAL',
+ verify_only: true,
+ },
+ ],
+ },
+ ],
+ })
+ expect(r.success).toBe(true)
+ })
+
+ it('rejects invalid verify_required enum', () => {
+ const r = ideaPlanMdFrontmatterSchema.safeParse({
+ ...validPlan,
+ stories: [
+ {
+ ...validPlan.stories[0],
+ tasks: [
+ { title: 't', priority: 2, verify_required: 'INVALID' },
+ ],
+ },
+ ],
+ })
+ expect(r.success).toBe(false)
+ })
+})
diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts
new file mode 100644
index 0000000..0dfc3dc
--- /dev/null
+++ b/__tests__/lib/idea-status.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect } from 'vitest'
+
+import {
+ ideaStatusToApi,
+ ideaStatusFromApi,
+ canTransition,
+ isIdeaEditable,
+ isGrillMdEditable,
+ isPlanMdEditable,
+ IDEA_STATUS_API_VALUES,
+} from '@/lib/idea-status'
+
+describe('idea-status mappers', () => {
+ it('round-trips every API value', () => {
+ for (const api of IDEA_STATUS_API_VALUES) {
+ const db = ideaStatusFromApi(api)
+ expect(db).not.toBeNull()
+ expect(ideaStatusToApi(db!)).toBe(api)
+ }
+ })
+
+ it('returns null for invalid input', () => {
+ expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull()
+ })
+
+ it('is case-insensitive on the API side', () => {
+ expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY')
+ expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY')
+ })
+})
+
+describe('canTransition', () => {
+ it('allows valid forward transitions', () => {
+ expect(canTransition('DRAFT', 'GRILLING')).toBe(true)
+ expect(canTransition('GRILLING', 'GRILLED')).toBe(true)
+ expect(canTransition('GRILLED', 'PLANNING')).toBe(true)
+ expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true)
+ expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true)
+ })
+
+ it('allows re-grill from GRILLED and PLAN_READY-ish states', () => {
+ expect(canTransition('GRILLED', 'GRILLING')).toBe(true)
+ expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true)
+ })
+
+ it('allows fail-side transitions', () => {
+ expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true)
+ expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true)
+ })
+
+ it('allows recovery from failed states', () => {
+ expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true)
+ expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true)
+ })
+
+ it('only allows PLANNED โ PLAN_READY (relink path)', () => {
+ expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true)
+ expect(canTransition('PLANNED', 'GRILLING')).toBe(false)
+ expect(canTransition('PLANNED', 'DRAFT')).toBe(false)
+ })
+
+ it('rejects invalid jumps', () => {
+ expect(canTransition('DRAFT', 'PLANNED')).toBe(false)
+ expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false)
+ expect(canTransition('GRILLING', 'PLANNED')).toBe(false)
+ })
+})
+
+describe('isIdeaEditable', () => {
+ it('allows edit in non-running, non-PLANNED states', () => {
+ expect(isIdeaEditable('DRAFT')).toBe(true)
+ expect(isIdeaEditable('GRILLED')).toBe(true)
+ expect(isIdeaEditable('GRILL_FAILED')).toBe(true)
+ expect(isIdeaEditable('PLAN_FAILED')).toBe(true)
+ expect(isIdeaEditable('PLAN_READY')).toBe(true)
+ })
+
+ it('blocks edit while a job is running or after PLANNED', () => {
+ expect(isIdeaEditable('GRILLING')).toBe(false)
+ expect(isIdeaEditable('PLANNING')).toBe(false)
+ expect(isIdeaEditable('PLANNED')).toBe(false)
+ })
+})
+
+describe('isGrillMdEditable / isPlanMdEditable', () => {
+ it('grill_md only editable in GRILLED or PLAN_READY', () => {
+ expect(isGrillMdEditable('GRILLED')).toBe(true)
+ expect(isGrillMdEditable('PLAN_READY')).toBe(true)
+ expect(isGrillMdEditable('DRAFT')).toBe(false)
+ expect(isGrillMdEditable('PLANNED')).toBe(false)
+ })
+
+ it('plan_md only editable in PLAN_READY', () => {
+ expect(isPlanMdEditable('PLAN_READY')).toBe(true)
+ expect(isPlanMdEditable('GRILLED')).toBe(false)
+ expect(isPlanMdEditable('PLAN_FAILED')).toBe(false)
+ expect(isPlanMdEditable('PLANNED')).toBe(false)
+ })
+})
diff --git a/docs/erd.svg b/docs/erd.svg
index 12b3637..b31ab45 100644
--- a/docs/erd.svg
+++ b/docs/erd.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/lib/idea-status.ts b/lib/idea-status.ts
new file mode 100644
index 0000000..c972a38
--- /dev/null
+++ b/lib/idea-status.ts
@@ -0,0 +1,85 @@
+// Bidirectionele case-mapper voor IdeaStatus + transitie-guard helper.
+// DB houdt UPPER_SNAKE; API exposeert lowercase.
+// Patroon volgt lib/task-status.ts.
+
+import type { IdeaStatus } from '@prisma/client'
+
+const IDEA_DB_TO_API = {
+ DRAFT: 'draft',
+ GRILLING: 'grilling',
+ GRILL_FAILED: 'grill_failed',
+ GRILLED: 'grilled',
+ PLANNING: 'planning',
+ PLAN_FAILED: 'plan_failed',
+ PLAN_READY: 'plan_ready',
+ PLANNED: 'planned',
+} as const satisfies Record
+
+const IDEA_API_TO_DB: Record = {
+ draft: 'DRAFT',
+ grilling: 'GRILLING',
+ grill_failed: 'GRILL_FAILED',
+ grilled: 'GRILLED',
+ planning: 'PLANNING',
+ plan_failed: 'PLAN_FAILED',
+ plan_ready: 'PLAN_READY',
+ planned: 'PLANNED',
+}
+
+export type IdeaStatusApi = (typeof IDEA_DB_TO_API)[IdeaStatus]
+
+export function ideaStatusToApi(s: IdeaStatus): IdeaStatusApi {
+ return IDEA_DB_TO_API[s]
+}
+
+export function ideaStatusFromApi(s: string): IdeaStatus | null {
+ return IDEA_API_TO_DB[s.toLowerCase()] ?? null
+}
+
+export const IDEA_STATUS_API_VALUES = Object.values(IDEA_DB_TO_API)
+
+// ---------------------------------------------------------------------------
+// State-machine transition table (zie docs/plans/M12-ideas.md state-machine).
+// Server-actions gebruiken canTransition(from, to) als guard vรณรณr mutatie.
+//
+// Asymmetrisch: trek vanuit DRAFT alleen naar GRILLING; vanuit GRILLED kan
+// re-grill (โ GRILLING) of make-plan (โ PLANNING) gebeuren. PLANNED is een
+// terminal state; verlaat alleen via expliciete relink (PBI verwijderd โ PLAN_READY).
+
+const ALLOWED_TRANSITIONS: Record> = {
+ DRAFT: ['GRILLING'],
+ GRILLING: ['GRILLED', 'GRILL_FAILED'],
+ GRILL_FAILED: ['GRILLING', 'DRAFT'],
+ GRILLED: ['GRILLING', 'PLANNING'],
+ PLANNING: ['PLAN_READY', 'PLAN_FAILED'],
+ PLAN_FAILED: ['PLANNING', 'GRILLED'],
+ PLAN_READY: ['PLANNING', 'PLANNED'],
+ PLANNED: ['PLAN_READY'], // alleen via relinkIdeaPlanAction (PBI deleted)
+}
+
+export function canTransition(from: IdeaStatus, to: IdeaStatus): boolean {
+ return ALLOWED_TRANSITIONS[from].includes(to)
+}
+
+// Statussen waarin een idee bewerkbaar is (form-input, niet md-velden).
+const EDITABLE_STATUSES: ReadonlyArray = [
+ 'DRAFT',
+ 'GRILL_FAILED',
+ 'GRILLED',
+ 'PLAN_FAILED',
+ 'PLAN_READY',
+]
+
+export function isIdeaEditable(s: IdeaStatus): boolean {
+ return EDITABLE_STATUSES.includes(s)
+}
+
+// Statussen waarin grill_md bewerkbaar is (handmatige finetuning).
+export function isGrillMdEditable(s: IdeaStatus): boolean {
+ return s === 'GRILLED' || s === 'PLAN_READY'
+}
+
+// Statussen waarin plan_md bewerkbaar is.
+export function isPlanMdEditable(s: IdeaStatus): boolean {
+ return s === 'PLAN_READY'
+}
diff --git a/lib/schemas/idea.ts b/lib/schemas/idea.ts
new file mode 100644
index 0000000..4be1553
--- /dev/null
+++ b/lib/schemas/idea.ts
@@ -0,0 +1,53 @@
+import { z } from 'zod'
+
+// Velden die de gebruiker invult bij create/edit. Status wordt door
+// server-actions gezet (niet door client-input).
+export const ideaCreateSchema = z.object({
+ title: z.string().trim().min(1, 'Titel is verplicht').max(200, 'Maximaal 200 tekens'),
+ description: z.string().max(4000, 'Maximaal 4000 tekens').optional().nullable(),
+ product_id: z.string().cuid('Ongeldig product').optional().nullable(),
+})
+
+export const ideaUpdateSchema = ideaCreateSchema.partial()
+
+export type IdeaCreateInput = z.infer
+export type IdeaUpdateInput = z.infer
+
+// ---------------------------------------------------------------------------
+// plan_md frontmatter โ strict format dat door make-plan-job geproduceerd
+// wordt en door materializeIdeaPlanAction wordt geparseerd. Zie
+// docs/plans/M12-ideas.md "Plan-md formaat A".
+
+const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'])
+
+const planTaskSchema = z.object({
+ title: z.string().min(1).max(200),
+ description: z.string().max(4000).optional(),
+ implementation_plan: z.string().max(8000).optional(),
+ priority: z.number().int().min(1).max(4),
+ verify_required: verifyRequiredEnum.optional(),
+ verify_only: z.boolean().optional(),
+})
+
+const planStorySchema = z.object({
+ title: z.string().min(1).max(200),
+ description: z.string().max(4000).optional(),
+ acceptance_criteria: z.string().max(4000).optional(),
+ priority: z.number().int().min(1).max(4),
+ tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'),
+})
+
+const planPbiSchema = z.object({
+ title: z.string().min(1).max(200),
+ description: z.string().max(4000).optional(),
+ priority: z.number().int().min(1).max(4),
+})
+
+export const ideaPlanMdFrontmatterSchema = z.object({
+ pbi: planPbiSchema,
+ stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'),
+})
+
+export type IdeaPlanFrontmatter = z.infer
+export type IdeaPlanStory = z.infer
+export type IdeaPlanTask = z.infer