From ebe153d4e39cc743e0454ec4b9f1530cbbfdd863 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 28 Apr 2026 00:33:38 +0200 Subject: [PATCH 001/284] =?UTF-8?q?docs(ST-1101..1108):=20add=20M11=20?= =?UTF-8?q?=E2=80=94=20Claude=20question-channel=20milestone=20to=20backlo?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plant acht stories ST-1101..ST-1108 voor het persistente vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker. Eerste concrete uitwerking van de AI-driven dev-flow-richting (strategisch besluit "B" uit overleg na M10). Beveiligingsuitgangspunt: atomic answer via updateMany WHERE status='open', demo-blok op write-tools, access-check via productAccessFilter in DB-query én SSE-filter, cron-endpoint via Bearer-secret, geen vraag/antwoord-tekst in logs. Hergebruikt bestaande scrum4me_changes-channel (uitgebreid met entity:'question') en het LISTEN/NOTIFY+ReadableStream-pattern uit M8/M10. Nieuw: user-scoped SSE op /api/realtime/notifications zodat de bell globaal werkt over producten heen. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/M11-claude-questions.md | 466 +++++++++++++++++++++++++++++ docs/scrum4me-backlog.md | 68 +++++ 2 files changed, 534 insertions(+) create mode 100644 docs/plans/M11-claude-questions.md diff --git a/docs/plans/M11-claude-questions.md b/docs/plans/M11-claude-questions.md new file mode 100644 index 0000000..d1a8e61 --- /dev/null +++ b/docs/plans/M11-claude-questions.md @@ -0,0 +1,466 @@ +# M11 — Claude vraagt, gebruiker antwoordt + +Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. Claude schrijft een vraag naar `claude_questions` als hij vastloopt op een keuze; een Postgres-trigger emit op het bestaande `scrum4me_changes`-kanaal; de app toont een notificatie-badge; iedereen met product-toegang kan antwoorden; Claude leest het antwoord (sync via polling of in latere sessie via `get_question_answer`) en gaat door. + +Eerste concrete uitwerking van strategische **richting B** (verdiepen van de unieke AI-driven dev-flow). + +Backlog-entries: zie [scrum4me-backlog.md § M11](../scrum4me-backlog.md#m11-claude-vraagt-gebruiker-antwoordt) (op te leveren in ST-1108). + +**Beveiligingsuitgangspunten:** +- Atomic answer via `updateMany WHERE status='open'` — concurrent dubbele submit kan niet +- Demo-blok op `ask_user_question` (MCP) en `answerQuestion` (Server Action) +- Access-check via `productAccessFilter` in DB-query én SSE-filter; vraag-tekst en antwoord komen pas via een aparte authenticated query +- Cron-endpoint beveiligd via `Authorization: Bearer ${CRON_SECRET}` +- Logging: alleen `question_id`, nooit vraag/antwoord-tekst (kan gevoelige info bevatten) + +**Gekozen kaders (uit overleg):** +- **Sync-model**: default async — `ask_user_question` retourneert direct met `question_id`; optionele `wait_seconds` (max 600) voor polling tot het antwoord er is +- **Answer-policy**: iedereen met product-toegang mag antwoorden; story-assignee krijgt visuele *"wacht op jou"*-emphase +- **Realtime**: hergebruik `scrum4me_changes`-kanaal (uitgebreid met `entity: 'question'`); aparte user-scoped SSE-route `/api/realtime/notifications` zodat solo-board-SSE product-scoped blijft + +--- + +## ST-1101 — `ClaudeQuestion` schema + Postgres-trigger + +**Bestanden** +- `prisma/schema.prisma` — model `ClaudeQuestion` + relations op `User`/`Story`/`Task`/`Product` +- `prisma/migrations/_add_claude_questions/migration.sql` — table-DDL + trigger +- `vendor/scrum4me`-submodule in `scrum4me-mcp` — schema-sync ná merge + +**Stappen** + +1. Schema-uitbreiding: + + ```prisma + model ClaudeQuestion { + id String @id @default(cuid()) + story_id String + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + task_id String? + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + product_id String // gedenormaliseerd voor SSE-filter + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + asked_by String + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answered_by String? + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime + + @@index([story_id, status]) + @@index([product_id, status]) + @@index([status, expires_at]) + @@map("claude_questions") + } + ``` + + Plus op `User`: `asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")` en `answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")`. + +2. Migratie-SQL voegt naast tabel + indexes ook trigger toe (mirror van `notify_pairing_change` uit M10 ST-1001): + + ```sql + CREATE OR REPLACE FUNCTION notify_question_change() RETURNS trigger AS $$ + DECLARE + story_row record; + payload jsonb; + BEGIN + SELECT assignee_id INTO story_row FROM stories WHERE id = NEW.story_id; + payload := jsonb_build_object( + 'op', CASE TG_OP WHEN 'INSERT' THEN 'I' ELSE 'U' END, + 'entity', 'question', + 'id', NEW.id, + 'product_id', NEW.product_id, + 'story_id', NEW.story_id, + 'task_id', NEW.task_id, + 'assignee_id', story_row.assignee_id, + 'status', NEW.status + ); + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER claude_questions_notify + AFTER INSERT OR UPDATE ON claude_questions + FOR EACH ROW EXECUTE FUNCTION notify_question_change(); + ``` + +3. `npx prisma migrate dev --name add_claude_questions` + +**Aandachtspunten** +- `entity: 'question'` is een nieuwe waarde naast bestaande `'task'`/`'story'`. Solo-route in M8 filter't via `payload.entity` — moet `'question'`-events expliciet wegfilteren (niet emitten naar solo-clients) +- `product_id` op de question is gedenormaliseerd uit `story.product_id` — voorkomt extra join in SSE-filter (zelfde keuze als `story.product_id` in M3) +- `vendor/scrum4me`-submodule sync vereist na merge (drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD`) + +**Verificatie** +- `npx prisma migrate dev` slaagt; `npx prisma validate` clean +- `psql $DIRECT_URL -c "LISTEN scrum4me_changes;"` toont payload met `entity: 'question'` bij INSERT +- Bestaande solo-flow nog steeds werkend (regressie-check) + +--- + +## ST-1102 — MCP-tools (in `scrum4me-mcp`-repo) + +**Bestanden** +- `scrum4me-mcp/src/tools/ask-user-question.ts` — nieuw +- `scrum4me-mcp/src/tools/get-question-answer.ts` — nieuw +- `scrum4me-mcp/src/tools/list-open-questions.ts` — nieuw +- `scrum4me-mcp/src/tools/cancel-question.ts` — nieuw +- `scrum4me-mcp/src/index.ts` — register de vier tools +- `scrum4me-mcp/scripts/smoke-test.ts` — uitbreiden met question-roundtrip +- `scrum4me-mcp/README.md` — tool-tabel uitbreiden + +**Stappen** + +1. **`ask_user_question`** (write-tool, sjabloon `create-todo.ts`): + - Input: `{ story_id, question, options?, task_id?, wait_seconds? }` — `wait_seconds` 0–600 (Zod `.min(0).max(600)`) + - `requireWriteAccess` (demo-blok) + - Access-check: `userCanAccessProduct(story.product_id, auth.userId)` + - Insert met `expires_at = now() + 24h`, `status = 'open'` + - Als `wait_seconds === 0` (default): return `{ question_id, status: 'open' }` + - Als `wait_seconds > 0`: poll elke 2s tot `status !== 'open'` of timeout. Bij answered: return `{ question_id, status, answer, answered_by, answered_at }`. Bij timeout: return `{ question_id, status: 'pending' }` zodat Claude met `get_question_answer` later kan ophalen + - Polling-implementatie: `setInterval` met `Promise` en abort-signal voor schone cleanup + +2. **`get_question_answer`** (read-tool): + - Input: `{ question_id }` + - Access-check via `userCanAccessProduct(question.product_id, auth.userId)` + - Output: full row (`status`, `answer`, `answered_by`, `answered_at`, `expires_at`) + +3. **`list_open_questions`** (read-tool): + - Input: `{ story_id? }` (optionele filter) + - Output: array van eigen vragen (`asked_by === auth.userId`) met status open of answered, max 50, geordend op `created_at desc` + - Bedoeld voor Claude om bij begin van een sessie te zien of eerdere vragen inmiddels beantwoord zijn + +4. **`cancel_question`** (write-tool): + - Input: `{ question_id }` + - Alleen de asker mag cancelen; `requireWriteAccess` voor demo-blok + - Atomic `updateMany WHERE id=… AND status='open' AND asked_by=…` + - Bedoeld voor wanneer Claude zelf de oplossing vindt en de vraag overbodig wordt + +5. Smoke-test in `scripts/smoke-test.ts`: `ask_user_question` met `wait_seconds=5` + parallel `answerQuestion` (via REST of direct DB-write) → verifieer dat de tool het antwoord retourneert binnen het venster + +**Aandachtspunten** +- `wait_seconds` polling moet aborten als de MCP-cliënt disconnect (signal-check) — anders blijft de Node-process hangen op een dood socket +- `options`-veld accepteert string-array; in zod als `z.array(z.string()).optional()` +- Als `wait_seconds` > 300 raakt 'm Vercel-deploy onmogelijk (Vercel-functies cap op 300s) — maar de MCP-server draait *lokaal* bij Claude Code, dus 600s mag + +**Verificatie** +- MCP Inspector toont 4 nieuwe tools (totaal 13) +- Smoke-test groen: ask + answer roundtrip binnen 5s +- Demo-token op `ask_user_question` of `cancel_question` geeft `PERMISSION_DENIED` +- `tsc --noEmit` clean op `scrum4me-mcp` + +--- + +## ST-1103 — Server Actions voor de browser-UI + +**Bestanden** +- `actions/questions.ts` — nieuw +- `__tests__/actions/questions.test.ts` — nieuw + +**Stappen** + +1. **`answerQuestion(questionId, answer)`** (volgt `docs/patterns/server-action.md`): + - `getSession` + `requireUser`; demo-blok via `if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' }` + - Zod-input: `{ questionId: cuid, answer: string.min(1).max(4000) }` + - Lookup question + access-check via `userCanAccessProduct(question.product_id, userId)` + - Atomic `updateMany WHERE id=… AND status='open' AND expires_at > now()` met `data: { status: 'answered', answer, answered_by: userId, answered_at: now }` + - Bij `count === 0`: disambigueer (al-answered → 'al beantwoord', expired → 'verlopen', geen access → 'geen toegang') + - `revalidatePath('/', 'layout')` zodat badge-count overal updatet + +2. **`cancelQuestionByAnswerer(questionId)`** — *uitgesteld naar v2*. Voor v1 alleen Claude (asker) kan annuleren via MCP. Als de UI later een dismiss-functie krijgt, komt het hier. + +3. **Tests** `__tests__/actions/questions.test.ts` (6 cases): + - happy answer → status='answered', `revalidatePath` aangeroepen + - demo-user → error + geen DB-write + - user zonder product-access → error + - already-answered → race-error (`updateMany count=0` met status='answered' fallback) + - expired → error + - empty answer → Zod-validatie + +**Aandachtspunten** +- `revalidatePath('/', 'layout')` is correct (zelfde keuze als M9 `setActiveProductAction`) — badge zit in app-layout +- Geen `revalidatePath` op `/sprint` of `/solo` nodig — die zien de question niet +- Bij multi-tab: na answer in tab-1 verdwijnt het item in tab-2 via SSE-event, niet via revalidate. revalidate is voor de SSR-render-na-navigatie + +**Verificatie** +- `npm test` 6/6 voor questions +- Handmatig: open vraag in browser, antwoord, badge-count zakt met 1 +- Demo-test: log in als demo, klik antwoord → toast "Niet beschikbaar in demo-modus" + +--- + +## ST-1104 — User-scoped SSE-route `/api/realtime/notifications` + +**Bestanden** +- `app/api/realtime/notifications/route.ts` — nieuw +- `app/api/realtime/solo/route.ts` — uitbreiden om `entity: 'question'` te filteren (anders krijgt solo-client question-events ongewenst door) +- `__tests__/api/notifications-stream.test.ts` — nieuw (auth-cases) + +**Stappen** + +1. Route Handler — sjabloon uit `app/api/realtime/solo/route.ts`: + - `runtime: 'nodejs'`, `maxDuration: 300`, `dynamic: 'force-dynamic'` + - Auth via iron-session cookie; 401 zonder + - **User-scoped** (geen `?product_id=`-param). Bij connect: query `productAccessFilter(userId)` om alle accessible product-IDs te krijgen + - LISTEN op `scrum4me_changes`; filter: + - `payload.entity === 'question'` (anders skip) + - `payload.product_id IN accessibleProductIds` + - Initial-state-event direct na connect, **na LISTEN actief**: query `claude_questions` met `status='open'` voor deze user's accessible products. Stuur als `event: state\ndata: [{...question-summary...}]`. Voorkomt race tussen connect en LISTEN (zelfde fix als M10 ST-1004) + - Auto-close bij hard-close 240s; client herconnect + +2. Solo-route bijwerken: in `shouldEmit` toevoegen `if (payload.entity === 'question') return false` + +3. Tests (auth-paden, full-stream blijft handmatig): + - 401 zonder iron-session cookie + - Bij connect met sessie: list van accessible products correct gefilterd + - Question-event op een product zonder access → niet doorgegeven + +**Aandachtspunten** +- Twee parallelle SSE-streams in browser (solo-route op product-pagina + notifications-route in app-layout) — netwerk-overhead aanvaardbaar; Vercel rekent per-actieve-functie ongeacht aantal streams +- Initial-state event content: een kleine summary (id, story_code, question, options?) per open vraag — voorkomt dat de bridge eerst een aparte fetch moet doen voor de initial badge-count +- Path expliciet maken in een client `useNotificationsRealtime`-hook (volgt `useSoloRealtime`-pattern) + +**Verificatie** +- `curl -N --cookie session-jar /api/realtime/notifications` blijft openstaan, levert `event: state` direct +- INSERT op `claude_questions` voor een toegankelijk product → event binnen 1s +- INSERT voor een ontoegankelijk product → geen event +- Solo-route op `/api/realtime/solo?product_id=…` levert geen question-events meer + +--- + +## ST-1105 — Notifications-UI (Bell + Sheet + Answer-modal) + +**Bestanden** +- `components/shared/notifications-bell.tsx` — nieuw +- `components/notifications/notifications-sheet.tsx` — nieuw +- `components/notifications/answer-modal.tsx` — nieuw +- `components/notifications/notifications-bridge.tsx` — nieuw, hookt SSE-listener aan store +- `stores/notifications-store.ts` — nieuw +- `lib/realtime/use-notifications-realtime.ts` — nieuw +- `components/shared/nav-bar.tsx` — `` toevoegen rechts (links van ``) +- `app/(app)/layout.tsx` — `` mounten (analoog aan ``) + +**Stappen** + +1. **`stores/notifications-store.ts`** — Zustand store; volgt `stores/solo-store.ts`-pattern: + - State: `{ questions: Question[], pendingAnswerIds: Set }` + - Actions: `init(q[])`, `add(q)`, `update(q)`, `remove(id)`, `optimisticAnswer(id)`, `rollbackAnswer(id, q)` + - Selectors: `openCount(userId)`, `forYouCount(userId)` (waar story-assignee = userId) + +2. **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`. EventSource opent op `/api/realtime/notifications`, dispatcht `state`/`message`-events naar store via `add`/`update`/`remove`. Reconnect met exponential backoff. + +3. **``** — Server Component die initial questions ophaalt en aan de store geeft via `init`-prop. Mount in `(app)/layout.tsx` zodat de bridge altijd actief is wanneer user is ingelogd. + +4. **``** — Client Component: + - Lucide `Bell`-icon met badge: `openCount` (totaal) + accent-dot als `forYouCount > 0` + - Klik: `setOpen(true)` op de Sheet + - Geen badge als count === 0 + +5. **``** — shadcn `Sheet` van rechts: + - Header: "Vragen van Claude (N)" + - Lijst gegroepeerd op product (analoog aan M5 todo-data-table-styling), elk item: story-code + truncated title, vraag-preview (line-clamp-2), assignee-emphase als forYou, "Beantwoord" knop opent answer-modal + - Lege staat: "Geen openstaande vragen. Lekker bezig!" + +6. **``** — shadcn `Dialog`: + - Story-context-link bovenaan (kleine kaart) + - Volledige vraag-tekst + - Als `options`: `` met opties; geen vrije tekst + - Anders: `