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")