--- title: "M11 — Claude vraagt, gebruiker antwoordt" status: active audience: [maintainer, contributor] language: nl last_updated: 2026-05-03 applies_to: [M11] --- # 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 [backlog.md § M11](../backlog/index.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 `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 `mcp`-repo) **Bestanden** - `mcp/src/tools/ask-user-question.ts` — nieuw - `mcp/src/tools/get-question-answer.ts` — nieuw - `mcp/src/tools/list-open-questions.ts` — nieuw - `mcp/src/tools/cancel-question.ts` — nieuw - `mcp/src/index.ts` — register de vier tools - `mcp/scripts/smoke-test.ts` — uitbreiden met question-roundtrip - `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 `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: `