From 300e426a4eba24b43588be96b6be14cce8160881 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:25:07 +0200 Subject: [PATCH 01/26] schema: add Idea + IdeaLog models, extend ClaudeJob/Question for ideas (M12 T-491) - new enums IdeaStatus, ClaudeJobKind, IdeaLogType - new models Idea (with @@unique([user_id, code]) + pbi_id @unique) and IdeaLog - User.idea_code_counter Int @default(0) for IDEA-{nnn} code generation - ClaudeJob.task_id nullable; new idea_id + kind fields + index - ClaudeQuestion.story_id nullable; new idea_id field + index - existing call sites narrowed to story-questions / task-jobs (idea-paths come in T-502+) - includes the M12 plan doc copied from /Users/janpetervisser/.claude/plans Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/realtime/notifications/route.ts | 8 +- .../notifications/notifications-bridge.tsx | 30 +- docs/INDEX.md | 1 + docs/plans/M12-ideas.md | 299 ++++++++++++++++++ lib/insights/verify-stats.ts | 8 +- prisma/schema.prisma | 282 +++++++++++------ 6 files changed, 508 insertions(+), 120 deletions(-) create mode 100644 docs/plans/M12-ideas.md diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 907898a..7a6befa 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -132,6 +132,7 @@ export async function GET(request: NextRequest) { status: 'open', expires_at: { gt: new Date() }, product_id: { in: products.map((p) => p.id) }, + story_id: { not: null }, }, orderBy: { created_at: 'desc' }, take: 100, @@ -150,7 +151,9 @@ export async function GET(request: NextRequest) { enqueue( `event: state\ndata: ${JSON.stringify({ - questions: openQuestions.map((q) => ({ + questions: openQuestions.flatMap((q) => { + if (!q.story || q.story_id === null) return [] + return [{ id: q.id, product_id: q.product_id, story_id: q.story_id, @@ -162,7 +165,8 @@ export async function GET(request: NextRequest) { options: q.options, created_at: q.created_at.toISOString(), expires_at: q.expires_at.toISOString(), - })), + }] + }), })}\n\n`, ) diff --git a/components/notifications/notifications-bridge.tsx b/components/notifications/notifications-bridge.tsx index 11128b2..ef7a9fe 100644 --- a/components/notifications/notifications-bridge.tsx +++ b/components/notifications/notifications-bridge.tsx @@ -28,6 +28,7 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps) status: 'open', expires_at: { gt: new Date() }, product_id: { in: productIds }, + story_id: { not: null }, }, orderBy: { created_at: 'desc' }, take: 100, @@ -44,19 +45,22 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps) }, }) - const initial: NotificationQuestion[] = openQuestions.map((q) => ({ - id: q.id, - product_id: q.product_id, - story_id: q.story_id, - task_id: q.task_id, - story_code: q.story.code, - story_title: q.story.title, - assignee_id: q.story.assignee_id, - question: q.question, - options: Array.isArray(q.options) ? (q.options as string[]) : null, - created_at: q.created_at.toISOString(), - expires_at: q.expires_at.toISOString(), - })) + const initial: NotificationQuestion[] = openQuestions.flatMap((q) => { + if (!q.story || q.story_id === null) return [] + return [{ + id: q.id, + product_id: q.product_id, + story_id: q.story_id, + task_id: q.task_id, + story_code: q.story.code, + story_title: q.story.title, + assignee_id: q.story.assignee_id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + }] + }) return } diff --git a/docs/INDEX.md b/docs/INDEX.md index ddb1a02..8e5f9e5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -43,6 +43,7 @@ Auto-generated on 2026-05-04 from front-matter and headings. | [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 | | [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 | | [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 | +| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — | | [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | diff --git a/docs/plans/M12-ideas.md b/docs/plans/M12-ideas.md new file mode 100644 index 0000000..988e490 --- /dev/null +++ b/docs/plans/M12-ideas.md @@ -0,0 +1,299 @@ +--- +title: "M12 — Idea entity + Grill/Plan Claude jobs" +status: planned +audience: implementation +language: nl +--- + +# M12 — Idea entity + Grill/Plan Claude jobs + +## Context + +Scrum4Me ondersteunt `Todo` als lichtgewicht voorstel-laag, en kan dat handmatig promoveren naar PBI/Story. Dat slaat het *denkproces* niet vast: waarom werd iets een PBI, welke alternatieven zijn afgewogen, welke randvoorwaarden waren er. + +Doel: een nieuw concept **Idee** dat: +- werkt als een Todo (top-level lijst, privé per gebruiker), met een **Grill Me**- en **Make Plan**-knop; +- via de bestaande Claude-job/worker-infrastructuur een gestructureerd plan oplevert; +- het hele planningsproces vastlegt (Q&A, beslissingen, grill-md, plan-md, link naar PBI); +- na goedkeuring deterministisch materialiseert tot PBI + stories + taken (incl. `implementation_plan`). + +## Vastgelegde keuzes (uit grill-sessie) + +1. **UI-plek**: top-level `/ideas`, naast `/todos`. +2. **Auth-scope**: strikt `user_id`-only (privé, ook ná `PLANNED`). Geen `productAccessFilter` op idea-acties; geen `pbi.idea_id`-veld nodig. +3. **Product-binding**: een idee mag bestaan zonder product, maar **Grill Me** én **Make Plan** vereisen een product met `repo_url` (de worker leest sources/docs uit de repo). `claude_jobs.product_id` blijft NOT NULL. +4. **Executie-model**: bestaand worker-model. `ClaudeJob{kind:IDEA_*}` QUEUED → lokale Claude-CLI claimt via `wait_for_job`. Knoppen zijn **disabled als `connectedWorkers === 0`** (exact zoals `solo/task-detail-dialog.tsx`). +5. **Skill-afhankelijkheid**: **embedded prompts** in `lib/idea-prompts/{grill,make-plan}.md`; meegestuurd in payload. Geen externe `anthropic-skills:grill-me`-plugin-vereiste op de worker. +6. **Make-Plan flow**: preview-en-bevestigen. Job produceert `Idea.plan_md`, status → `PLAN_READY`. Aparte knop **"Materialiseer plan"** parseert md → entiteiten in één Prisma-transactie, status → `PLANNED`. +7. **Plan-md formaat**: YAML-frontmatter (structuur) + markdown-body (overwegingen, alternatieven, vrije reasoning). +8. **Make-Plan-job**: single-pass (geen `ask_user_question`). Twijfels → terug naar grill (append-context). +9. **Backward transitions**: + - Re-grill vanuit `GRILLED`/`PLAN_READY`: nieuwe `IDEA_GRILL`-job met **append-context** (oude `grill_md` als input); oude versie naar `IdeaLog{type:GRILL_RESULT}` als history. + - Re-plan vanuit `PLAN_READY`: idem voor `plan_md`. + - PBI-verwijdering vanuit `PLANNED`: **expliciete user-actie "Re-link plan"** (geen DB-trigger). Zet `pbi_id=null`, status `PLAN_READY`. + - Failed grill/plan: dedicated states **`GRILL_FAILED` / `PLAN_FAILED`** (zichtbaar voor user), niet stilzwijgend resetten. +10. **Logging-model**: `IdeaLog` smal (`DECISION | NOTE | GRILL_RESULT | PLAN_RESULT | STATUS_CHANGE | JOB_EVENT`). Q&A blijft uitsluitend in `claude_questions`. Timeline-tab in UI doet `UNION ALL` over beide bronnen. +11. **Opslag md-bestanden**: alleen DB (`Idea.grill_md`, `Idea.plan_md`). Geen auto-commit naar repo (zou strict-private auth-keuze ondergraven). UI biedt **"Download .md"**. +12. **Editability**: beide md's bewerkbaar door user in hun ready-states (`GRILLED` voor grill_md, `PLAN_READY` voor plan_md). Bij `PLANNED`: read-only. Yaml-frontmatter wordt zod-gevalideerd-on-save voor `plan_md`. +13. **Promotie vanuit Todo**: nieuwe `promoteTodoToIdeaAction` (Todo → DRAFT-Idea + Todo wordt `archived=true`). Bestaande Todo→PBI/Story-acties blijven onaangetast. +14. **Demo-policy** (3-laag, zoals Todo): create/edit/archive **mag**; Grill / Make Plan / Materialiseer / promote-from-Todo zijn **geblokkeerd** (proxy.ts 403 + `session.isDemo`-guard + ``). +15. **Idea-code**: `Idea.code = "IDEA-{nnn}"`, `@@unique([user_id, code])`, counter op `User.idea_code_counter`. +16. **Realtime-store**: nieuwe `stores/idea-store.ts`. `connectedWorkers` direct selecten via `useSoloStore(s => s.connectedWorkers)` (lift naar shared store is opvolg-refactor). +17. **Sidebar**: nieuwe entry **Ideeën** (`Lightbulb`-icon) direct boven Todo's. +18. **Q&A-expiry**: 24h aanhouden (consistent met bestaand). Verlopen → re-grill (append-context). + +## State machine + +``` + ┌──── re-grill ────┐ + ▼ │ +DRAFT ──Grill Me──▶ GRILLING ─done──▶ GRILLED ─Make Plan─▶ PLANNING ─done──▶ PLAN_READY ─Materialiseer─▶ PLANNED + │ fail │ fail │ ▲ │ + ▼ ▼ │ │ │ + GRILL_FAILED PLAN_FAILED └──┘ re-plan │ + │ │ + └────── retry/edit ──────────────────────────────────────── PBI verwijderd ──────────┘ + + "Re-link plan" +``` + +`archived: boolean` is orthogonaal en kan vanuit elke status. + +## Datamodel + +### Nieuwe enums +```prisma +enum IdeaStatus { + DRAFT + GRILLING + GRILL_FAILED + GRILLED + PLANNING + PLAN_FAILED + PLAN_READY + PLANNED +} + +enum ClaudeJobKind { + TASK_IMPLEMENTATION + IDEA_GRILL + IDEA_MAKE_PLAN +} + +enum IdeaLogType { + DECISION + NOTE + GRILL_RESULT + PLAN_RESULT + STATUS_CHANGE + JOB_EVENT +} +``` + +### `User` +- Veld toevoegen: `idea_code_counter Int @default(0)`. + +### Nieuwe tabel `ideas` +```prisma +model Idea { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + code String @db.VarChar(30) + title String + description String? @db.VarChar(4000) + grill_md String? @db.Text + plan_md String? @db.Text + pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) + pbi_id String? @unique + status IdeaStatus @default(DRAFT) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + questions ClaudeQuestion[] + jobs ClaudeJob[] + logs IdeaLog[] + + @@unique([user_id, code]) + @@index([user_id, archived, status]) + @@index([user_id, product_id]) + @@map("ideas") +} +``` + +### Nieuwe tabel `idea_logs` +```prisma +model IdeaLog { + id String @id @default(cuid()) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String + type IdeaLogType + content String @db.Text + metadata Json? + created_at DateTime @default(now()) + + @@index([idea_id, created_at]) + @@map("idea_logs") +} +``` + +### Aanpassingen `claude_jobs` +- `task_id` → **nullable**. +- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`. +- `kind ClaudeJobKind @default(TASK_IMPLEMENTATION)` toegevoegd. +- `product_id` blijft NOT NULL. +- Raw-SQL check-constraint: `(task_id IS NOT NULL) <> (idea_id IS NOT NULL)`. +- Index: `@@index([idea_id, status])`. + +### Aanpassingen `claude_questions` +- `story_id` → **nullable**. +- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`. +- Raw-SQL check-constraint: `(story_id IS NOT NULL) <> (idea_id IS NOT NULL)`. +- Index: `@@index([idea_id, status])`. +- pg_notify-trigger payload uitbreiden met `idea_id` (nullable). SSE-filter laat idea-payloads alleen door naar `idea.user_id === session.user_id`. + +## Server-laag + +### Schemas + helpers +- `lib/schemas/idea.ts` — `ideaCreateSchema`, `ideaUpdateSchema`, `ideaPlanMdFrontmatterSchema`. +- `lib/idea-status.ts` — DB-enum ↔ API-string mapping. +- `lib/idea-plan-parser.ts` — synchroon: `parsePlanMd(md): ParsedPlan | ZodError`. Gebruikt `yaml`-package + zod. +- `lib/idea-code.ts` — atomair `nextIdeaCode(userId)` via Prisma-transactie. + +### Embedded prompts +- `lib/idea-prompts/grill.md` — eigen scrum4me-versie. Instrueert: gebruik `ask_user_question` MCP, schrijf via `update_idea_grill_md` aan eind. +- `lib/idea-prompts/make-plan.md` — strict yaml-frontmatter-format. Instrueert: lees `grill_md`, gebruik repo-files, **geen vragen**, eindig met `update_idea_plan_md`. + +### Server actions — `actions/ideas.ts` +Volg `docs/patterns/server-action.md`: auth → demo-check → zod → user-id-scope-check → write → `revalidatePath`. + +- `createIdeaAction(input)` — `nextIdeaCode(userId)`, status `DRAFT`. +- `updateIdeaAction(id, input)` — alleen `DRAFT|GRILL_FAILED|GRILLED|PLAN_FAILED|PLAN_READY`. +- `archiveIdeaAction(id)` / `unarchiveIdeaAction(id)`. +- `deleteIdeaAction(id)` — geweigerd als `pbi_id` gevuld. +- `updateGrillMdAction(id, md)` — alleen in `GRILLED|PLAN_READY`. Logt `IdeaLog{NOTE}`. +- `updatePlanMdAction(id, md)` — alleen in `PLAN_READY`. Eerst `parsePlanMd(md)`; bij parse-fail → 422 met line-info. +- `startGrillJobAction(id)` — vereist product met `repo_url`, `connectedWorkers > 0`. `ClaudeJob{kind:IDEA_GRILL, idea_id, product_id, QUEUED}`. Status → `GRILLING`. Demo: 403. +- `startMakePlanJobAction(id)` — vereist `GRILLED|PLAN_FAILED|PLAN_READY` voor re-plan, product met repo, worker. Status → `PLANNING`. Demo: 403. +- `cancelIdeaJobAction(id)` — actieve job CANCELLED, idea-status terug naar vorige. +- `materializeIdeaPlanAction(id)` — `PLAN_READY` → `parsePlanMd` → Prisma-`$transaction`: + 1. Counters incrementeren (PBI/Story/Task). + 2. INSERT PBI + N stories + M tasks (incl. `implementation_plan`). + 3. UPDATE idea: `pbi_id`, `status:PLANNED`. + 4. INSERT `IdeaLog{type:PLAN_RESULT, metadata}`. + Rollback bij ANY fail. Demo: 403. +- `relinkIdeaPlanAction(id)` — alleen als `status===PLANNED && pbi_id===null`. Status → `PLAN_READY`. +- `downloadIdeaMdAction(id, kind: 'grill'|'plan')` — server returnt md. + +### Promote van Todo → Idea +- `actions/todos.ts`: nieuwe `promoteTodoToIdeaAction(todoId)` — auth + demo + scope. Maakt Idea (DRAFT) met title/description; zet Todo `archived=true`. Demo: 403. + +### REST-routes +- `app/api/ideas/route.ts` (GET, POST) en `app/api/ideas/[id]/route.ts` (GET, PATCH). + +### proxy.ts (demo-laag) +- 403 op `POST/PATCH/DELETE /api/ideas*` voor demo-token. +- 403 op grill/make-plan/materialize-endpoints. + +## MCP-laag (`scrum4me-mcp`-repo) + +### Nieuwe tools +- `get_idea_context(idea_id)` — `{idea, product, repo_url, grill_md_so_far, open_questions, prompt_text}`. +- `update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}`. +- `update_idea_plan_md(idea_id, markdown)` — schrijft veld; parser draait server-side; status → `PLAN_READY` of `PLAN_FAILED`. +- `log_idea_decision(idea_id, type, content, metadata?)` — types: `DECISION | NOTE`. + +### Uitbreiding bestaande tools +- `ask_user_question`: contract uitbreiden — exact één van `story_id` of `idea_id`. +- `wait_for_job`: response uitbreiden: + - `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'` + - bij `IDEA_*`: `idea`, `product`, `repo_url`, `prompt_text`. +- `update_job_status`: bij `failed` voor IDEA_*-jobs zet idea-status `GRILL_FAILED` / `PLAN_FAILED`. + +### Schema-drift +- `docs/runbooks/mcp-integration.md:62`: schema-drift-watchdog moet groen zijn vóór merge. MCP-server-PR parallel. + +## Realtime-laag + +- `app/api/realtime/notifications/route.ts` — idea-questions alleen aan `idea.user_id === session.user_id`. +- `app/api/realtime/solo/route.ts` — `JobPayload` uitbreiden met `kind` en `idea_id`. Idea-jobs op `user_id`. +- `stores/idea-store.ts` (nieuw). `connectedWorkers` direct uit `useSoloStore`. + +## UI-laag + +### Routing +- `app/(app)/ideas/page.tsx` — top-level lijst. +- `app/(app)/ideas/[id]/page.tsx` — detailpagina met tabs **Idee** · **Grill** · **Plan** · **Timeline**. +- Sidebar-entry: `Lightbulb`, label "Ideeën", boven Todo's. + +### Componenten — `components/ideas/` +- `idea-list.tsx` — TanStack Table; kolommen code/title/product/status/archived. Bulk-archive. +- `idea-row-actions.tsx` — Grill Me / Make Plan / Materialiseer / Edit / Archive met disabled-rules. +- `idea-dialog.tsx` + `components/dialogs/idea-dialog.tsx` (wrapper) volgens dialog-pattern. +- `idea-md-editor.tsx` — markdown editor met yaml-validate voor plan_md. +- `idea-timeline.tsx` — UNION-view IdeaLog + claude_questions. +- `idea-pbi-link-card.tsx` — incl. "Re-link plan"-banner. +- `download-md-button.tsx`. + +### Promote-from-Todo UI +- `components/todos/todo-list.tsx`: extra menu-item "Promote naar Idee". + +### Profiel-doc +- `docs/specs/dialogs/idea.md` — verplicht volgens dialog-pattern. + +## Te raken / aan te maken bestanden + +| Laag | Bestand | +|---|---| +| Schema | `prisma/schema.prisma` | +| Migratie | `prisma/migrations/_add_ideas/migration.sql` | +| Schemas | `lib/schemas/idea.ts`, `lib/idea-status.ts`, `lib/idea-plan-parser.ts`, `lib/idea-code.ts` | +| Prompts | `lib/idea-prompts/grill.md`, `lib/idea-prompts/make-plan.md` | +| Actions | `actions/ideas.ts`, uitbreiding `actions/todos.ts` | +| API | `app/api/ideas/route.ts`, `app/api/ideas/[id]/route.ts`, `proxy.ts` | +| Realtime | `app/api/realtime/notifications/route.ts`, `app/api/realtime/solo/route.ts` | +| Pages | `app/(app)/ideas/page.tsx`, `app/(app)/ideas/[id]/page.tsx` | +| UI | `components/ideas/*.tsx`, `components/dialogs/idea-dialog.tsx`, sidebar-update | +| Store | `stores/idea-store.ts` | +| Docs | `docs/specs/dialogs/idea.md`, `docs/runbooks/mcp-integration.md`, `docs/backlog/index.md` | +| MCP-server | `madhura68/scrum4me-mcp` (parallel-PR) | + +## Implementatievolgorde + +1. **DB & migratie** +2. **Lib + schemas + prompts** +3. **Server actions + Todo-promote** +4. **REST + proxy demo-laag** +5. **Realtime SSE + idea-store** +6. **MCP-server tools (extern repo, parallel)** +7. **UI lijst + row-actions** +8. **UI detail + dialog + tabs** +9. **UI promote-from-Todo + sidebar-entry** +10. **End-to-end smoke + docs** + +## Verificatie + +```bash +npm run lint && npm test && npm run build +``` + +End-to-end: +1. `npm run dev` + lokale Claude-CLI met `wait_for_job`-loop. +2. Maak idee, koppel aan product. Status `DRAFT`. +3. Grill Me → vragen via answer-modal → `update_idea_grill_md` → `GRILLED`. +4. Edit grill_md handmatig → `IdeaLog{NOTE}`. +5. Make Plan → `update_idea_plan_md` → `PLAN_READY`. +6. Yaml-fout in plan_md → save geblokkeerd. +7. Materialiseer → PBI + stories + taken in transactie. `idea.pbi_id` gezet, `PLANNED`. +8. PBI verwijderen → "Re-link plan"-banner → `PLAN_READY`. +9. Demo-test: knoppen geblokkeerd via DemoTooltip + 403. +10. Failure-test: kill worker → `GRILL_FAILED`/`PLAN_FAILED`. +11. Promote-test: Todo → "Promote naar Idee" → DRAFT-Idea, Todo archived. + +## Open punten (niet-blokkerend) + +- Concrete copy-finetuning van prompt-md's tijdens implementatie. +- Lift `connectedWorkers` naar gedeelde `worker-presence-store` (opvolg-refactor). +- Optionele "Commit plan_md naar repo"-knop (buiten v1). diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts index c19140f..c09806b 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -40,6 +40,7 @@ export async function getVerifyResultStats( status: 'DONE' as const, verify_result: { not: null as null }, finished_at: { gt: cutoff }, + task_id: { not: null }, } const [grouped, rawEmpty, rawDivergent] = await Promise.all([ @@ -82,7 +83,8 @@ export async function getVerifyResultStats( .filter(r => countMap.has(r)) .map(r => ({ result: r, count: countMap.get(r)! })) - function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob { + function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string } | null; product: { id: string; name: string } }): TopJob | null { + if (!j.task) return null return { jobId: j.id, taskId: j.task.id, @@ -95,8 +97,8 @@ export async function getVerifyResultStats( return { counts, - topEmpty: rawEmpty.map(toTopJob), - topDivergent: rawDivergent.map(toTopJob), + topEmpty: rawEmpty.map(toTopJob).filter((j): j is TopJob => j !== null), + topDivergent: rawDivergent.map(toTopJob).filter((j): j is TopJob => j !== null), } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 130e322..a60674c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,30 +74,58 @@ enum SprintStatus { COMPLETED } +enum IdeaStatus { + DRAFT + GRILLING + GRILL_FAILED + GRILLED + PLANNING + PLAN_FAILED + PLAN_READY + PLANNED +} + +enum ClaudeJobKind { + TASK_IMPLEMENTATION + IDEA_GRILL + IDEA_MAKE_PLAN +} + +enum IdeaLogType { + DECISION + NOTE + GRILL_RESULT + PLAN_RESULT + STATUS_CHANGE + JOB_EVENT +} + model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - avatar_data Bytes? - active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") - login_pairings LoginPairing[] - asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") - answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") - claude_jobs ClaudeJob[] - claude_workers ClaudeWorker[] + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + idea_code_counter Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + ideas Idea[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + claude_jobs ClaudeJob[] + claude_workers ClaudeWorker[] @@index([active_product_id]) @@map("users") @@ -114,33 +142,33 @@ model UserRole { } model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? - claimed_jobs ClaudeJob[] - claude_worker ClaudeWorker? + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + claimed_jobs ClaudeJob[] + claude_worker ClaudeWorker? @@index([token_hash]) @@map("api_tokens") } model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String name String - code String? @db.VarChar(30) + code String? @db.VarChar(30) description String? repo_url String? definition_of_done String - auto_pr Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + auto_pr Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt pbis Pbi[] sprints Sprint[] stories Story[] @@ -150,6 +178,7 @@ model Product { active_for_users User[] @relation("UserActiveProduct") claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] + ideas Idea[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -158,20 +187,21 @@ model Product { } model Pbi { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - code String @db.VarChar(30) - title String - description String? - priority Int - sort_order Float + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String @db.VarChar(30) + title String + description String? + priority Int + sort_order Float status PbiStatus @default(READY) pr_url String? pr_merged_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt stories Story[] + idea Idea? @@unique([product_id, code]) @@index([product_id, priority, sort_order]) @@ -180,24 +210,24 @@ model Pbi { } model Story { - id String @id @default(cuid()) - pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) pbi_id String - product Product @relation(fields: [product_id], references: [id]) + product Product @relation(fields: [product_id], references: [id]) product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? - assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) + assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) assignee_id String? - code String @db.VarChar(30) + code String @db.VarChar(30) title String description String? acceptance_criteria String? priority Int sort_order Float - status StoryStatus @default(OPEN) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt logs StoryLog[] tasks Task[] claude_questions ClaudeQuestion[] @@ -244,29 +274,29 @@ model Sprint { } model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) story_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? - code String @db.VarChar(30) + code String @db.VarChar(30) title String description String? implementation_plan String? priority Int sort_order Float - status TaskStatus @default(TO_DO) - verify_only Boolean @default(false) - verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) + status TaskStatus @default(TO_DO) + verify_only Boolean @default(false) + verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) // Override product.repo_url for branch/worktree/push purposes. Set when // a task targets a different repo than its parent product (e.g. an // MCP-server task tracked under the main product's PBI). Falls back to // product.repo_url when null. repo_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + created_at DateTime @default(now()) + updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] @@ -283,8 +313,11 @@ model ClaudeJob { user_id String product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String? + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String? + kind ClaudeJobKind @default(TASK_IMPLEMENTATION) status ClaudeJobStatus @default(QUEUED) claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) claimed_by_token_id String? @@ -304,20 +337,21 @@ model ClaudeJob { @@index([user_id, status]) @@index([task_id, status]) + @@index([idea_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) @@map("claude_jobs") } model ClaudeWorker { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String - token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) token_id String product_id String? - started_at DateTime @default(now()) - last_seen_at DateTime @default(now()) + started_at DateTime @default(now()) + last_seen_at DateTime @default(now()) @@unique([token_id]) @@index([user_id, last_seen_at]) @@ -338,23 +372,64 @@ model ProductMember { } model Todo { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - title String - description String? @db.VarChar(2000) - done Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + title String + description String? @db.VarChar(2000) + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, done, archived]) @@index([user_id, product_id]) @@map("todos") } +model Idea { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + code String @db.VarChar(30) + title String + description String? @db.VarChar(4000) + grill_md String? @db.Text + plan_md String? @db.Text + pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) + pbi_id String? @unique + status IdeaStatus @default(DRAFT) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + questions ClaudeQuestion[] + jobs ClaudeJob[] + logs IdeaLog[] + + @@unique([user_id, code]) + @@index([user_id, archived, status]) + @@index([user_id, product_id]) + @@map("ideas") +} + +model IdeaLog { + id String @id @default(cuid()) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String + type IdeaLogType + content String @db.Text + metadata Json? + created_at DateTime @default(now()) + + @@index([idea_id, created_at]) + @@map("idea_logs") +} + model LoginPairing { id String @id @default(cuid()) secret_hash String @@ -375,26 +450,29 @@ model LoginPairing { } model ClaudeQuestion { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) - task_id String? - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String // gedenormaliseerd uit story.product_id voor SSE-filter - asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) - asked_by String // user_id van token-houder (= Claude-token) - question String @db.Text - options Json? // string[] voor multi-choice; null voor free-text - status String // 'open' | 'answered' | 'cancelled' | 'expired' - answer String? @db.Text - answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) - answered_by String? - answered_at DateTime? - created_at DateTime @default(now()) - expires_at DateTime // ingesteld door MCP-tool, default now() + 24h + id String @id @default(cuid()) + story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String? + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + task_id String? + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String? + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String // gedenormaliseerd uit story.product_id voor SSE-filter + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + asked_by String // user_id van token-houder (= Claude-token) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_by String? + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime // ingesteld door MCP-tool, default now() + 24h @@index([story_id, status]) + @@index([idea_id, status]) @@index([product_id, status]) @@index([status, expires_at]) @@map("claude_questions") From 86fb97456ea6c23c8ae00ed68a90e13796abb0e1 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:35:28 +0200 Subject: [PATCH 02/26] =?UTF-8?q?db:=20M12=20migration=20=E2=80=94=20ideas?= =?UTF-8?q?=20+=20idea=5Flogs=20+=20check-constraints=20+=20pg=5Fnotify=20?= =?UTF-8?q?update=20(T-492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new tables ideas + idea_logs with FKs (User/Product/Pbi cascade rules per plan) - claude_jobs.task_id nullable; new idea_id FK + kind enum + index + check-constraint: exactly_one(task_id, idea_id) - claude_questions.story_id nullable; new idea_id FK + index + check-constraint: exactly_one(story_id, idea_id) - notify_question_change trigger: handles null story_id; idea_id added to payload Verified against dev DB: tables created, both check-constraints active (neither-set insert correctly rejected with errcode 23514), trigger replaced. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql diff --git a/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql b/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql new file mode 100644 index 0000000..49cc4dd --- /dev/null +++ b/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql @@ -0,0 +1,129 @@ +-- M12 — Idea entity + Grill/Plan Claude jobs +-- See docs/plans/M12-ideas.md + +-- 1. New enums +CREATE TYPE "IdeaStatus" AS ENUM ('DRAFT', 'GRILLING', 'GRILL_FAILED', 'GRILLED', 'PLANNING', 'PLAN_FAILED', 'PLAN_READY', 'PLANNED'); +CREATE TYPE "ClaudeJobKind" AS ENUM ('TASK_IMPLEMENTATION', 'IDEA_GRILL', 'IDEA_MAKE_PLAN'); +CREATE TYPE "IdeaLogType" AS ENUM ('DECISION', 'NOTE', 'GRILL_RESULT', 'PLAN_RESULT', 'STATUS_CHANGE', 'JOB_EVENT'); + +-- 2. User.idea_code_counter +ALTER TABLE "users" ADD COLUMN "idea_code_counter" INTEGER NOT NULL DEFAULT 0; + +-- 3. ideas table +CREATE TABLE "ideas" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "product_id" TEXT, + "code" VARCHAR(30) NOT NULL, + "title" TEXT NOT NULL, + "description" VARCHAR(4000), + "grill_md" TEXT, + "plan_md" TEXT, + "pbi_id" TEXT, + "status" "IdeaStatus" NOT NULL DEFAULT 'DRAFT', + "archived" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ideas_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ideas_pbi_id_key" ON "ideas"("pbi_id"); +CREATE UNIQUE INDEX "ideas_user_id_code_key" ON "ideas"("user_id", "code"); +CREATE INDEX "ideas_user_id_archived_status_idx" ON "ideas"("user_id", "archived", "status"); +CREATE INDEX "ideas_user_id_product_id_idx" ON "ideas"("user_id", "product_id"); + +ALTER TABLE "ideas" ADD CONSTRAINT "ideas_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ideas" ADD CONSTRAINT "ideas_product_id_fkey" + FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ideas" ADD CONSTRAINT "ideas_pbi_id_fkey" + FOREIGN KEY ("pbi_id") REFERENCES "pbis"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 4. idea_logs table +CREATE TABLE "idea_logs" ( + "id" TEXT NOT NULL, + "idea_id" TEXT NOT NULL, + "type" "IdeaLogType" NOT NULL, + "content" TEXT NOT NULL, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "idea_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "idea_logs_idea_id_created_at_idx" ON "idea_logs"("idea_id", "created_at"); + +ALTER TABLE "idea_logs" ADD CONSTRAINT "idea_logs_idea_id_fkey" + FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 5. ClaudeJob: nullable task_id, new idea_id + kind +ALTER TABLE "claude_jobs" DROP CONSTRAINT "claude_jobs_task_id_fkey"; +ALTER TABLE "claude_jobs" ALTER COLUMN "task_id" DROP NOT NULL; +ALTER TABLE "claude_jobs" ADD COLUMN "idea_id" TEXT; +ALTER TABLE "claude_jobs" ADD COLUMN "kind" "ClaudeJobKind" NOT NULL DEFAULT 'TASK_IMPLEMENTATION'; +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_task_id_fkey" + FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_idea_id_fkey" + FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE INDEX "claude_jobs_idea_id_status_idx" ON "claude_jobs"("idea_id", "status"); + +-- Check-constraint: exactly one of task_id, idea_id +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_one_of_task_or_idea" + CHECK (("task_id" IS NOT NULL) <> ("idea_id" IS NOT NULL)); + +-- 6. ClaudeQuestion: nullable story_id, new idea_id +ALTER TABLE "claude_questions" DROP CONSTRAINT "claude_questions_story_id_fkey"; +ALTER TABLE "claude_questions" ALTER COLUMN "story_id" DROP NOT NULL; +ALTER TABLE "claude_questions" ADD COLUMN "idea_id" TEXT; +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_story_id_fkey" + FOREIGN KEY ("story_id") REFERENCES "stories"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_idea_id_fkey" + FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE INDEX "claude_questions_idea_id_status_idx" ON "claude_questions"("idea_id", "status"); + +-- Check-constraint: exactly one of story_id, idea_id +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_one_of_story_or_idea" + CHECK (("story_id" IS NOT NULL) <> ("idea_id" IS NOT NULL)); + +-- 7. pg_notify-trigger update: handle null story_id + emit idea_id +-- Replaces notify_question_change from 20260427224849_add_claude_questions. +-- New payload shape: +-- { op: 'I' | 'U', +-- entity: 'question', +-- id: text, +-- product_id: text, +-- story_id: text|null, +-- task_id: text|null, +-- idea_id: text|null, +-- assignee_id: text|null, // story.assignee_id, null voor idea-questions (privé) +-- status: 'open'|'answered'|'cancelled'|'expired' } + +CREATE OR REPLACE FUNCTION notify_question_change() RETURNS trigger AS $$ +DECLARE + story_assignee TEXT; + payload jsonb; +BEGIN + IF NEW.story_id IS NOT NULL THEN + SELECT assignee_id INTO story_assignee FROM stories WHERE id = NEW.story_id; + ELSE + story_assignee := NULL; + END IF; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + END, + 'entity', 'question', + 'id', NEW.id, + 'product_id', NEW.product_id, + 'story_id', NEW.story_id, + 'task_id', NEW.task_id, + 'idea_id', NEW.idea_id, + 'assignee_id', story_assignee, + 'status', NEW.status + ); + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; From bba3f112692a3bcaa151c0144dedd5525c3aa96b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:38:52 +0200 Subject: [PATCH 03/26] lib: idea schemas + status mappers + transition guards (M12 T-493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/schemas/idea.ts: ideaCreateSchema, ideaUpdateSchema, ideaPlanMdFrontmatterSchema (yaml-frontmatter contract for materialize-step parser) - lib/idea-status.ts: bidirectional DB↔API mapping, canTransition state-machine guard, isIdeaEditable + isGrillMdEditable + isPlanMdEditable helpers - includes auto-regen docs/erd.svg from prisma generate Tests: 26 cases (status round-trip, transitions valid/invalid, schema validation edge-cases, priority bounds, verify-enum). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/idea-schemas.test.ts | 131 +++++++++++++++++++++++++++++ __tests__/lib/idea-status.test.ts | 99 ++++++++++++++++++++++ docs/erd.svg | 2 +- lib/idea-status.ts | 85 +++++++++++++++++++ lib/schemas/idea.ts | 53 ++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 __tests__/lib/idea-schemas.test.ts create mode 100644 __tests__/lib/idea-status.test.ts create mode 100644 lib/idea-status.ts create mode 100644 lib/schemas/idea.ts 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 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

product

sprint

enum:status

enum:verify_required

user

product

task

enum:status

claimed_by_token

enum:verify_result

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

plan_snapshot

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

product

sprint

enum:status

enum:verify_required

user

product

task

idea

enum:kind

enum:status

claimed_by_token

enum:verify_result

user

token

product

user

user

product

user

product

pbi

enum:status

idea

enum:type

user

story

task

idea

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

IdeaStatus

DRAFT

DRAFT

GRILLING

GRILLING

GRILL_FAILED

GRILL_FAILED

GRILLED

GRILLED

PLANNING

PLANNING

PLAN_FAILED

PLAN_FAILED

PLAN_READY

PLAN_READY

PLANNED

PLANNED

ClaudeJobKind

TASK_IMPLEMENTATION

TASK_IMPLEMENTATION

IDEA_GRILL

IDEA_GRILL

IDEA_MAKE_PLAN

IDEA_MAKE_PLAN

IdeaLogType

DECISION

DECISION

NOTE

NOTE

GRILL_RESULT

GRILL_RESULT

PLAN_RESULT

PLAN_RESULT

STATUS_CHANGE

STATUS_CHANGE

JOB_EVENT

JOB_EVENT

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

Int

idea_code_counter

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobKind

kind

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

plan_snapshot

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

ideas

String

id

🗝️

String

code

String

title

String

description

String

grill_md

String

plan_md

IdeaStatus

status

Boolean

archived

DateTime

created_at

DateTime

updated_at

idea_logs

String

id

🗝️

IdeaLogType

type

String

content

Json

metadata

DateTime

created_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ 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 From dfee5189960809f6bbeb4852be4ff3abc5b398f3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:40:39 +0200 Subject: [PATCH 04/26] lib: idea-code generator + plan_md yaml-frontmatter parser (M12 T-494) - lib/idea-code.ts: pure formatIdeaCode helper (client-safe, no prisma) - lib/idea-code-server.ts: atomic nextIdeaCode via Prisma row-lock, accepts optional TransactionClient for combining with idea.create - lib/idea-plan-parser.ts: parsePlanMd extracts ---yaml---/body, runs yaml.parse + ideaPlanMdFrontmatterSchema, returns line-info on failure; CRLF-tolerant - adds yaml@^2.8.4 dependency - 8 unit tests (parser happy/missing/yaml-error/zod-error/empty-stories/CRLF; formatIdeaCode pad-3 + overflow) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/idea-code.test.ts | 21 +++++ __tests__/lib/idea-plan-parser.test.ts | 103 +++++++++++++++++++++++++ lib/idea-code-server.ts | 26 +++++++ lib/idea-code.ts | 8 ++ lib/idea-plan-parser.ts | 73 ++++++++++++++++++ package-lock.json | 8 +- package.json | 1 + 7 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 __tests__/lib/idea-code.test.ts create mode 100644 __tests__/lib/idea-plan-parser.test.ts create mode 100644 lib/idea-code-server.ts create mode 100644 lib/idea-code.ts create mode 100644 lib/idea-plan-parser.ts diff --git a/__tests__/lib/idea-code.test.ts b/__tests__/lib/idea-code.test.ts new file mode 100644 index 0000000..f0a9150 --- /dev/null +++ b/__tests__/lib/idea-code.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' + +import { formatIdeaCode } from '@/lib/idea-code' + +describe('formatIdeaCode', () => { + it('pads to 3 digits', () => { + expect(formatIdeaCode(1)).toBe('IDEA-001') + expect(formatIdeaCode(42)).toBe('IDEA-042') + expect(formatIdeaCode(999)).toBe('IDEA-999') + }) + + it('does not truncate beyond pad-width', () => { + expect(formatIdeaCode(1000)).toBe('IDEA-1000') + expect(formatIdeaCode(99999)).toBe('IDEA-99999') + }) +}) + +// Integration-style concurrency-test op nextIdeaCode is in +// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap). +// Hier alleen de pure formatter; de increment-logica leunt op Prisma's +// row-lock in $transaction die we per-database vertrouwen. diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts new file mode 100644 index 0000000..30169aa --- /dev/null +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' + +import { parsePlanMd } from '@/lib/idea-plan-parser' + +const VALID = `--- +pbi: + title: Test PBI + priority: 2 +stories: + - title: Eerste flow + priority: 2 + tasks: + - title: Setup + priority: 2 + implementation_plan: | + 1. Doe X + 2. Doe Y +--- + +# Overwegingen + +Dit is de body, niet geparsed. +` + +describe('parsePlanMd', () => { + it('parses a valid plan', () => { + const r = parsePlanMd(VALID) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.pbi.title).toBe('Test PBI') + expect(r.plan.stories).toHaveLength(1) + expect(r.plan.stories[0].tasks).toHaveLength(1) + expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X') + expect(r.body).toContain('# Overwegingen') + } + }) + + it('rejects when frontmatter is missing', () => { + const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].line).toBe(1) + expect(r.errors[0].message).toMatch(/frontmatter/i) + } + }) + + it('reports yaml syntax error with line info', () => { + const broken = `--- +pbi: + title: Test + priority: [unclosed +stories: + - foo +--- + +body +` + const r = parsePlanMd(broken) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].message.length).toBeGreaterThan(0) + } + }) + + it('reports schema-validation error when pbi-section missing', () => { + const noPbi = `--- +stories: + - title: x + priority: 2 + tasks: + - title: y + priority: 2 +--- + +body +` + const r = parsePlanMd(noPbi) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true) + } + }) + + it('rejects empty stories array', () => { + const noStories = `--- +pbi: + title: x + priority: 2 +stories: [] +--- + +body +` + const r = parsePlanMd(noStories) + expect(r.ok).toBe(false) + }) + + it('handles CRLF line endings', () => { + const crlf = VALID.replace(/\n/g, '\r\n') + const r = parsePlanMd(crlf) + expect(r.ok).toBe(true) + }) +}) diff --git a/lib/idea-code-server.ts b/lib/idea-code-server.ts new file mode 100644 index 0000000..9f26aed --- /dev/null +++ b/lib/idea-code-server.ts @@ -0,0 +1,26 @@ +// Atomic per-user idea-code generator (DB-side). +// Schema: User.idea_code_counter Int @default(0) — increment-and-return via +// Prisma `update` (which acquires a row-lock for the duration of the +// transaction; concurrent calls serialize). Format: "IDEA-001", "IDEA-002", … +// +// Concurrency: vertrouwt op Postgres row-locking binnen Prisma `update`. +// Geen aparte $transaction nodig voor enkelvoudige update — de update is +// atomisch op één rij. Voor combineren met een idea.create wordt +// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller. + +import { prisma } from '@/lib/prisma' +import { formatIdeaCode } from '@/lib/idea-code' + +import type { Prisma } from '@prisma/client' + +export async function nextIdeaCode( + userId: string, + client: Prisma.TransactionClient | typeof prisma = prisma, +): Promise { + const u = await client.user.update({ + where: { id: userId }, + data: { idea_code_counter: { increment: 1 } }, + select: { idea_code_counter: true }, + }) + return formatIdeaCode(u.idea_code_counter) +} diff --git a/lib/idea-code.ts b/lib/idea-code.ts new file mode 100644 index 0000000..dfb1536 --- /dev/null +++ b/lib/idea-code.ts @@ -0,0 +1,8 @@ +// Pure helpers voor IDEA-codes. Geen DB-imports — daarom client-safe. +// De DB-mutating nextIdeaCode staat in lib/idea-code-server.ts. + +const PAD = 3 // "IDEA-001". Bumps to 4 digits at counter 1000 organically. + +export function formatIdeaCode(n: number): string { + return `IDEA-${String(n).padStart(PAD, '0')}` +} diff --git a/lib/idea-plan-parser.ts b/lib/idea-plan-parser.ts new file mode 100644 index 0000000..02b968b --- /dev/null +++ b/lib/idea-plan-parser.ts @@ -0,0 +1,73 @@ +// Parser voor de plan_md die make-plan-job produceert. +// Format: yaml-frontmatter (structuur, parseerbaar) + markdown-body (vrije +// reasoning). Frontmatter wordt gevalideerd via ideaPlanMdFrontmatterSchema. +// +// Wordt zowel door de server-action materializeIdeaPlanAction als door de +// MCP-tool update_idea_plan_md gebruikt. Synchroon — geen LLM-call. +// +// Zie docs/plans/M12-ideas.md "Plan-md formaat A" voor het format-voorbeeld. + +import { parse as parseYaml, YAMLParseError } from 'yaml' +import { + ideaPlanMdFrontmatterSchema, + type IdeaPlanFrontmatter, +} from '@/lib/schemas/idea' + +export type PlanParseError = { line?: number; message: string } + +export type PlanParseResult = + | { ok: true; plan: IdeaPlanFrontmatter; body: string } + | { ok: false; errors: PlanParseError[] } + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +export function parsePlanMd(md: string): PlanParseResult { + const match = md.match(FRONTMATTER_RE) + if (!match) { + return { + ok: false, + errors: [ + { + line: 1, + message: + 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---', + }, + ], + } + } + + const [, frontmatterRaw, body] = match + + let parsed: unknown + try { + parsed = parseYaml(frontmatterRaw) + } catch (err) { + if (err instanceof YAMLParseError) { + return { + ok: false, + errors: [ + { + line: err.linePos?.[0]?.line, + message: err.message, + }, + ], + } + } + return { + ok: false, + errors: [{ message: err instanceof Error ? err.message : String(err) }], + } + } + + const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed) + if (!validation.success) { + return { + ok: false, + errors: validation.error.issues.map((iss) => ({ + message: `${iss.path.join('.') || ''}: ${iss.message}`, + })), + } + } + + return { ok: true, plan: validation.data, body: body.trimStart() } +} diff --git a/package-lock.json b/package-lock.json index cd1cafe..15a386a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" }, @@ -21518,10 +21519,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index fe08b37..f6c340d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" }, From dd935c22d3ac24831297a81933d5e859ee5422ad Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:41:56 +0200 Subject: [PATCH 05/26] prompts: embedded grill + make-plan prompts for IDEA_* jobs (M12 T-495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/idea-prompts/grill.md: interview-loop prompt; uses ask_user_question MCP one-question-at-a-time; stop-condition (title + scope + 3+ AC + 1+ risk); ends with update_idea_grill_md - lib/idea-prompts/make-plan.md: single-pass planning prompt; reads grill_md + repo; produces strict yaml-frontmatter format consumable by parsePlanMd; forbids ask_user_question (twijfels → re-grill via UI) Both in Dutch, consistent with rest of scrum4me content. No external anthropic-skills dependency: scrum4me owns these prompts. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/idea-prompts/grill.md | 98 ++++++++++++++++++++++++++ lib/idea-prompts/make-plan.md | 129 ++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 lib/idea-prompts/grill.md create mode 100644 lib/idea-prompts/make-plan.md diff --git a/lib/idea-prompts/grill.md b/lib/idea-prompts/grill.md new file mode 100644 index 0000000..d5af711 --- /dev/null +++ b/lib/idea-prompts/grill.md @@ -0,0 +1,98 @@ +# Grill-prompt voor IDEA_GRILL-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt +> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill +> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen +> versie zodat de flow reproduceerbaar is op elke worker. + +--- + +Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: +`{idea_title}`). + +Je context (meegegeven in `wait_for_job`-payload): + +- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` +- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) +- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) + +## Doel + +Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar +PBI van kan maken. Eindresultaat is een markdown-document dat je via +`mcp__scrum4me__update_idea_grill_md` opslaat. + +## Werkwijze (loop, één vraag per cyclus) + +1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig) + `idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit + het niet weg. +2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante + source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal. +3. Stel **één scherpe vraag tegelijk** via + `mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht + op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`). +4. Verwerk het antwoord: log belangrijke beslissingen via + `mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE', + content })`. +5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie). +6. Schrijf het eindresultaat via + `mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`. +7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. + +## Stop-conditie + +Je hebt genoeg wanneer je markdown bevat: + +- **Titel + scope** (1–3 zinnen) +- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken) +- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden) +- **Open eindjes** (wat opzettelijk **niet** in v1 zit) + +Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". + +## Output-format (strikt) + +```markdown +# Idee — {korte titel} + +## Scope +… + +## Acceptatie +- AC 1 +- AC 2 +- AC 3 + +## Risico's & onbekenden +- Risico 1 +- Onbekende 2 + +## Open eindjes (niet in v1) +- … +``` + +## Vraag-richtlijnen + +- **Scherp & specifiek**, geen open "wat denk je ervan?". +- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`. +- Stel **één vraag per cyclus** — niet meerdere geneste. +- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf. +- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt. + +## Foutgevallen + +- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`. +- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`. +- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af. + +## Voorbeeld-vraag + +``` +ask_user_question({ + idea_id, + question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?", + options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"], +}) +``` diff --git a/lib/idea-prompts/make-plan.md b/lib/idea-prompts/make-plan.md new file mode 100644 index 0000000..ea7f1a8 --- /dev/null +++ b/lib/idea-prompts/make-plan.md @@ -0,0 +1,129 @@ +# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze +> 8). Twijfels → terug naar grill via UI. + +--- + +Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. + +Je context (meegegeven in `wait_for_job`-payload): + +- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je + primaire input. +- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als + referentie. +- `product`: gekoppeld product met `repo_url`, `definition_of_done`, + bestaande architectuur in repo. + +## Doel + +Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md` +opslaat. Dit document wordt later **deterministisch** geparseerd door de +server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in +PBI + stories + taken via `materializeIdeaPlanAction`. + +## Werkwijze (single-pass) + +1. Lees `idea.grill_md` volledig. +2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur. +3. Bouw het plan op in de **strikte format** hieronder. +4. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. +5. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. + +## STEL GEEN VRAGEN + +`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je +informatie mist die je nodig hebt om het plan compleet te maken, schrijf je +plan met je beste aanname en documenteer je in de **Body** (zie hieronder) +welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY` +en kan dan handmatig editen of een re-grill triggeren. + +## Output-format (strikt — frontmatter wordt server-side geparseerd) + +````markdown +--- +pbi: + title: "Korte PBI-titel (≤200 chars)" + description: | + 1-3 zinnen die de PBI samenvatten. + priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have +stories: + - title: "Story 1 titel" + description: | + Wat deze story bereikt vanuit user-perspectief. + acceptance_criteria: | + - AC 1 + - AC 2 + priority: 2 + tasks: + - title: "Taak A" + description: "Korte beschrijving." + implementation_plan: | + 1. Bestand X aanpassen — concrete steps + 2. Test toevoegen Y + 3. Verifieer Z + priority: 2 + verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY + verify_only: false # true voor pure verify-passes + - title: "Taak B" + priority: 2 + implementation_plan: | + ... + - title: "Story 2 titel" + priority: 2 + tasks: + - title: "..." + priority: 2 +--- + +# Overwegingen + +(Vrije body — niet geparsed door materialize, wordt opgeslagen in +IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.) + +Beschrijf: +- Waarom deze opdeling in stories/taken +- Welke aannames je hebt gemaakt (indien grill onvolledig was) +- Architectuur-keuzes & verwijzingen naar bestaande modules in repo + +# Alternatieven + +- Optie X (verworpen omdat …) +- Optie Y (overwogen voor v2 …) + +# Beslissingen + +- ... + +# Aannames (indien van toepassing) + +- ... +```` + +## Validatie-regels die de parser afdwingt + +- `pbi.title`: 1–200 chars, **verplicht**. +- `pbi.priority`, `story.priority`, `task.priority`: integer 1–4. +- Minimaal 1 story; per story minimaal 1 taak. +- `implementation_plan`: max 8000 chars. +- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`. +- Alle string-velden trimmen, geen lege strings. + +Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat +regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen. + +## Schaal-richtlijnen (geen harde limieten) + +- 1 PBI per idee. +- 2–6 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau). +- 2–5 taken per story. +- Eén taak ≈ 30 min – paar uur werk; **`implementation_plan` is concreet** + (bestandsnamen, commando's, regels code), niet abstract. + +## Voorbeelden van goede vs slechte taken + +❌ **Slecht**: "Maak de feature werkend" +✅ **Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth + +demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath" From 4d2e4b0b4bf1f7d1804c41dfa15da4cae420d47e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:44:48 +0200 Subject: [PATCH 06/26] =?UTF-8?q?fix:=20drop=20\`{=20not:=20null=20}\`=20f?= =?UTF-8?q?ilters=20=E2=80=94=20Prisma=207=20rejects=20them=20at=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrismaClientValidationError ('Argument \`not\` must not be null') hit at runtime when notifications-bridge mounted post-M12 schema change. Although StringNullableFilter typings allow \`not: null\`, the v7 query engine rejects it. Removed the WHERE-side filter in 3 places — null-narrowing already happens client-side via flatMap / Boolean filter: - components/notifications/notifications-bridge.tsx - app/api/realtime/notifications/route.ts - lib/insights/verify-stats.ts (task_id filter) Idea-questions / idea-jobs will be routed via separate channels in T-502 + T-507; for now, story-question + task-job paths simply ignore NULL rows in their post-fetch mapping. Tests: 479/479 green; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/realtime/notifications/route.ts | 4 +++- components/notifications/notifications-bridge.tsx | 5 ++++- lib/insights/verify-stats.ts | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 7a6befa..4fad600 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -132,7 +132,9 @@ export async function GET(request: NextRequest) { status: 'open', expires_at: { gt: new Date() }, product_id: { in: products.map((p) => p.id) }, - story_id: { not: null }, + // Skip idea-questions (story_id NULL) — story-questions only here. + // Narrowing happens in the flatMap below — Prisma 7 rejects + // `story_id: { not: null }` at runtime. }, orderBy: { created_at: 'desc' }, take: 100, diff --git a/components/notifications/notifications-bridge.tsx b/components/notifications/notifications-bridge.tsx index ef7a9fe..e600838 100644 --- a/components/notifications/notifications-bridge.tsx +++ b/components/notifications/notifications-bridge.tsx @@ -28,7 +28,10 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps) status: 'open', expires_at: { gt: new Date() }, product_id: { in: productIds }, - story_id: { not: null }, + // Skip idea-questions (story_id NULL): they have a separate + // realtime channel and aren't shown in this product-scoped bell. + // Narrowing happens in the flatMap below — Prisma 7 rejects + // `story_id: { not: null }` at runtime. }, orderBy: { created_at: 'desc' }, take: 100, diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts index c09806b..0de209f 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -40,7 +40,9 @@ export async function getVerifyResultStats( status: 'DONE' as const, verify_result: { not: null as null }, finished_at: { gt: cutoff }, - task_id: { not: null }, + // Note: task_id can now be NULL on idea-jobs (M12). The toTopJob mapper + // filters them out via .filter(Boolean). Keeping a where-side filter + // (`task_id: { not: null }`) is rejected by Prisma 7 runtime. } const [grouped, rawEmpty, rawDivergent] = await Promise.all([ From 5f410d3b1044a54fb66ad0d35183ad55b274d193 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:47:30 +0200 Subject: [PATCH 07/26] 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 } From 33cbb6c2f451762436ba829c59499c47c3f4891e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:49:27 +0200 Subject: [PATCH 08/26] actions: idea-job triggers + cancel (M12 T-497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/ideas.ts: - startGrillJobAction(id) — DRAFT/GRILLED/GRILL_FAILED/PLAN_READY → GRILLING; validates product+repo_url, idempotency check (active job 409), worker-count check (15s freshness), atomic $transaction creates ClaudeJob + flips idea.status + IdeaLog{JOB_EVENT}, manual pg_notify - startMakePlanJobAction(id) — GRILLED/PLAN_FAILED/PLAN_READY → PLANNING; same shape via shared startIdeaJob helper - cancelIdeaJobAction(id) — finds active QUEUED|CLAIMED|RUNNING job for idea, reverts status: grill→DRAFT/GRILLED based on grill_md presence; plan→GRILLED/PLAN_READY based on plan_md presence Tests: 31 cases incl. happy path, demo-403, no-product/no-repo-422, no-worker-422, idempotency-409, status-mismatch-422, cancel revert paths, 404 no-active-job. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/ideas-crud.test.ts | 153 ++++++++++++++++++++- actions/ideas.ts | 197 ++++++++++++++++++++++++++- 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 6ceba0e..543b42c 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -24,7 +24,16 @@ vi.mock('@/lib/prisma', () => ({ delete: vi.fn(), }, ideaLog: { create: vi.fn() }, + claudeJob: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + claudeWorker: { + count: vi.fn(), + }, $transaction: vi.fn(), + $executeRaw: vi.fn().mockResolvedValue(0), }, })) @@ -37,9 +46,19 @@ import { updateGrillMdAction, updatePlanMdAction, downloadIdeaMdAction, + startGrillJobAction, + startMakePlanJobAction, + cancelIdeaJobAction, } from '@/actions/ideas' -type MockIdea = { idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType }; ideaLog: { create: ReturnType }; $transaction: ReturnType } +type MockIdea = { + idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType } + ideaLog: { create: ReturnType } + claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } + claudeWorker: { count: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} const m = prisma as unknown as MockIdea beforeEach(() => { @@ -207,6 +226,138 @@ body }) }) +describe('startGrillJobAction', () => { + const idea = { + id: 'idea-1', + status: 'DRAFT', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-1' }) + }) + + it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => { + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } }) + expect(m.$executeRaw).toHaveBeenCalled() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when product has no repo_url', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + ...idea, + product: { id: 'prod-1', repo_url: null }, + }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) }) + }) + + it('blocks when no idea is unlinked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when no worker is active', async () => { + m.claudeWorker.count.mockResolvedValueOnce(0) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when an active job already exists (409)', async () => { + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) + + it('blocks invalid status (PLANNING)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('startMakePlanJobAction', () => { + const idea = { + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-2' }) + }) + + it('happy: GRILLED → PLANNING', async () => { + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ success: true }) + }) + + it('blocks from DRAFT (must grill first)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' }) + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('cancelIdeaJobAction', () => { + it('grill cancel without prior grill_md → DRAFT', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + // Verify $transaction was called with 3 ops (job-update, idea-update, log) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('grill re-grill cancel with prior grill_md → GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: '# old grill', + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + }) + + it('returns 404 when no active job', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce(null) + const r = await cancelIdeaJobAction('idea-1') + expect(r).toMatchObject({ code: 404 }) + }) +}) + describe('downloadIdeaMdAction', () => { it('returns grill_md when present', async () => { m.idea.findFirst.mockResolvedValueOnce({ diff --git a/actions/ideas.ts b/actions/ideas.ts index ec05f74..9d1438b 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -17,8 +17,20 @@ 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 { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import type { Idea } from '@prisma/client' +import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' + +// Worker-presence: aligned met /api/realtime/solo. +const WORKER_FRESH_MS = 15_000 +async function countActiveWorkers(userId: string): Promise { + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) }, + }, + }) +} async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -264,6 +276,189 @@ export async function downloadIdeaMdAction( } } +// --------------------------------------------------------------------------- +// Job-triggers (Grill Me / Make Plan / Cancel) + +const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY'] +const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] + +export async function startGrillJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) +} + +export async function startMakePlanJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM) +} + +async function startIdeaJob( + id: string, + kind: ClaudeJobKind, + newStatus: IdeaStatus, + allowedFrom: IdeaStatus[], +): 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('start-idea-job', session.userId) + if (limited) return limited + + // Laad idee + product (voor repo_url-validatie) + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { + id: true, + status: true, + product_id: true, + product: { select: { id: true, repo_url: true } }, + }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!allowedFrom.includes(idea.status)) { + return { + error: `Actie niet toegestaan in status ${idea.status}`, + code: 422, + } + } + if (!canTransition(idea.status, newStatus)) { + return { error: `Status-transitie ${idea.status}→${newStatus} ongeldig`, code: 422 } + } + + // Product-met-repo verplicht (M12 grill-keuze 3) + if (!idea.product_id || !idea.product?.repo_url) { + return { + error: 'Idee moet gekoppeld zijn aan een product met repo_url voordat je dit kunt starten.', + code: 422, + } + } + + // Idempotency: weiger als er al een actieve job loopt voor dit idee. + const existing = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }) + if (existing) { + return { + error: 'Er loopt al een actieve agent voor dit idee.', + code: 409, + details: { job_id: existing.id }, + } + } + + // Worker-presence — server-side check, naast UI-side disabled-rule. + const workers = await countActiveWorkers(session.userId) + if (workers === 0) { + return { + error: 'Geen Claude-worker actief. Start een lokale wait_for_job-loop en probeer opnieuw.', + code: 422, + } + } + + // Atomic: create job + flip idea-status + log. + const job = await prisma.$transaction(async (tx) => { + const j = await tx.claudeJob.create({ + data: { + user_id: session.userId, + product_id: idea.product_id!, + idea_id: id, + kind, + status: 'QUEUED', + }, + select: { id: true }, + }) + await tx.idea.update({ where: { id }, data: { status: newStatus } }) + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${kind} queued`, + metadata: { job_id: j.id, kind }, + }, + }) + return j + }) + + // Manual pg_notify zoals enqueueClaudeJobAction in actions/claude-jobs.ts. + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + idea_id: id, + user_id: session.userId, + product_id: idea.product_id, + kind, + status: 'queued', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true, data: { job_id: job.id } } +} + +export async function cancelIdeaJobAction(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, status: true, grill_md: true, plan_md: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + + // Vind de actieve job — meest recente in QUEUED|CLAIMED|RUNNING. + const job = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + orderBy: { created_at: 'desc' }, + select: { id: true, kind: true }, + }) + if (!job) return { error: 'Geen actieve job om te annuleren', code: 404 } + + // Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er + // al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al + // plan_md was (re-plan-cancel), anders GRILLED. + let revertStatus: IdeaStatus + if (job.kind === 'IDEA_GRILL') { + revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT' + } else if (job.kind === 'IDEA_MAKE_PLAN') { + revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED' + } else { + return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 } + } + + await prisma.$transaction([ + prisma.claudeJob.update({ + where: { id: job.id }, + data: { status: 'CANCELLED', finished_at: new Date(), error: 'user_cancelled' }, + }), + prisma.idea.update({ where: { id }, data: { status: revertStatus } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${job.kind} cancelled by user`, + metadata: { job_id: job.id, revert_status: revertStatus }, + }, + }), + ]) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: job.id, + idea_id: id, + user_id: session.userId, + kind: job.kind, + status: 'cancelled', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true } +} + // --------------------------------------------------------------------------- // Helpers From 6fee0394c5105b79b1bf1e36f360cb3c8a5f40cd Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:51:18 +0200 Subject: [PATCH 09/26] actions: materializeIdeaPlanAction + relinkIdeaPlanAction (M12 T-498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/ideas.ts: - materializeIdeaPlanAction(id): - guard: status===PLAN_READY, plan_md present, product linked, demo-403 - parsePlanMd → 422 with line-info on fail - Prisma.\$transaction: - SELECT max(code) for PBI/Story/Task within product - INSERT PBI with sort_order = lastPbi+1 within priority - per story: INSERT (sequential ST-NNN), per task: INSERT (T-N) - UPDATE idea SET pbi_id, status=PLANNED - INSERT IdeaLog{PLAN_RESULT, metadata} - returns 409 on P2002 (concurrent-materialize race) - relinkIdeaPlanAction(id): - guard: status===PLANNED && pbi_id===null (PBI manually deleted via SetNull FK) - reverts to PLAN_READY + IdeaLog{NOTE} Tests: 39 cases total (8 new for materialize + relink): happy creates entities, status-mismatch-422, parse-fail-422 with details, demo-403, P2002→409, relink happy + invalid-precondition guards. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/ideas-crud.test.ts | 151 +++++++++++++++++++ actions/ideas.ts | 209 +++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 543b42c..525c56f 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -32,6 +32,19 @@ vi.mock('@/lib/prisma', () => ({ claudeWorker: { count: vi.fn(), }, + pbi: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + }, + story: { + findMany: vi.fn(), + create: vi.fn(), + }, + task: { + findMany: vi.fn(), + create: vi.fn(), + }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), }, @@ -49,6 +62,8 @@ import { startGrillJobAction, startMakePlanJobAction, cancelIdeaJobAction, + materializeIdeaPlanAction, + relinkIdeaPlanAction, } from '@/actions/ideas' type MockIdea = { @@ -56,6 +71,9 @@ type MockIdea = { ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } + story: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } @@ -358,6 +376,139 @@ describe('cancelIdeaJobAction', () => { }) }) +describe('materializeIdeaPlanAction', () => { + const VALID_PLAN = `--- +pbi: + title: New PBI + priority: 2 +stories: + - title: Story A + priority: 2 + tasks: + - title: Task A1 + priority: 2 + implementation_plan: "1. Doe X" + - title: Task A2 + priority: 2 + - title: Story B + priority: 3 + tasks: + - title: Task B1 + priority: 3 +--- + +body +` + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + m.pbi.findMany.mockResolvedValue([]) + m.story.findMany.mockResolvedValue([]) + m.task.findMany.mockResolvedValue([]) + m.pbi.findFirst.mockResolvedValue(null) + m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' }) + m.story.create + .mockResolvedValueOnce({ id: 's-A' }) + .mockResolvedValueOnce({ id: 's-B' }) + m.task.create + .mockResolvedValueOnce({ id: 't-A1' }) + .mockResolvedValueOnce({ id: 't-A2' }) + .mockResolvedValueOnce({ id: 't-B1' }) + }) + + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => { + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ + success: true, + data: { + pbi_id: 'pbi-1', + pbi_code: 'PBI-1', + story_ids: ['s-A', 's-B'], + task_ids: ['t-A1', 't-A2', 't-B1'], + }, + }) + expect(m.pbi.create).toHaveBeenCalledTimes(1) + expect(m.story.create).toHaveBeenCalledTimes(2) + expect(m.task.create).toHaveBeenCalledTimes(3) + }) + + it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.pbi.create).not.toHaveBeenCalled() + }) + + it('returns 422 with details on parse-fail', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: '# no frontmatter', + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + }) + + it('returns 409 on P2002 race', async () => { + m.$transaction.mockImplementationOnce(async () => { + throw new Error('Unique constraint failed (P2002)') + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) +}) + +describe('relinkIdeaPlanAction', () => { + it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('blocks when pbi still linked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: 'pbi-1', + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when not PLANNED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + describe('downloadIdeaMdAction', () => { it('returns grill_md when present', async () => { m.idea.findFirst.mockResolvedValueOnce({ diff --git a/actions/ideas.ts b/actions/ideas.ts index 9d1438b..dea31c7 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -459,6 +459,215 @@ export async function cancelIdeaJobAction(id: string): Promise { return { success: true } } +// --------------------------------------------------------------------------- +// Materialize: parse plan_md → INSERT PBI + stories + taken (atomic) + +const PBI_AUTO_RE = /^PBI-(\d+)$/ +const STORY_AUTO_RE = /^ST-(\d+)$/ +const TASK_AUTO_RE = /^T-(\d+)$/ + +function nextNumber(existing: (string | null)[], re: RegExp): number { + let max = 0 + for (const c of existing) { + if (!c) continue + const m = c.match(re) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return max + 1 +} + +export async function materializeIdeaPlanAction( + 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 limited = enforceUserRateLimit('materialize-idea', session.userId) + if (limited) return limited + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, product_id: true, plan_md: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLAN_READY') { + return { + error: `Materialiseren alleen toegestaan in PLAN_READY (huidige status: ${idea.status})`, + code: 422, + } + } + if (!idea.product_id) { + return { error: 'Idee mist een gekoppeld product', code: 422 } + } + if (!idea.plan_md) { + return { error: 'Idee heeft geen plan_md', code: 422 } + } + + const parsed = parsePlanMd(idea.plan_md) + if (!parsed.ok) { + return { error: 'plan_md is niet parseerbaar', code: 422, details: parsed.errors } + } + + const productId = idea.product_id + const plan = parsed.plan + + try { + const result = await prisma.$transaction(async (tx) => { + // Codes: één keer SELECT max per type binnen de transactie. Bij P2002 + // (race met andere materialize) abort de transactie en gooien we 409. + const [existingPbis, existingStories, existingTasks] = await Promise.all([ + tx.pbi.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.story.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.task.findMany({ where: { product_id: productId }, select: { code: true } }), + ]) + let nextPbiN = nextNumber(existingPbis.map((p) => p.code), PBI_AUTO_RE) + let nextStoryN = nextNumber(existingStories.map((s) => s.code), STORY_AUTO_RE) + let nextTaskN = nextNumber(existingTasks.map((t) => t.code), TASK_AUTO_RE) + + // sort_order: vraag de huidige max binnen het product op (per priority) + const lastPbi = await tx.pbi.findFirst({ + where: { product_id: productId, priority: plan.pbi.priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + const pbiSortOrder = (lastPbi?.sort_order ?? 0) + 1.0 + + const pbi = await tx.pbi.create({ + data: { + product_id: productId, + code: `PBI-${nextPbiN++}`, + title: plan.pbi.title, + description: plan.pbi.description ?? null, + priority: plan.pbi.priority, + sort_order: pbiSortOrder, + }, + select: { id: true, code: true }, + }) + + const storyIds: string[] = [] + const taskIds: string[] = [] + + for (let si = 0; si < plan.stories.length; si++) { + const s = plan.stories[si] + const story = await tx.story.create({ + data: { + pbi_id: pbi.id, + product_id: productId, + code: `ST-${String(nextStoryN++).padStart(3, '0')}`, + title: s.title, + description: s.description ?? null, + acceptance_criteria: s.acceptance_criteria ?? null, + priority: s.priority, + sort_order: si + 1, // sequential within PBI + status: 'OPEN', + }, + select: { id: true }, + }) + storyIds.push(story.id) + + for (let ti = 0; ti < s.tasks.length; ti++) { + const t = s.tasks[ti] + const task = await tx.task.create({ + data: { + story_id: story.id, + product_id: productId, + code: `T-${nextTaskN++}`, + title: t.title, + description: t.description ?? null, + implementation_plan: t.implementation_plan ?? null, + priority: t.priority, + sort_order: ti + 1, + status: 'TO_DO', + verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', + verify_only: t.verify_only ?? false, + }, + select: { id: true }, + }) + taskIds.push(task.id) + } + } + + // Link idea → PBI + status PLANNED + await tx.idea.update({ + where: { id }, + data: { pbi_id: pbi.id, status: 'PLANNED' }, + }) + + // Audit log + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'PLAN_RESULT', + content: `Materialized into ${pbi.code} (${plan.stories.length} stories, ${taskIds.length} tasks)`, + metadata: { + pbi_id: pbi.id, + pbi_code: pbi.code, + story_count: storyIds.length, + task_count: taskIds.length, + }, + }, + }) + + return { pbi_id: pbi.id, pbi_code: pbi.code, story_ids: storyIds, task_ids: taskIds } + }) + + revalidatePath(`/ideas/${id}`) + revalidatePath(`/products/${productId}/backlog`) + return { success: true, data: result } + } catch (err) { + // P2002 op code = race met andere materialize. Andere fouten = bug. + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('P2002') || msg.includes('Unique constraint')) { + return { + error: 'Code-conflict tijdens materialiseren (race). Probeer opnieuw.', + code: 409, + } + } + throw err + } +} + +// --------------------------------------------------------------------------- +// Re-link: een idee in PLANNED waarvan de PBI handmatig is verwijderd +// (Pbi.id → null door de SetNull-FK). Gebruiker klikt expliciet "Re-link plan" +// om terug naar PLAN_READY te gaan en eventueel opnieuw te materialiseren. + +export async function relinkIdeaPlanAction(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, status: true, pbi_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLANNED' || idea.pbi_id !== null) { + return { + error: 'Re-link kan alleen wanneer status=PLANNED én PBI is verwijderd', + code: 422, + } + } + + await prisma.$transaction([ + prisma.idea.update({ where: { id }, data: { status: 'PLAN_READY' } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'PBI was deleted; relinked to PLAN_READY', + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + // --------------------------------------------------------------------------- // Helpers From 6904de9f2b40dd7ced1bcd670e04d177535da136 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:52:37 +0200 Subject: [PATCH 10/26] actions: promoteTodoToIdeaAction (M12 T-499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/todos.ts: - promoteTodoToIdeaAction(todoId): auth + demo + scope + already-archived guards. Atomic \$transaction creates DRAFT Idea (with auto IDEA-NNN code) and archives source Todo + IdeaLog{NOTE}. - Anders dan Todo→PBI/Story (die de todo deleten): we ARCHIVEREN. De idea wordt het nieuwe planningsartifact; de archived todo bewaart het vertrekpunt (zie M12 grill-keuze 12). Tests: 5 cases — happy, auth-401, demo-403, scope-404, already-archived-422. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/todos-promote-idea.test.ts | 114 +++++++++++++++++++ actions/todos.ts | 54 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 __tests__/actions/todos-promote-idea.test.ts diff --git a/__tests__/actions/todos-promote-idea.test.ts b/__tests__/actions/todos-promote-idea.test.ts new file mode 100644 index 0000000..7ddb169 --- /dev/null +++ b/__tests__/actions/todos-promote-idea.test.ts @@ -0,0 +1,114 @@ +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-005'), +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/code-server', () => ({ + generateNextPbiCode: vi.fn(), + generateNextStoryCode: vi.fn(), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + todo: { + findFirst: vi.fn(), + update: vi.fn(), + }, + idea: { + create: vi.fn(), + }, + ideaLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { promoteTodoToIdeaAction } from '@/actions/todos' + +type M = { + todo: { findFirst: ReturnType; update: ReturnType } + idea: { create: ReturnType } + ideaLog: { create: ReturnType } + $transaction: ReturnType +} +const m = prisma as unknown as M + +beforeEach(() => { + vi.clearAllMocks() + mockSession.userId = 'user-1' + mockSession.isDemo = false + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: unknown) => unknown)(m) + } + return arg + }) +}) + +describe('promoteTodoToIdeaAction', () => { + it('happy: archives todo, creates DRAFT idea, returns idea_id', async () => { + m.todo.findFirst.mockResolvedValueOnce({ + id: 'todo-1', + title: 'My idea', + description: 'desc', + product_id: null, + archived: false, + }) + m.idea.create.mockResolvedValueOnce({ id: 'idea-9', code: 'IDEA-005' }) + + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ success: true, idea_id: 'idea-9', idea_code: 'IDEA-005' }) + expect(m.todo.update).toHaveBeenCalledWith({ + where: { id: 'todo-1' }, + data: { archived: true }, + }) + }) + + it('rejects unauthenticated', async () => { + mockSession.userId = '' + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 401 }) + }) + + it('rejects demo-user', async () => { + mockSession.isDemo = true + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 403 }) + }) + + it('returns 404 when todo belongs to another user', async () => { + m.todo.findFirst.mockResolvedValueOnce(null) + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 404 }) + }) + + it('rejects already-archived todo', async () => { + m.todo.findFirst.mockResolvedValueOnce({ + id: 'todo-1', + title: 'x', + description: null, + product_id: null, + archived: true, + }) + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) +}) diff --git a/actions/todos.ts b/actions/todos.ts index 7720eb4..02e4864 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -241,6 +241,60 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo return { success: true } } +// M12: promote a Todo into a DRAFT Idea. Anders dan Todo→PBI/Story (die de +// todo deleteert) ARCHIVEREN we de todo hier — het idee houdt zelf de +// planningsgeschiedenis bij, en de archived todo bewaart het oorspronkelijke +// vertrekpunt. +export async function promoteTodoToIdeaAction(todoId: string): Promise< + { success: true; idea_id: string; idea_code: string } | { error: string; code?: number } +> { + 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 } + + if (!todoId) return { error: 'todoId is verplicht', code: 422 } + + const todo = await prisma.todo.findFirst({ + where: { id: todoId, user_id: session.userId }, + select: { id: true, title: true, description: true, product_id: true, archived: true }, + }) + if (!todo) return { error: 'Todo niet gevonden', code: 404 } + if (todo.archived) return { error: 'Todo is al gearchiveerd', code: 422 } + + const userId = session.userId + // Lazy-import om dit server-only bestand niet te dwingen in een client bundle. + const { nextIdeaCode } = await import('@/lib/idea-code-server') + + const idea = await prisma.$transaction(async (tx) => { + const code = await nextIdeaCode(userId, tx) + const created = await tx.idea.create({ + data: { + user_id: userId, + product_id: todo.product_id, + code, + title: todo.title, + description: todo.description ?? null, + status: 'DRAFT', + }, + select: { id: true, code: true }, + }) + await tx.todo.update({ where: { id: todoId }, data: { archived: true } }) + await tx.ideaLog.create({ + data: { + idea_id: created.id, + type: 'NOTE', + content: `Promoted from Todo ${todoId}`, + metadata: { source_todo_id: todoId }, + }, + }) + return created + }) + + revalidatePath('/ideas') + revalidatePath('/todos') + return { success: true, idea_id: idea.id, idea_code: idea.code } +} + export async function updateRolesAction(roles: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } From 4b234dc3000fabb151fd6a63dbd35e3675463626 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:55:49 +0200 Subject: [PATCH 11/26] api: REST endpoints for ideas (M12 T-500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/api/ideas/route.ts: GET (list with archived/product_id/status filters, user_id-scope), POST (creates DRAFT with auto IDEA-NNN code, 201) - app/api/ideas/[id]/route.ts: GET (idea + recent logs), PATCH (ideaUpdateSchema, isIdeaEditable guard) - lib/idea-dto.ts: API projection — converts Prisma row → DTO with lowercase status + has_grill_md/has_plan_md flags (md content excluded from list payloads, fetch via dedicated download action) Auth: session OR API-token via authenticateApiRequest. Strict user_id scope (no productAccessFilter — Idee is privé per Q8). 404 (not 403) for foreign-user reads to prevent enumeration. Tests: 13 cases (auth-401, demo-403, validation-422, malformed-400, not-found-404, status-mismatch-422, filter param round-trip, DTO shape). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/ideas.test.ts | 194 ++++++++++++++++++++++++++++++++++++ app/api/ideas/[id]/route.ts | 91 +++++++++++++++++ app/api/ideas/route.ts | 94 +++++++++++++++++ lib/idea-dto.ts | 49 +++++++++ 4 files changed, 428 insertions(+) create mode 100644 __tests__/api/ideas.test.ts create mode 100644 app/api/ideas/[id]/route.ts create mode 100644 app/api/ideas/route.ts create mode 100644 lib/idea-dto.ts diff --git a/__tests__/api/ideas.test.ts b/__tests__/api/ideas.test.ts new file mode 100644 index 0000000..448cc6b --- /dev/null +++ b/__tests__/api/ideas.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + idea: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + ideaLog: { findMany: vi.fn() }, + $transaction: vi.fn(), + }, +})) +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) +vi.mock('@/lib/idea-code-server', () => ({ + nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route' +import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route' + +type M = { + product: { findFirst: ReturnType } + idea: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } + ideaLog: { findMany: ReturnType } + $transaction: ReturnType +} +const m = prisma as unknown as M +const mockAuth = authenticateApiRequest as ReturnType + +const NOW = new Date('2026-05-04T19:00:00Z') + +const IDEA_ROW = { + id: 'idea-1', + user_id: 'user-1', + code: 'IDEA-001', + title: 'Plant-watering reminder', + description: null, + status: 'DRAFT' as const, + product_id: null, + product: null, + pbi: null, + pbi_id: null, + archived: false, + grill_md: null, + plan_md: null, + created_at: NOW, + updated_at: NOW, +} + +function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request { + return new Request(`http://localhost${url}`, { + method, + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m) + return arg + }) +}) + +describe('GET /api/ideas', () => { + it('returns user ideas (DTO shape)', async () => { + m.idea.findMany.mockResolvedValueOnce([IDEA_ROW]) + const res = await getIdeas(makeRequest('GET', '/api/ideas')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.ideas).toHaveLength(1) + expect(body.ideas[0]).toMatchObject({ + id: 'idea-1', + code: 'IDEA-001', + status: 'draft', + has_grill_md: false, + }) + }) + + it('rejects unauthenticated', async () => { + mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 }) + const res = await getIdeas(makeRequest('GET', '/api/ideas')) + expect(res.status).toBe(401) + }) + + it('filters by archived=false param', async () => { + m.idea.findMany.mockResolvedValueOnce([]) + await getIdeas(makeRequest('GET', '/api/ideas?archived=false')) + expect(m.idea.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ archived: false, user_id: 'user-1' }), + }), + ) + }) +}) + +describe('POST /api/ideas', () => { + it('creates idea and returns 201', async () => { + m.idea.create.mockResolvedValueOnce(IDEA_ROW) + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' })) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' }) + }) + + it('rejects demo with 403', async () => { + mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' })) + expect(res.status).toBe(403) + }) + + it('rejects empty title with 422', async () => { + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' })) + expect(res.status).toBe(422) + }) + + it('rejects malformed JSON with 400', async () => { + const req = new Request('http://localhost/api/ideas', { + method: 'POST', + headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' }, + body: 'not-json', + }) + const res = await postIdea(req) + expect(res.status).toBe(400) + }) + + it('returns 404 when product_id refers to a foreign product', async () => { + m.product.findFirst.mockResolvedValueOnce(null) + const res = await postIdea( + makeRequest('POST', '/api/ideas', { + title: 'x', + product_id: 'cmohrysyj0000rd17clnjy4tc', + }), + ) + expect(res.status).toBe(404) + }) +}) + +describe('GET /api/ideas/[id]', () => { + it('returns idea + logs', async () => { + m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW) + m.ideaLog.findMany.mockResolvedValueOnce([ + { id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW }, + ]) + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.idea).toMatchObject({ id: 'idea-1' }) + expect(body.logs).toHaveLength(1) + }) + + it('returns 404 (not 403) for foreign user — anti-enumeration', async () => { + m.idea.findFirst.mockResolvedValueOnce(null) + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) + expect(res.status).toBe(404) + }) +}) + +describe('PATCH /api/ideas/[id]', () => { + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + + it('updates editable idea', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) + m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx) + expect(res.status).toBe(200) + }) + + it('blocks demo with 403', async () => { + mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) + expect(res.status).toBe(403) + }) + + it('blocks update on PLANNED with 422', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) + expect(res.status).toBe(422) + }) +}) diff --git a/app/api/ideas/[id]/route.ts b/app/api/ideas/[id]/route.ts new file mode 100644 index 0000000..4c547c3 --- /dev/null +++ b/app/api/ideas/[id]/route.ts @@ -0,0 +1,91 @@ +// Per-idea REST endpoints (M12). user_id-strict scope, 404 (niet 403) bij +// foreign user om enumeratie te vermijden. + +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { ideaUpdateSchema } from '@/lib/schemas/idea' +import { isIdeaEditable } from '@/lib/idea-status' +import { ideaToDto } from '@/lib/idea-dto' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function GET(request: Request, ctx: RouteContext) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await ctx.params + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: auth.userId }, + include: { + product: { select: { id: true, name: true, repo_url: true } }, + pbi: { select: { id: true, code: true, title: true } }, + }, + }) + if (!idea) { + return Response.json({ error: 'Idee niet gevonden' }, { status: 404 }) + } + + // Recente logs (max 50) — handig voor MCP tools die context willen ophalen. + const logs = await prisma.ideaLog.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'desc' }, + take: 50, + select: { id: true, type: true, content: true, metadata: true, created_at: true }, + }) + + return Response.json({ idea: ideaToDto(idea), logs }) +} + +export async function PATCH(request: Request, ctx: RouteContext) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + const { id } = await ctx.params + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = ideaUpdateSchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: auth.userId }, + select: { id: true, status: true }, + }) + if (!idea) { + return Response.json({ error: 'Idee niet gevonden' }, { status: 404 }) + } + if (!isIdeaEditable(idea.status)) { + return Response.json( + { error: `Idee niet bewerkbaar in status ${idea.status}` }, + { status: 422 }, + ) + } + + const updated = 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 } : {}), + }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, + }) + + return Response.json({ idea: ideaToDto(updated) }) +} diff --git a/app/api/ideas/route.ts b/app/api/ideas/route.ts new file mode 100644 index 0000000..84d1ad7 --- /dev/null +++ b/app/api/ideas/route.ts @@ -0,0 +1,94 @@ +// REST endpoints voor de Idee-entity (M12). +// - Strikt user_id-only — geen productAccessFilter. +// - Auth via session OF API-token (zelfde patroon als /api/todos). +// - Demo blokkeert POST/PATCH/DELETE (proxy.ts laag + 403 hier als second-line). + +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { ideaCreateSchema } from '@/lib/schemas/idea' +import { ideaStatusFromApi, ideaStatusToApi } from '@/lib/idea-status' +import { nextIdeaCode } from '@/lib/idea-code-server' +import { ideaToDto } from '@/lib/idea-dto' + +export async function GET(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const url = new URL(request.url) + const archivedParam = url.searchParams.get('archived') + const productIdParam = url.searchParams.get('product_id') + const statusParam = url.searchParams.get('status') + + const archived = + archivedParam === 'true' ? true : archivedParam === 'false' ? false : undefined + const status = statusParam ? ideaStatusFromApi(statusParam) ?? undefined : undefined + + const ideas = await prisma.idea.findMany({ + where: { + user_id: auth.userId, + ...(archived !== undefined ? { archived } : {}), + ...(productIdParam ? { product_id: productIdParam } : {}), + ...(status ? { status } : {}), + }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, + orderBy: { created_at: 'desc' }, + take: 200, + }) + + return Response.json({ ideas: ideas.map(ideaToDto) }) +} + +export async function POST(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = ideaCreateSchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + // Optionele product-binding: alleen toelaten als gebruiker eigenaar/member is. + if (parsed.data.product_id) { + const product = await prisma.product.findFirst({ + where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, + select: { id: true }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + } + + const userId = auth.userId + 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', + }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, + }) + }) + + return Response.json( + { idea: { ...ideaToDto(idea), status: ideaStatusToApi(idea.status) } }, + { status: 201 }, + ) +} diff --git a/lib/idea-dto.ts b/lib/idea-dto.ts new file mode 100644 index 0000000..b32d14a --- /dev/null +++ b/lib/idea-dto.ts @@ -0,0 +1,49 @@ +// API-projection voor Idea — converteert Prisma-row naar het externe contract. +// Belangrijk: status wordt naar lowercase API-string vertaald (zelfde patroon +// als TaskStatus / StoryStatus / PbiStatus elders in de codebase). + +import { ideaStatusToApi } from '@/lib/idea-status' + +import type { Idea, IdeaStatus, Product } from '@prisma/client' + +type IdeaWithProduct = Idea & { + product: Pick | null + pbi?: { id: string; code: string; title: string } | null +} + +export interface IdeaDto { + id: string + code: string + title: string + description: string | null + status: ReturnType + product_id: string | null + product: { id: string; name: string; repo_url: string | null } | null + pbi_id: string | null + pbi?: { id: string; code: string; title: string } | null + archived: boolean + has_grill_md: boolean + has_plan_md: boolean + created_at: string + updated_at: string +} + +export function ideaToDto(idea: IdeaWithProduct & { status: IdeaStatus }): IdeaDto { + return { + id: idea.id, + code: idea.code, + title: idea.title, + description: idea.description, + status: ideaStatusToApi(idea.status), + product_id: idea.product_id, + product: idea.product, + pbi_id: idea.pbi_id, + pbi: idea.pbi ?? null, + archived: idea.archived, + // Geen md-content in lijst-payloads (kan groot zijn) — enkel een vlag. + has_grill_md: idea.grill_md !== null, + has_plan_md: idea.plan_md !== null, + created_at: idea.created_at.toISOString(), + updated_at: idea.updated_at.toISOString(), + } +} From a1d1f99216e53c0051043b7fb50e0ace47822b88 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:56:41 +0200 Subject: [PATCH 12/26] proxy: add /ideas to protectedRoutes; verify demo-guard for /api/ideas (M12 T-501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy.ts: /ideas added to protectedRoutes — unauthenticated users get redirected to /login when navigating to /ideas or /ideas/[id] - existing demo-guard catch-all (\`/api/* + non-GET\`) already blocks POST/PATCH/DELETE /api/ideas* with 403 — confirmed via 3 new tests - server-action endpoints (start-grill / start-make-plan / materialize / promote-to-idea) carry their own \`session.isDemo\` checks inside actions/ideas.ts and actions/todos.ts (defense in depth) Tests: 9/9 in proxy demo-guard suite (added 3 idea cases). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/proxy/demo-guard.test.ts | 20 ++++++++++++++++++++ proxy.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/__tests__/proxy/demo-guard.test.ts b/__tests__/proxy/demo-guard.test.ts index f229a8f..1ae94a2 100644 --- a/__tests__/proxy/demo-guard.test.ts +++ b/__tests__/proxy/demo-guard.test.ts @@ -30,6 +30,26 @@ beforeEach(() => { }) describe('proxy demo-guard', () => { + it('demo + POST /api/ideas → 403 (M12)', async () => { + mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + const req = makeRequest('POST', '/api/ideas', true) + const res = await proxy(req) + expect(res?.status).toBe(403) + }) + + it('demo + PATCH /api/ideas/abc → 403 (M12)', async () => { + mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + const req = makeRequest('PATCH', '/api/ideas/abc', true) + const res = await proxy(req) + expect(res?.status).toBe(403) + }) + + it('demo + GET /api/ideas → passthrough (M12)', async () => { + const req = makeRequest('GET', '/api/ideas', true) + const res = await proxy(req) + expect(res?.status).not.toBe(403) + }) + it('demo + POST /api/todos → 403', async () => { mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) const req = makeRequest('POST', '/api/todos', true) diff --git a/proxy.ts b/proxy.ts index afbfd55..24fc34d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server' import { unsealData } from 'iron-session' import { sessionOptions, type SessionData } from '@/lib/session' -const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] +const protectedRoutes = ['/dashboard', '/products', '/todos', '/ideas', '/settings', '/solo'] const authRoutes = ['/login', '/register'] const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) From 0e2808ac88c8877278e115d249178966122c65c0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 20:00:05 +0200 Subject: [PATCH 13/26] realtime: route idea-jobs + idea-questions to /notifications channel (M12 T-502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idea-jobs and idea-questions are user-private (M12 grill-keuze 8) — they flow through /api/realtime/notifications, not /api/realtime/solo. app/api/realtime/notifications/route.ts: - Pre-fetch user's idea-ids → accessibleIdeaIds Set (avoids per-event DB lookup) - New IdeaJobPayload type (claude_job_enqueued/_status with kind=IDEA_*) - New QuestionPayload narrows: story_id and idea_id mutually exclusive (DB check-constraint enforces it) - Routing: idea-jobs filtered on user_id; idea-questions on accessibleIdeaIds; story-questions on accessibleProductIds (existing path) app/api/realtime/solo/route.ts: - JobPayload extended with optional kind + idea_id - shouldEmit filters out kind=IDEA_GRILL/IDEA_MAKE_PLAN — they don't belong on the product/sprint Solo Paneel Tests: 539/539 green; notifications-stream test mock updated for idea.findMany. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/notifications-stream.test.ts | 1 + app/api/realtime/notifications/route.ts | 66 ++++++++++++++++++++-- app/api/realtime/solo/route.ts | 8 ++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts index 53fc590..59fd1a8 100644 --- a/__tests__/api/notifications-stream.test.ts +++ b/__tests__/api/notifications-stream.test.ts @@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn() }, claudeQuestion: { findMany: vi.fn() }, + idea: { findMany: vi.fn().mockResolvedValue([]) }, }, })) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 4fad600..834ebff 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -26,17 +26,49 @@ const CHANNEL = 'scrum4me_changes' const HEARTBEAT_MS = 25_000 const HARD_CLOSE_MS = 240_000 -interface NotifyPayload { +// Question-payloads: emitted by the notify_question_change trigger on +// claude_questions. story_id and idea_id are mutually exclusive (DB-level +// check-constraint added in M12). +interface QuestionPayload { op: 'I' | 'U' - entity: 'task' | 'story' | 'question' + entity: 'question' id: string product_id: string - story_id?: string + story_id?: string | null task_id?: string | null + idea_id?: string | null assignee_id?: string | null status?: string } +// Idea-job-payloads: emitted by actions/ideas.ts (startGrillJobAction etc.) +// via prisma.$executeRaw pg_notify. Always carries user_id + idea_id + kind. +interface IdeaJobPayload { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + status: string +} + +type NotifyPayload = QuestionPayload | IdeaJobPayload + +function isQuestionPayload(p: NotifyPayload): p is QuestionPayload { + return 'entity' in p && p.entity === 'question' +} + +function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload { + return ( + 'type' in p && + (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && + 'idea_id' in p && + 'kind' in p && + (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') + ) +} + export async function GET(request: NextRequest) { const session = await getSession() if (!session.userId) { @@ -53,6 +85,15 @@ export async function GET(request: NextRequest) { }) const accessibleProductIds = new Set(products.map((p) => p.id)) + // M12: idea-questions zijn strikt user_id-only (geen productAccessFilter). + // We pre-fetchen de user's idea-ids zodat we snel kunnen filteren op het + // SSE-pad — geen DB-call per event. + const userIdeas = await prisma.idea.findMany({ + where: { user_id: userId }, + select: { id: true }, + }) + const accessibleIdeaIds = new Set(userIdeas.map((i) => i.id)) + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL if (!directUrl) { return Response.json( @@ -115,7 +156,24 @@ export async function GET(request: NextRequest) { } catch { return } - if (payload.entity !== 'question') return + + if (isIdeaJobPayload(payload)) { + // M12: idea-jobs zijn user-scoped, niet product-scoped. + if (payload.user_id !== userId) return + enqueue(`data: ${msg.payload}\n\n`) + return + } + + if (!isQuestionPayload(payload)) return + + // Idea-question: alleen voor de eigenaar van het idee. + if (payload.idea_id) { + if (!accessibleIdeaIds.has(payload.idea_id)) return + enqueue(`data: ${msg.payload}\n\n`) + return + } + + // Story-question: bestaande product-access-check. if (!accessibleProductIds.has(payload.product_id)) return enqueue(`data: ${msg.payload}\n\n`) }) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 0553cf6..e514797 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -41,7 +41,11 @@ type EntityPayload = { type JobPayload = { type: 'claude_job_enqueued' | 'claude_job_status' job_id: string - task_id: string + task_id?: string | null + // M12: idea-jobs zetten kind + idea_id ipv task_id. Solo filtert die weg + // (idea-jobs horen op /api/realtime/notifications, niet op het Solo Paneel). + idea_id?: string | null + kind?: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' user_id: string product_id: string status: string @@ -77,6 +81,8 @@ function shouldEmit( userId: string, ): boolean { if (isJobPayload(payload)) { + // M12: skip idea-jobs (kind=IDEA_*) — die horen op /api/realtime/notifications. + if (payload.kind === 'IDEA_GRILL' || payload.kind === 'IDEA_MAKE_PLAN') return false return payload.user_id === userId && payload.product_id === productId } From 8cc4e0aeb7bc5d18521132309e36331db66518ee Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 20:02:22 +0200 Subject: [PATCH 14/26] realtime: idea-store + extend notifications hook for idea events (M12 T-503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stores/idea-store.ts (Zustand): - jobByIdea, ideaStatuses, openQuestionsByIdea - handleIdeaJobEvent: derives optimistic ideaStatus (queued/claimed/running → grilling/planning; failed → grill_failed/plan_failed; done = no-op since the server-side update_idea_*_md is source-of-truth) - handleIdeaQuestionEvent: list-based, removes on non-open - setIdeaStatus / setJobStatus / clearForIdea optimistic helpers - connectedWorkers NOT duplicated — UI reads useSoloStore(s.connectedWorkers) lib/realtime/use-notifications-realtime.ts: - Single SSE serves both bell-questions and idea-state. Adds dispatcher branches: idea-job payloads → idea-store; idea-question payloads (idea_id set) → idea-store; story-questions → existing notifications-store path. Tests: 7/7 idea-store cases (queued→grilling, failed→*_failed, done no-op, question-list management, clearForIdea isolation). Full suite: 546/546 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/stores/idea-store.test.ts | 145 ++++++++++++++++ lib/realtime/use-notifications-realtime.ts | 78 ++++++++- stores/idea-store.ts | 182 +++++++++++++++++++++ 3 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 __tests__/stores/idea-store.test.ts create mode 100644 stores/idea-store.ts diff --git a/__tests__/stores/idea-store.test.ts b/__tests__/stores/idea-store.test.ts new file mode 100644 index 0000000..37d7413 --- /dev/null +++ b/__tests__/stores/idea-store.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { useIdeaStore } from '@/stores/idea-store' + +beforeEach(() => { + // Reset store between tests — Zustand persists state across tests otherwise. + useIdeaStore.setState({ + jobByIdea: {}, + ideaStatuses: {}, + openQuestionsByIdea: {}, + }) +}) + +describe('useIdeaStore — handleIdeaJobEvent', () => { + it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_enqueued', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'queued', + }) + const s = useIdeaStore.getState() + expect(s.jobByIdea['idea-1']?.status).toBe('queued') + expect(s.ideaStatuses['idea-1']).toBe('grilling') + }) + + it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'failed', + error: 'oops', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed') + expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops') + }) + + it('failed IDEA_MAKE_PLAN → plan_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-2', + idea_id: 'idea-2', + user_id: 'u-1', + kind: 'IDEA_MAKE_PLAN', + status: 'failed', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed') + }) + + it('done does NOT auto-derive status (server is source-of-truth)', () => { + useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled') + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-3', + idea_id: 'idea-3', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'done', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled') + }) +}) + +describe('useIdeaStore — handleIdeaQuestionEvent', () => { + it('non-open status removes question from list', () => { + useIdeaStore.getState().initQuestions('idea-1', [ + { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open', + created_at: '', + expires_at: '', + }, + ]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'U', + entity: 'question', + id: 'q-1', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'answered', + }) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([]) + }) + + it('open status keeps existing list (no detail in payload)', () => { + const q = { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open' as const, + created_at: '', + expires_at: '', + } + useIdeaStore.getState().initQuestions('idea-1', [q]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'I', + entity: 'question', + id: 'q-2', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'open', + }) + // List length blijft 1 (server-fetch leveert de detail) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1) + }) +}) + +describe('useIdeaStore — clearForIdea', () => { + it('removes job + status + questions for one idea, leaves others', () => { + const s = useIdeaStore.getState() + s.setJobStatus({ + job_id: 'j-1', + idea_id: 'idea-1', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setJobStatus({ + job_id: 'j-2', + idea_id: 'idea-2', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setIdeaStatus('idea-1', 'grilling') + s.setIdeaStatus('idea-2', 'grilling') + + s.clearForIdea('idea-1') + + const after = useIdeaStore.getState() + expect(after.jobByIdea['idea-1']).toBeUndefined() + expect(after.jobByIdea['idea-2']).toBeDefined() + expect(after.ideaStatuses['idea-1']).toBeUndefined() + expect(after.ideaStatuses['idea-2']).toBe('grilling') + }) +}) diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts index 8f58e12..f9ad0e3 100644 --- a/lib/realtime/use-notifications-realtime.ts +++ b/lib/realtime/use-notifications-realtime.ts @@ -12,21 +12,52 @@ import { useEffect, useRef } from 'react' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' +import { useIdeaStore } from '@/stores/idea-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 -interface NotifyPayload { +// Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive +// (DB-check-constraint). Voor story-questions blijft het pad onveranderd; +// idea-questions worden naar de idea-store doorgezet. +interface QuestionPayload { op: 'I' | 'U' entity: 'question' id: string product_id: string - story_id: string + story_id: string | null task_id: string | null + idea_id?: string | null assignee_id: string | null status: 'open' | 'answered' | 'cancelled' | 'expired' } +// Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify. +interface IdeaJobPayload { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + status: string + error?: string +} + +type AnyPayload = QuestionPayload | IdeaJobPayload + +function isQuestionPayload(p: AnyPayload): p is QuestionPayload { + return 'entity' in p && p.entity === 'question' +} + +function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload { + return ( + 'type' in p && + (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && + (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') + ) +} + interface StateEvent { questions: NotificationQuestion[] } @@ -73,11 +104,44 @@ export function useNotificationsRealtime() { source.addEventListener('message', (ev) => { try { - const payload = JSON.parse(ev.data) as NotifyPayload - if (payload.entity !== 'question') return - // Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst - // mee in de payload, dus we doen een mini-fetch via de same SSE's - // initial-state on reconnect; hier voor MVP alleen status-handling). + const payload = JSON.parse(ev.data) as AnyPayload + + // M12 — idea-job events naar idea-store dispatchen. + if (isIdeaJobPayload(payload)) { + useIdeaStore.getState().handleIdeaJobEvent({ + type: payload.type, + job_id: payload.job_id, + idea_id: payload.idea_id, + user_id: payload.user_id, + product_id: payload.product_id ?? null, + kind: payload.kind, + // The store-types narrow this; cast is safe because the server + // emits valid statuses. + status: payload.status as 'queued', + error: payload.error, + }) + return + } + + if (!isQuestionPayload(payload)) return + + // M12 — idea-question events naar idea-store dispatchen. + if (payload.idea_id) { + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: payload.op, + entity: 'question', + id: payload.id, + product_id: payload.product_id, + story_id: null, + idea_id: payload.idea_id, + task_id: payload.task_id, + assignee_id: payload.assignee_id, + status: payload.status, + }) + return + } + + // Story-questions: bestaande bell-pad onveranderd. if (payload.status === 'open') { // Inkomende open vraag: we hebben de details nog niet — beste optie is // herfetchen door opnieuw te verbinden, of via een API. Voor v1 diff --git a/stores/idea-store.ts b/stores/idea-store.ts new file mode 100644 index 0000000..0b95f16 --- /dev/null +++ b/stores/idea-store.ts @@ -0,0 +1,182 @@ +// M12: Zustand-store voor idee-gerelateerde realtime state. +// +// Wordt gevoed door `use-notifications-realtime.ts` (zelfde SSE-stream als de +// notifications-bell — geen tweede EventSource nodig). Houdt: +// - jobByIdea: live status van de actieve grill/make-plan-job per idee +// - ideaStatuses: optimistische idea-status-updates (uit job-events) +// - openQuestionsByIdea: open vragen voor de Timeline-tab (M12 ST-1199) +// +// connectedWorkers wordt NIET gedupliceerd — UI-componenten lezen die direct +// via `useSoloStore(s => s.connectedWorkers)` (zie M12 grill-keuze 16). + +import { create } from 'zustand' + +import type { ClaudeJobStatusApi } from '@/lib/job-status' +import type { IdeaStatusApi } from '@/lib/idea-status' + +export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + +export interface IdeaJobState { + job_id: string + idea_id: string + kind: IdeaJobKind + status: ClaudeJobStatusApi + error?: string + started_at?: string | null + finished_at?: string | null +} + +export interface IdeaQuestion { + id: string + idea_id: string + question: string + options: string[] | null + status: 'open' | 'answered' | 'cancelled' | 'expired' + answer?: string | null + created_at: string + expires_at: string +} + +export type IdeaJobEvent = + | { + type: 'claude_job_enqueued' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: IdeaJobKind + status: 'queued' + } + | { + type: 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: IdeaJobKind + status: ClaudeJobStatusApi + error?: string + } + +export type IdeaQuestionEvent = { + op: 'I' | 'U' + entity: 'question' + id: string + product_id: string + story_id: null + idea_id: string + task_id?: string | null + assignee_id?: string | null + status: 'open' | 'answered' | 'cancelled' | 'expired' +} + +interface IdeaStore { + jobByIdea: Record + ideaStatuses: Record + openQuestionsByIdea: Record + + // Bulk-init bij mount van een page (server-component → client hydration). + initJobs: (jobs: IdeaJobState[]) => void + initStatuses: (statuses: Record) => void + initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void + + // Realtime event handlers — aangeroepen door use-notifications-realtime. + handleIdeaJobEvent: (event: IdeaJobEvent) => void + handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void + + // Optimistic updates vanuit server-actions in client-components. + setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void + setJobStatus: (job: IdeaJobState) => void + + // Cleanup bij navigeren weg van een detail-pagina. + clearForIdea: (ideaId: string) => void +} + +// Mapping van een job-status (uit pg_notify event) naar een afgeleide +// idea-status. De server is de bron-van-waarheid; dit is alleen optimistic UI. +function deriveIdeaStatusFromJob( + kind: IdeaJobKind, + status: ClaudeJobStatusApi, +): IdeaStatusApi | null { + if (status === 'queued' || status === 'claimed' || status === 'running') { + return kind === 'IDEA_GRILL' ? 'grilling' : 'planning' + } + if (status === 'failed') { + return kind === 'IDEA_GRILL' ? 'grill_failed' : 'plan_failed' + } + // 'done' wordt door update_idea_*_md gezet (GRILLED resp. PLAN_READY) — + // daar is geen kind-onafhankelijke afleiding voor; lees de DB-update via + // re-fetch / page-revalidate. We laten de status hier ongemoeid. + return null +} + +export const useIdeaStore = create((set) => ({ + jobByIdea: {}, + ideaStatuses: {}, + openQuestionsByIdea: {}, + + initJobs: (jobs) => + set(() => { + const jobByIdea: Record = {} + for (const j of jobs) jobByIdea[j.idea_id] = j + return { jobByIdea } + }), + + initStatuses: (statuses) => set({ ideaStatuses: { ...statuses } }), + + initQuestions: (ideaId, questions) => + set((s) => ({ + openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions }, + })), + + handleIdeaJobEvent: (event) => + set((s) => { + const jobState: IdeaJobState = { + job_id: event.job_id, + idea_id: event.idea_id, + kind: event.kind, + status: event.status as ClaudeJobStatusApi, + error: 'error' in event ? event.error : undefined, + } + const derived = deriveIdeaStatusFromJob(event.kind, event.status as ClaudeJobStatusApi) + return { + jobByIdea: { ...s.jobByIdea, [event.idea_id]: jobState }, + ideaStatuses: + derived !== null + ? { ...s.ideaStatuses, [event.idea_id]: derived } + : s.ideaStatuses, + } + }), + + handleIdeaQuestionEvent: (event) => + set((s) => { + const list = s.openQuestionsByIdea[event.idea_id] ?? [] + // Bij open/insert: we hebben alleen status + id; de UI fetcht de + // detail bij re-render. Voor v1 markeren we 'm in de lijst zodat de + // count niet uit sync raakt. + let next = list + if (event.status !== 'open') { + next = list.filter((q) => q.id !== event.id) + } + return { + openQuestionsByIdea: { ...s.openQuestionsByIdea, [event.idea_id]: next }, + } + }), + + setIdeaStatus: (ideaId, status) => + set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })), + + setJobStatus: (job) => + set((s) => ({ jobByIdea: { ...s.jobByIdea, [job.idea_id]: job } })), + + clearForIdea: (ideaId) => + set((s) => { + const { [ideaId]: _j, ...jobByIdea } = s.jobByIdea + const { [ideaId]: _s, ...ideaStatuses } = s.ideaStatuses + const { [ideaId]: _q, ...openQuestionsByIdea } = s.openQuestionsByIdea + void _j + void _s + void _q + return { jobByIdea, ideaStatuses, openQuestionsByIdea } + }), +})) From 006d803a16f0fdd39a6a27d92ce1ed2b6a48e241 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 21:29:02 +0200 Subject: [PATCH 15/26] ui: idea-status badge helper (M12 T-509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/idea-status-colors.ts: getIdeaStatusBadge(status) → { label, classes, pulse? }. Reuses existing --status-*-tokens (in-progress / blocked / review / done) — no new tokens needed in theme.css. Mapping (per docs/plans/M12-ideas.md state machine): - DRAFT → surface-variant (neutral) - GRILLING → in-progress + pulse - GRILL_FAILED → blocked - GRILLED → review (waiting for next step) - PLANNING → in-progress + pulse - PLAN_FAILED → blocked - PLAN_READY → review - PLANNED → done CLAUDE.md hardstop respected — only MD3-tokens, no arbitrary Tailwind colors. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/idea-status-colors.ts | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 lib/idea-status-colors.ts diff --git a/lib/idea-status-colors.ts b/lib/idea-status-colors.ts new file mode 100644 index 0000000..6e947b6 --- /dev/null +++ b/lib/idea-status-colors.ts @@ -0,0 +1,56 @@ +// Mapping van IdeaStatus → Tailwind/MD3-classes voor badge-rendering. +// Hergebruikt de bestaande --status-*-tokens (zie app/styles/theme.css). +// CLAUDE.md hardstop: nooit `bg-blue-500` o.i.d.; altijd MD3-tokens. + +import type { IdeaStatus } from '@prisma/client' + +export interface IdeaStatusBadge { + label: string + classes: string + pulse?: boolean +} + +const PILL = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium' + +// Per-status: label + Tailwind-classes + optionele pulse-indicator. +// in-progress + status-blocked + status-review + status-done worden hergebruikt. +const TABLE: Record = { + DRAFT: { + label: 'Concept', + classes: `${PILL} bg-surface-variant text-on-surface-variant border-outline-variant`, + }, + GRILLING: { + label: 'Grillen…', + classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`, + pulse: true, + }, + GRILL_FAILED: { + label: 'Grill mislukt', + classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`, + }, + GRILLED: { + label: 'Gegrilld', + classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`, + }, + PLANNING: { + label: 'Plannen…', + classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`, + pulse: true, + }, + PLAN_FAILED: { + label: 'Plan mislukt', + classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`, + }, + PLAN_READY: { + label: 'Plan klaar', + classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`, + }, + PLANNED: { + label: 'Gepland', + classes: `${PILL} bg-status-done/15 text-status-done border-status-done/30`, + }, +} + +export function getIdeaStatusBadge(status: IdeaStatus): IdeaStatusBadge { + return TABLE[status] +} From 2eb0f330687f6d81d8c7d53ada688c154c70f888 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 21:30:56 +0200 Subject: [PATCH 16/26] ui: /ideas list page + IdeaList table + row-actions skeleton (M12 T-507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/(app)/ideas/page.tsx (server-component): - user_id-only fetch (no productAccessFilter — Idee is privé) - products fetched with productAccessFilter for filter-dropdown + create-form components/ideas/idea-list.tsx (client-component): - Search by title, product-dropdown filter, status multi-chip filter - Inline create form with title/description/product (optional) - Native shadcn Table + status badge via getIdeaStatusBadge (T-509) - Row click navigates to /ideas/[id] - Sonner toasts for success/error; router.refresh() after mutations - DemoTooltip + disabled on Nieuw + Archive - Empty-state + filtered-empty messaging components/ideas/idea-row-actions.tsx (placeholder for T-508): - "Open" navigation + "Archive" button only — Grill / Make Plan / Materialiseer come in T-508 with full disabled-rules Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/ideas/page.tsx | 48 ++++ components/ideas/idea-list.tsx | 306 ++++++++++++++++++++++++++ components/ideas/idea-row-actions.tsx | 49 +++++ 3 files changed, 403 insertions(+) create mode 100644 app/(app)/ideas/page.tsx create mode 100644 components/ideas/idea-list.tsx create mode 100644 components/ideas/idea-row-actions.tsx diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx new file mode 100644 index 0000000..142e376 --- /dev/null +++ b/app/(app)/ideas/page.tsx @@ -0,0 +1,48 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' + +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { ideaToDto } from '@/lib/idea-dto' +import { IdeaList } from '@/components/ideas/idea-list' + +export const dynamic = 'force-dynamic' + +export default async function IdeasPage() { + const session = await getIronSession(await cookies(), sessionOptions) + + // M12: idee is strikt user_id-only (geen productAccessFilter — Q8). + const ideas = await prisma.idea.findMany({ + where: { user_id: session.userId, archived: false }, + orderBy: { created_at: 'desc' }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, + take: 200, + }) + + // Productenlijst voor de filter-dropdown + voor "Nieuw idee"-form. + // Producten zijn product-scoped (kan team-shared zijn) — productAccessFilter + // is hier dus wél juist. + const products = await prisma.product.findMany({ + where: { ...productAccessFilter(session.userId), archived: false }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, repo_url: true }, + }) + + return ( +
+
+

Ideeën

+

+ Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien. +

+
+ + ideaToDto(i))} + products={products} + isDemo={session.isDemo ?? false} + /> +
+ ) +} diff --git a/components/ideas/idea-list.tsx b/components/ideas/idea-list.tsx new file mode 100644 index 0000000..e457a02 --- /dev/null +++ b/components/ideas/idea-list.tsx @@ -0,0 +1,306 @@ +'use client' + +// IdeaList — top-level lijstpagina voor /ideas. +// - Strikt user_id-only data (server haalt al; client filtert binnen die set). +// - Filters: zoeken op titel, product-dropdown, status-multiselect. +// - Klik op rij navigeert naar /ideas/[id]. Acties (Grill / Make Plan / +// Materialiseer) staan in components/ideas/idea-row-actions.tsx (T-508). +// - DemoTooltip rondom muteer-acties; bulk-archive blijft achter feature-flag +// in T-508 en latere stories. + +import { useMemo, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Plus } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { getIdeaStatusBadge } from '@/lib/idea-status-colors' +import type { IdeaStatusApi } from '@/lib/idea-status' +import type { IdeaDto } from '@/lib/idea-dto' +import { createIdeaAction, archiveIdeaAction } from '@/actions/ideas' +import { IdeaRowActions } from '@/components/ideas/idea-row-actions' + +// Reverse mapping voor het renderen van de status-badge — DTO bevat lowercase +// API-strings, het badge-helper verwacht DB-enum. +const API_TO_DB: Record[0]> = { + draft: 'DRAFT', + grilling: 'GRILLING', + grill_failed: 'GRILL_FAILED', + grilled: 'GRILLED', + planning: 'PLANNING', + plan_failed: 'PLAN_FAILED', + plan_ready: 'PLAN_READY', + planned: 'PLANNED', +} + +interface ProductOption { + id: string + name: string + repo_url: string | null +} + +interface IdeaListProps { + ideas: IdeaDto[] + products: ProductOption[] + isDemo: boolean +} + +const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [ + { value: 'draft', label: 'Concept' }, + { value: 'grilling', label: 'Grillen' }, + { value: 'grilled', label: 'Gegrilld' }, + { value: 'planning', label: 'Plannen' }, + { value: 'plan_ready', label: 'Plan klaar' }, + { value: 'planned', label: 'Gepland' }, + { value: 'grill_failed', label: 'Grill mislukt' }, + { value: 'plan_failed', label: 'Plan mislukt' }, +] + +export function IdeaList({ ideas, products, isDemo }: IdeaListProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + // Filter state + const [search, setSearch] = useState('') + const [productFilter, setProductFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState>(new Set()) + + // Create-form state + const [showCreate, setShowCreate] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [newDescription, setNewDescription] = useState('') + const [newProductId, setNewProductId] = useState('') + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + return ideas.filter((idea) => { + if (q && !idea.title.toLowerCase().includes(q)) return false + if (productFilter !== 'all') { + if (productFilter === 'none' && idea.product_id !== null) return false + if (productFilter !== 'none' && idea.product_id !== productFilter) return false + } + if (statusFilter.size > 0 && !statusFilter.has(idea.status)) return false + return true + }) + }, [ideas, search, productFilter, statusFilter]) + + function toggleStatus(s: IdeaStatusApi) { + setStatusFilter((prev) => { + const next = new Set(prev) + if (next.has(s)) next.delete(s) + else next.add(s) + return next + }) + } + + function handleCreate() { + if (isDemo) return + const title = newTitle.trim() + if (!title) { + toast.error('Titel is verplicht') + return + } + startTransition(async () => { + const r = await createIdeaAction({ + title, + description: newDescription.trim() || null, + product_id: newProductId || null, + }) + if ('error' in r) { + toast.error(r.error) + return + } + toast.success(`Idee aangemaakt (${r.data?.code})`) + setNewTitle('') + setNewDescription('') + setNewProductId('') + setShowCreate(false) + router.refresh() + }) + } + + function handleArchive(id: string) { + if (isDemo) return + startTransition(async () => { + const r = await archiveIdeaAction(id) + if ('error' in r) { + toast.error(r.error) + return + } + toast.success('Idee gearchiveerd') + router.refresh() + }) + } + + return ( +
+ {/* Top-bar: search + nieuw-knop */} +
+ setSearch(e.target.value)} + placeholder="Zoek op titel..." + className="max-w-sm" + /> + +
+ + + +
+
+ + {/* Status-chips als multi-select filter */} +
+ {STATUS_FILTERS.map((s) => { + const active = statusFilter.has(s.value) + return ( + + ) + })} +
+ + {/* Inline create form */} + {showCreate && ( +
+ setNewTitle(e.target.value)} + placeholder="Titel van het idee..." + disabled={isPending} + /> +