From 300e426a4eba24b43588be96b6be14cce8160881 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 19:25:07 +0200 Subject: [PATCH] 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")