From 9587ff4ff3791f4a15c1d194d2eff1f5fe90d950 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 11:38:23 +0200 Subject: [PATCH] M11: Claude vraagt, gebruiker antwoordt (ST-1101..ST-1108) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(ST-1101..1108): add M11 — Claude question-channel milestone to backlog 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) * chore(M11): swap demo-active sprint from M10 to M11 M10 is gemerged en afgesloten — M11 wordt de nieuwe demo-actieve milestone zodat get_claude_context (via MCP) ST-1101 als next-story teruggeeft. Drie maps in parse-backlog.ts uitgebreid: M11 priority=4, goal omschrijving, sprint_status='ACTIVE'. M10 → COMPLETED. Vereist npx prisma db seed na deze commit zodat de live DB de nieuwe sprint-state weerspiegelt. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(ST-1108): add F-11b — Claude question-channel to functional spec Voegt feature-omschrijving toe naast bestaande F-11 (Claude Code REST API). Beschrijft het verloop (Claude → MCP-tool → DB → trigger → SSE → user → answer → trigger → Claude polls), acceptatiecriteria (8 items), randgevallen (offline- Claude, assignee-change, expiry, abuse) en datamodel (claude_questions tabel). Persona Lars als primair, Dina secundair voor klant-werk. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(M11): drop parser ACTIVE-flip; sprint goes via UI from now on Bij M9/M10 hebben we de seed-flip (MILESTONE_SPRINT_STATUS pivot) gebruikt om nieuwe stories als IN_SPRINT in een verse sprint te krijgen. Dat werkt maar is fragiel: - npm run seed wist user-data - de "sprint" die de seed maakt is geen echte planning-actie - bij multi-product scenario's breekt het model Vanaf M11 gebruiken we de bestaande Sprint-creatie-UI van Scrum4Me. Stories voor M11 worden via scripts/insert-milestone.ts (idempotent insert, geen seed-reset) aan de DB toegevoegd; de gebruiker maakt zelf een Sprint aan in /products/[scrum4me]/sprint en sleept ST-1101..1108 ernaartoe. Parser-map M11 dus terug naar COMPLETED zodat een eventuele re-seed niet meer een fake sprint aanmaakt voor M11-stories. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1101): add ClaudeQuestion model + notify_question_change trigger Schema (prisma/schema.prisma): - Nieuw model ClaudeQuestion: id (cuid), story_id (FK Cascade), task_id? (FK SetNull), product_id (FK Cascade — gedenormaliseerd uit story.product_id voor SSE-filter zonder join), asked_by (FK Restrict — Claude-token-houder), question (Text), options (Json? — string[] voor multi-choice), status ('open'|'answered'|'cancelled'|'expired'), answer (Text?), answered_by (FK SetNull), answered_at?, created_at, expires_at - Indexes: (story_id, status), (product_id, status), (status, expires_at) - Back-relations: User.asked_questions (ClaudeQuestionAsker), User.answered_questions (ClaudeQuestionAnswerer), Story.claude_questions, Task.claude_questions, Product.claude_questions Migratie (20260427224849_add_claude_questions): - Prisma-gegenereerde DDL voor claude_questions + indexes + 5 FK's - Toegevoegde notify_question_change() functie + claude_questions_notify trigger op AFTER INSERT/UPDATE - Emit op BESTAANDE scrum4me_changes-channel met entity:'question' (i.t.t. M10 dat eigen scrum4me_pairing-channel kreeg) — solo-route in ST-1104 moet entity='question' wegfilteren om regressie op solo-board te voorkomen - Trigger leest story.assignee_id voor "wacht op jou"-emphase in payload - DELETE niet ondersteund — questions gaan naar answered/cancelled/expired Verification: Node pg-client roundtrip via DATABASE_URL toonde correcte payloads bij INSERT (op=I, status=open) en UPDATE (op=U, status=answered) met alle FK-IDs en assignee_id correct uit story-join. Volgende stap M11: ST-1102 — vier MCP-tools in scrum4me-mcp-repo (ask_user_question, get_question_answer, list_open_questions, cancel_question). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1103): add answerQuestion server action actions/questions.ts: - answerQuestion(questionId, answer) — auth + Zod + demo-blok + access-check via productAccessFilter (anyone met product-membership mag antwoorden, consistent met Scrum self-organizing — niet alleen story-assignee) - Atomic prisma.claudeQuestion.updateMany WHERE id + status='open' + expires_at>now → status='answered'; concurrent dubbele submit: één wint (count=1), rest count=0 met disambiguatie via second findFirst - revalidatePath('/', 'layout') refresh't NavBar bell-count voor SSR-paths; realtime updates voor andere clients gaan via SSE in ST-1104/1105 - Begrijpelijke NL-foutmeldingen voor elk faalpad Tests __tests__/actions/questions.test.ts (6 cases): - happy: status update + revalidatePath called - demo-block: error + geen DB-call + geen revalidate - geen access: error + geen update - al-answered: race-error 'is al answered' - expired: race-error 'is verlopen' - lege answer: Zod-validatie Quality gates: lint 0 errors, tsc clean, vitest 145/145 (17 files). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1104): add user-scoped /api/realtime/notifications + filter solo-route Twee delen: 1. Solo-route filter (1-regel-fix in app/api/realtime/solo/route.ts): - NotifyPayload uitgebreid met entity:'question' - shouldEmit returnt direct false bij entity='question' Voorkomt dat solo-clients M11 question-events ontvangen (geen lekkage naar het Solo-bord; geen onnodig netwerk-verkeer; loose coupling tussen features). 2. Nieuwe SSE-route app/api/realtime/notifications/route.ts: - User-scoped (geen ?product_id=); query alle accessible product-IDs één keer bij connect via productAccessFilter - LISTEN scrum4me_changes; filter entity='question' && product_id ∈ accessible - Initial-state-event NA LISTEN actief (race-fix conform M10 ST-1004): query open vragen voor deze user's accessible products, stuur als event:state met summary (id, story_code/title, assignee_id, question, options, expires_at) - Hergebruikt het pg.Client + ReadableStream + heartbeat 25s + hard-close 240s + abort-cleanup-pattern uit solo-route Tests __tests__/api/notifications-stream.test.ts: - 401 zonder iron-session cookie (en geen DB-call) - Solo-route filter wordt visueel/E2E gedekt in ST-1108-acceptatie Quality gates: lint 0 errors, tsc clean, vitest 146/146 (18 files). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1105): add NavBar bell + sheet + answer-modal + Zustand store + SSE hook UI-volledig voor de Claude vraag-antwoord-flow (M11). Bel-icon links van avatar in NavBar; klik opent slide-over rechts met openstaande vragen; klik op een vraag opent een modal voor antwoord. Story-assignee = current user krijgt visuele "voor jou"-emphase met primary-container accent en error-color badge-ring. Bestanden: - stores/notifications-store.ts — Zustand store met init/upsert/remove + openCount/forYouCount selectors (vereenvoudigd vs solo-store: geen pendingOps, geen optimistic-echo-onderdrukking) - lib/realtime/use-notifications-realtime.ts — EventSource hook met state- event en message-event handling, exponential-backoff reconnect, Page Visibility pause-resume - components/notifications/notifications-bridge.tsx — Server Component die initial open-questions fetcht via productAccessFilter - components/notifications/notifications-realtime-mount.tsx — tiny client island dat de store hydrateert + de hook activeert - components/notifications/notifications-sheet.tsx — shadcn Sheet met item- lijst, "voor jou"-accent voor assignee-vragen, lege staat - components/notifications/answer-modal.tsx — Dialog met options-radio of free-text Textarea (max 4000), char-counter, demo-blok via Tooltip; bij succes optimistisch remove + sheet blijft open zodat meerdere vragen achter elkaar te beantwoorden zijn - components/shared/notifications-bell.tsx — Bell-icon met badge (count >9 → "9+"), ring-accent als forYouCount > 0, ARIA-label voor screenreaders Wiring: - components/shared/nav-bar.tsx — rechts naast - app/(app)/layout.tsx — naast , user.id (server-side) als prop base-ui-aanpassingen: SheetTrigger/TooltipTrigger gebruiken render-prop ipv asChild (geen Radix). Quality gates: lint 0 errors, tsc clean, vitest 146/146, npm run build groen. Co-Authored-By: Claude Opus 4.7 (1M context) * test(ST-1106): add cross-product access-isolation test for notifications SSE Demo-policy + assignee-emphase zaten al in eerdere stories: - answerQuestion demo-blok in actions/questions.test.ts (ST-1103) - AnswerModal demo-tooltip in components/notifications/answer-modal.tsx (ST-1105) - requireWriteAccess in MCP write-tools (ST-1102) Deze story voegt expliciet een access-isolation-test toe op de notifications- SSE-route: productAccessFilter wordt met de echte userId aangeroepen, en prisma.product.findMany filter't op archived=false + user_id-scope. Dat garandeert dat een gebruiker geen question-events ontvangt voor producten waar hij geen membership op heeft. Story-assignee-emphase blijft visueel-only (NotificationsBell ring-accent + Sheet primary-container) — toegang werkt product-membership-breed zodat een team-lid kan invallen als de assignee niet beschikbaar is. Quality gates: lint 0 errors, tsc clean, vitest 147/147 (was 146). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1107): add Vercel cron expire-questions (+ M10 pairing cleanup) POST /api/cron/expire-questions: - Auth via Authorization: Bearer ${CRON_SECRET} (Vercel injecteert dit automatisch wanneer de env-var op de project-omgeving staat); 401 als secret niet matcht of niet is gezet (faal-veilig — geen open endpoint in dev) - updateMany op claude_questions WHERE status='open' AND expires_at * docs(ST-1108): document M11 question-channel — API + architecture + pattern docs/API.md — twee nieuwe secties: - 'Notifications' met /api/realtime/notifications SSE-endpoint (event-shapes, filter-rules, voorbeeld) - 'Cron — Expire questions' met /api/cron/expire-questions (Bearer-auth, schedule, response-shape, manual curl) docs/scrum4me-architecture.md — nieuw hoofdstuk 'Vraag-antwoord-kanaal Claude ↔ user' tussen QR-pairing-flow en Projectstructuur: - Mermaid sequence-diagram (Claude → DB → trigger → SSE → user → answer → trigger → Claude polls) - Threat-model-tabel (race, demo-misbruik, cross-product leak, cron-misbruik, growth, log-leakage) - Subsectie 'Waarom hergebruik scrum4me_changes-kanaal' met trade-off vs M10's eigen-kanaal-aanpak docs/patterns/claude-question-channel.md — herbruikbaar pattern 'Bidirectionele async-comms tussen MCP-agent en interactieve user' met de vier eindpunten, vier security-uitgangspunten, channel-strategie-tabel, TTL-richtlijn, en sjabloon-bestanden per laag (DB / server / client / MCP-tools). CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe pattern-doc verwijst. Acceptatie 6 scenario's: 1. Sync happy path (MCP wait_seconds + UI submit) — handmatig getest tijdens ST-1105 acceptance-loop met de q-test injection 2. Async happy path — gedekt door get_question_answer-tool in ST-1102 + list_open_questions 3. Demo-block — actions/questions.test.ts (case 2: demo-user) + AnswerModal tooltip (visueel) 4. Access-isolation — notifications-stream.test.ts (case 'access-isolation') 5. Expiry — cron-expire-questions.test.ts (case '200 met juiste secret') 6. Race — actions/questions.test.ts (case 'al-answered' via atomic updateMany) Quality gates: lint 0 errors, tsc clean, vitest 151/151 (19 files), npm run build groen. M11 is hiermee feature-compleet. feat/M11-claude-questions heeft 12 commits lokaal, klaar voor user-acceptatie en PR. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ST-1107): cron schedule daily — Vercel Hobby allows only 1 run/day Vercel deploy faalde met: > Hobby accounts are limited to daily cron jobs. > This cron expression (0 */6 * * *) would run more than once per day. Schedule van 4×/dag (0 */6 * * *) naar 1×/dag (0 4 * * * — 04:00 UTC, rustig tijdstip). Functioneel acceptabel: ClaudeQuestion TTL is 24u, dus daily cleanup pakt alles dat in de afgelopen 24u verlopen is. Login-pairings TTL is 2 min — die zijn al onbruikbaar zodra ze expiren, cron is alleen voor status-housekeeping. Schedule-referenties consistent bijgewerkt in docs (API.md, architecture, backlog M11-sectie, plan-doc, pattern-doc) + comment in route.ts. Vermelding overal dat dit een Hobby-plan-beperking is en Pro fijnmaziger ondersteunt. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .env.example | 6 + CLAUDE.md | 1 + __tests__/actions/questions.test.ts | 122 +++++ __tests__/api/cron-expire-questions.test.ts | 77 +++ __tests__/api/notifications-stream.test.ts | 84 ++++ actions/questions.ts | 84 ++++ app/(app)/layout.tsx | 2 + app/api/cron/expire-questions/route.ts | 46 ++ app/api/realtime/notifications/route.ts | 194 ++++++++ app/api/realtime/solo/route.ts | 8 +- components/notifications/answer-modal.tsx | 157 ++++++ .../notifications/notifications-bridge.tsx | 62 +++ .../notifications-realtime-mount.tsx | 23 + .../notifications/notifications-sheet.tsx | 106 ++++ components/shared/nav-bar.tsx | 4 +- components/shared/notifications-bell.tsx | 54 ++ docs/API.md | 67 +++ docs/patterns/claude-question-channel.md | 145 ++++++ docs/plans/M11-claude-questions.md | 466 ++++++++++++++++++ docs/scrum4me-architecture.md | 70 +++ docs/scrum4me-backlog.md | 68 +++ docs/scrum4me-functional-spec.md | 38 ++ lib/env.ts | 4 + lib/realtime/use-notifications-realtime.ts | 126 +++++ .../migration.sql | 99 ++++ prisma/schema.prisma | 31 ++ prisma/seed-data/parse-backlog.ts | 5 +- stores/notifications-store.ts | 61 +++ vercel.json | 9 + 29 files changed, 2216 insertions(+), 3 deletions(-) create mode 100644 __tests__/actions/questions.test.ts create mode 100644 __tests__/api/cron-expire-questions.test.ts create mode 100644 __tests__/api/notifications-stream.test.ts create mode 100644 actions/questions.ts create mode 100644 app/api/cron/expire-questions/route.ts create mode 100644 app/api/realtime/notifications/route.ts create mode 100644 components/notifications/answer-modal.tsx create mode 100644 components/notifications/notifications-bridge.tsx create mode 100644 components/notifications/notifications-realtime-mount.tsx create mode 100644 components/notifications/notifications-sheet.tsx create mode 100644 components/shared/notifications-bell.tsx create mode 100644 docs/patterns/claude-question-channel.md create mode 100644 docs/plans/M11-claude-questions.md create mode 100644 lib/realtime/use-notifications-realtime.ts create mode 100644 prisma/migrations/20260427224849_add_claude_questions/migration.sql create mode 100644 stores/notifications-store.ts create mode 100644 vercel.json diff --git a/.env.example b/.env.example index 1cac979..ab61549 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ SESSION_SECRET="replace-with-at-least-32-characters" # Optional; Vercel and Node set this automatically in deployed environments. NODE_ENV="development" + +# M11 (ST-1107) — shared secret between Vercel cron-trigger and the +# /api/cron/expire-questions handler. Required in production; optional in +# local dev (the route returns 401 if the Authorization header doesn't match). +# Generate with: openssl rand -base64 32 +CRON_SECRET="" diff --git a/CLAUDE.md b/CLAUDE.md index 3af09fa..a539598 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven. | Float sort_order drag-and-drop | `docs/patterns/sort-order.md` | | Middleware (route protection) | `docs/patterns/middleware.md` | | QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` | +| Bidirectionele async-comms MCP-agent ↔ user | `docs/patterns/claude-question-channel.md` | | Status-enum mapping (DB ↔ API) | `lib/task-status.ts` | | Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten | diff --git a/__tests__/actions/questions.test.ts b/__tests__/actions/questions.test.ts new file mode 100644 index 0000000..22dd33d --- /dev/null +++ b/__tests__/actions/questions.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeQuestion: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + }, +})) + +import { revalidatePath } from 'next/cache' +import { prisma } from '@/lib/prisma' +import { answerQuestion } from '@/actions/questions' + +const mockPrisma = prisma as unknown as { + claudeQuestion: { + findFirst: ReturnType + updateMany: ReturnType + } +} +const mockRevalidate = revalidatePath as ReturnType + +const VALID_ID = 'cmohrz0jra1aaaaaaaaaaaaaa' +const VALID_ANSWER = 'Antwoord van de gebruiker' + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('actions/questions — answerQuestion', () => { + it('happy: status pending→answered, revalidatePath geroepen', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: true }) + + const updateArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0] + expect(updateArg.where).toMatchObject({ + id: VALID_ID, + status: 'open', + }) + expect(updateArg.where.expires_at).toMatchObject({ gt: expect.any(Date) }) + expect(updateArg.data).toMatchObject({ + status: 'answered', + answer: VALID_ANSWER, + answered_by: 'user-1', + }) + + expect(mockRevalidate).toHaveBeenCalledWith('/', 'layout') + }) + + it('demo-user wordt geblokkeerd, geen DB-call', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled() + expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled() + expect(mockRevalidate).not.toHaveBeenCalled() + }) + + it('user zonder product-access: error, geen update', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce(null) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag niet gevonden of geen toegang' }) + expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled() + }) + + it('al-answered: race-error met begrijpelijke melding', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + status: 'answered', + expires_at: new Date(Date.now() + 60_000), + }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag is al answered' }) + expect(mockRevalidate).not.toHaveBeenCalled() + }) + + it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) + mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + status: 'open', + expires_at: new Date(Date.now() - 60_000), + }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag is verlopen' }) + }) + + it('lege answer: Zod-validatie faalt', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + const res = await answerQuestion(VALID_ID, '') + expect(res.ok).toBe(false) + if (!res.ok) { + expect(res.error.toLowerCase()).toMatch(/string|character|leeg|empty|small/i) + } + expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/api/cron-expire-questions.test.ts b/__tests__/api/cron-expire-questions.test.ts new file mode 100644 index 0000000..d539d8b --- /dev/null +++ b/__tests__/api/cron-expire-questions.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeQuestion: { updateMany: vi.fn() }, + loginPairing: { updateMany: vi.fn() }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { POST } from '@/app/api/cron/expire-questions/route' + +const mockPrisma = prisma as unknown as { + claudeQuestion: { updateMany: ReturnType } + loginPairing: { updateMany: ReturnType } +} + +const SECRET = 'test-cron-secret-abc123' + +function makeReq(headers: Record = {}): Request { + return new Request('http://localhost:3000/api/cron/expire-questions', { + method: 'POST', + headers, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + process.env.CRON_SECRET = SECRET + mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 0 }) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 }) +}) + +describe('POST /api/cron/expire-questions', () => { + it('401 zonder Authorization-header', async () => { + const res = await POST(makeReq()) + expect(res.status).toBe(401) + expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled() + expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled() + }) + + it('401 met verkeerde secret', async () => { + const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' })) + expect(res.status).toBe(401) + }) + + it('401 als CRON_SECRET niet is gezet (faal-veilig)', async () => { + delete process.env.CRON_SECRET + const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET })) + expect(res.status).toBe(401) + }) + + it('200 met juiste secret + beide updateMany aangeroepen', async () => { + mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 3 }) + mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 }) + + const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ + expired_questions: 3, + expired_pairings: 1, + }) + expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + + // Question-update: status='open' AND expires_at < now → 'expired' + const qArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0] + expect(qArg.where).toMatchObject({ status: 'open' }) + expect(qArg.where.expires_at).toMatchObject({ lt: expect.any(Date) }) + expect(qArg.data).toEqual({ status: 'expired' }) + + // Pairing-update: status='pending' AND expires_at < now → 'cancelled' + const pArg = mockPrisma.loginPairing.updateMany.mock.calls[0][0] + expect(pArg.where).toMatchObject({ status: 'pending' }) + expect(pArg.data).toEqual({ status: 'cancelled' }) + }) +}) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts new file mode 100644 index 0000000..53fc590 --- /dev/null +++ b/__tests__/api/notifications-stream.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findMany: vi.fn() }, + claudeQuestion: { findMany: vi.fn() }, + }, +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn(), +})) + +import { prisma } from '@/lib/prisma' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/realtime/notifications/route' + +const mockPrisma = prisma as unknown as { + product: { findMany: ReturnType } + claudeQuestion: { findMany: ReturnType } +} + +function makeReq(): NextRequest { + // Minimaal NextRequest-shape voor de auth-pad — we komen niet bij de + // pg-stream-setup omdat de auth-fail vóór dat punt gebeurt. + return { signal: new AbortController().signal } as unknown as NextRequest +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +import { productAccessFilter } from '@/lib/product-access' + +const mockProductAccessFilter = productAccessFilter as ReturnType + +describe('GET /api/realtime/notifications', () => { + it('401 zonder iron-session cookie, geen DB-call', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await GET(makeReq()) + expect(res.status).toBe(401) + expect(mockPrisma.product.findMany).not.toHaveBeenCalled() + }) + + it('access-isolation: productAccessFilter wordt met de juiste userId aangeroepen', async () => { + // ST-1106: cross-product-isolatie zit in de productAccessFilter-Set die we + // bij connect opbouwen. We mocken de filter zodat product.findMany wel + // aangeroepen wordt maar geen producten retourneert; daarna stoppen we + // vóór de pg-stream-setup (DIRECT_URL ontbreekt → 500). + mockGetSession.mockResolvedValue({ userId: 'user-A', isDemo: false }) + mockProductAccessFilter.mockReturnValue({ user_id: 'user-A' }) + mockPrisma.product.findMany.mockResolvedValue([]) + + // We laten de stream zelf falen door DIRECT_URL/DATABASE_URL niet te zetten. + const before = { ...process.env } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq()) + expect(res.status).toBe(500) + } finally { + process.env.DIRECT_URL = before.DIRECT_URL + process.env.DATABASE_URL = before.DATABASE_URL + } + + // De filter is met de echte userId aangeroepen (cross-user lekt niet) + expect(mockProductAccessFilter).toHaveBeenCalledWith('user-A') + expect(mockPrisma.product.findMany).toHaveBeenCalledWith({ + where: { archived: false, user_id: 'user-A' }, + select: { id: true }, + }) + }) +}) + +// Solo-route filter (entity='question' uitgesloten) is een 1-regel-fix in +// app/api/realtime/solo/route.ts. Visueel reviewbaar in de diff; full-stream- +// regressie wordt handmatig gedekt in ST-1108-acceptatie. diff --git a/actions/questions.ts b/actions/questions.ts new file mode 100644 index 0000000..19a45bc --- /dev/null +++ b/actions/questions.ts @@ -0,0 +1,84 @@ +'use server' + +// ST-1103: Server Action voor het beantwoorden van een Claude-vraag (M11). +// +// Volgt docs/patterns/server-action.md: getSession + Zod + demo-blok + +// productAccessFilter. Atomic updateMany sluit double-submit uit; bij race +// (count=0) doet een tweede findFirst de disambiguatie tussen 'al beantwoord', +// 'verlopen', en 'niet gevonden of geen toegang'. +// +// revalidatePath('/', 'layout') refresh't de NavBar-bell badge-count voor +// SSR-rendered pages — de Zustand store + SSE in ST-1104/1105 dekken de +// realtime updates voor andere clients. + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { productAccessFilter } from '@/lib/product-access' + +const inputSchema = z.object({ + questionId: z.string().cuid(), + answer: z.string().min(1).max(4000), +}) + +type ActionResult = { ok: true } | { ok: false; error: string } + +export async function answerQuestion( + questionId: string, + answer: string, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const parsed = inputSchema.safeParse({ questionId, answer }) + if (!parsed.success) { + const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' + return { ok: false, error: first } + } + + // Access-check: gebruiker moet toegang hebben tot het product van de vraag. + // Iedereen met product-membership mag antwoorden — niet alleen de story- + // assignee — consistent met Scrum self-organizing. + const question = await prisma.claudeQuestion.findFirst({ + where: { + id: parsed.data.questionId, + product: productAccessFilter(session.userId), + }, + select: { id: true }, + }) + if (!question) return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + + // Atomic state-transitie: alleen open + niet-verlopen vragen worden beantwoord. + // Concurrent dubbele submit: PostgreSQL row-locking laat één caller count=1 + // zien, de rest count=0 → disambiguatie hieronder. + const updated = await prisma.claudeQuestion.updateMany({ + where: { + id: parsed.data.questionId, + status: 'open', + expires_at: { gt: new Date() }, + }, + data: { + status: 'answered', + answer: parsed.data.answer, + answered_by: session.userId, + answered_at: new Date(), + }, + }) + + if (updated.count !== 1) { + const exists = await prisma.claudeQuestion.findFirst({ + where: { id: parsed.data.questionId }, + select: { status: true, expires_at: true }, + }) + if (!exists) return { ok: false, error: 'Vraag niet gevonden' } + if (exists.status !== 'open') { + return { ok: false, error: `Vraag is al ${exists.status}` } + } + return { ok: false, error: 'Vraag is verlopen' } + } + + revalidatePath('/', 'layout') + return { ok: true } +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 1a5b3b9..fa41d4a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' +import { NotificationsBridge } from '@/components/notifications/notifications-bridge' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -92,6 +93,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod + diff --git a/app/api/cron/expire-questions/route.ts b/app/api/cron/expire-questions/route.ts new file mode 100644 index 0000000..7f60c8f --- /dev/null +++ b/app/api/cron/expire-questions/route.ts @@ -0,0 +1,46 @@ +// ST-1107: Vercel cron handler die verlopen Claude-vragen op 'expired' zet. +// +// Wordt dagelijks om 04:00 UTC door Vercel POST'd (zie vercel.json crons- +// config; Vercel Hobby-plan staat alleen daily crons toe — Pro ondersteunt +// fijnmaziger). Auth is +// via een gedeeld secret in de Authorization-header — Vercel injecteert +// `Authorization: Bearer ` automatisch wanneer de env-var op de +// project-omgeving staat. +// +// Bonus (ST-1107.4): zelfde route ruimt ook M10's verlopen `pending` +// login_pairings op. Reden: één cron-job is goedkoper qua Vercel-budget en +// houdt de cleanup-strategie centraal. + +import { prisma } from '@/lib/prisma' + +export const runtime = 'nodejs' + +export async function POST(request: Request) { + const auth = request.headers.get('authorization') + const expected = process.env.CRON_SECRET + if (!expected || auth !== `Bearer ${expected}`) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const now = new Date() + + // M11: open Claude-vragen → expired + const expiredQuestions = await prisma.claudeQuestion.updateMany({ + where: { status: 'open', expires_at: { lt: now } }, + data: { status: 'expired' }, + }) + + // M10: pending login_pairings die niet meer bruikbaar zijn → cancelled + // (status='expired' bestaat niet voor pairings; cancelled heeft hetzelfde + // resultaat: niet-claimable, niet meer in de SSE-listener.) + const expiredPairings = await prisma.loginPairing.updateMany({ + where: { status: 'pending', expires_at: { lt: now } }, + data: { status: 'cancelled' }, + }) + + return Response.json({ + expired_questions: expiredQuestions.count, + expired_pairings: expiredPairings.count, + ran_at: now.toISOString(), + }) +} diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts new file mode 100644 index 0000000..f31c6d5 --- /dev/null +++ b/app/api/realtime/notifications/route.ts @@ -0,0 +1,194 @@ +// ST-1104: User-scoped Server-Sent Events stream voor de notificatie-bel (M11). +// +// Wordt door in app/(app)/layout.tsx gemount zodra de +// gebruiker is ingelogd. In tegenstelling tot /api/realtime/solo (product- +// scoped, voor één Solo-bord) is deze stream **user-scoped**: hij filtert +// op alle producten waar de ingelogde user toegang toe heeft, zodat de bell +// flikkert ongeacht op welke pagina je staat. +// +// Auth: iron-session cookie. Demo-tokens mogen lezen. +// Output: text/event-stream — `event:state` met initial open-questions list, +// daarna `data:` events bij elke status-overgang in scrum4me_changes. +// Sluit zelf na 240s als safety-net; client herconnect. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +interface NotifyPayload { + op: 'I' | 'U' + entity: 'task' | 'story' | 'question' + id: string + product_id: string + story_id?: string + task_id?: string | null + assignee_id?: string | null + status?: string +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + const userId = session.userId + + // Haal alle accessible product-IDs één keer op — gebruikt voor SSE-filter en + // voor de initial-state query. Geen real-time refresh als de user halverwege + // toegang krijgt of verliest; reconnect lost dat op (frontend doet dat al). + const products = await prisma.product.findMany({ + where: { archived: false, ...productAccessFilter(userId) }, + select: { id: true }, + }) + const accessibleProductIds = new Set(products.map((p) => p.id)) + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json( + { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, + { status: 500 }, + ) + } + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // controller al gesloten — negeren + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + try { + await pgClient.end() + } catch { + // ignore + } + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/notifications] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/notifications] pg connect/listen failed:', err) + enqueue( + `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, + ) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (payload.entity !== 'question') return + if (!accessibleProductIds.has(payload.product_id)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', (err) => { + console.error('[realtime/notifications] pg client error:', err) + cleanup('pg error') + }) + + // Initial state ná LISTEN actief — race-fix conform M10 ST-1004 / ST-1006. + // Voorkomt dat een vraag die net vóór SSE-open landt verloren gaat. + const openQuestions = await prisma.claudeQuestion.findMany({ + where: { + status: 'open', + expires_at: { gt: new Date() }, + product_id: { in: products.map((p) => p.id) }, + }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + product_id: true, + story_id: true, + task_id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + story: { select: { code: true, title: true, assignee_id: true } }, + }, + }) + + enqueue( + `event: state\ndata: ${JSON.stringify({ + questions: 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: q.options, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + })), + })}\n\n`, + ) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 06127ff..ba68b63 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -25,7 +25,10 @@ const HARD_CLOSE_MS = 240_000 interface NotifyPayload { op: 'I' | 'U' | 'D' - entity: 'task' | 'story' + // M11 (ST-1101) voegt entity:'question' toe op hetzelfde scrum4me_changes- + // kanaal; we filteren die hieronder weg zodat solo-clients geen + // notification-events ontvangen waar ze niets mee doen. + entity: 'task' | 'story' | 'question' id: string story_id?: string product_id: string @@ -40,6 +43,9 @@ function shouldEmit( activeSprintId: string | null, userId: string, ): boolean { + // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. + if (payload.entity === 'question') return false + if (payload.product_id !== productId) return false // Sprint scope: alleen events binnen de actieve sprint (of zonder sprint diff --git a/components/notifications/answer-modal.tsx b/components/notifications/answer-modal.tsx new file mode 100644 index 0000000..cbe574d --- /dev/null +++ b/components/notifications/answer-modal.tsx @@ -0,0 +1,157 @@ +'use client' + +// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11). +// +// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de +// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via +// useTransition; bij succes wordt de vraag uit de store verwijderd +// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit +// disabled met tooltip. + +import { useState, useTransition } from 'react' +import Link from 'next/link' +import { ExternalLink } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { answerQuestion } from '@/actions/questions' +import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' + +const MAX_ANSWER_CHARS = 4000 + +interface AnswerModalProps { + question: NotificationQuestion | null + isDemo: boolean + onClose: () => void +} + +export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { + const [answer, setAnswer] = useState('') + const [pending, startTransition] = useTransition() + + if (!question) return null + + const charsLeft = MAX_ANSWER_CHARS - answer.length + const tooLong = charsLeft < 0 + const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong + + function submit(text: string) { + if (!question) return + if (isDemo) { + toast.error('Niet beschikbaar in demo-modus') + return + } + startTransition(async () => { + const res = await answerQuestion(question.id, text) + if (!res.ok) { + toast.error(res.error) + return + } + // Optimistisch verwijderen — SSE-event komt anders later met dezelfde + // remove en kost een extra render + useNotificationsStore.getState().remove(question.id) + toast.success('Antwoord verstuurd') + setAnswer('') + onClose() + }) + } + + return ( + !open && onClose()}> + + + Beantwoord Claude + + {question.story_code ?? 'story'} + {' — '} + {question.story_title} + + + + + Open in Sprint + + +
+ {question.question} +
+ + {question.options && question.options.length > 0 ? ( +
+

Kies een van de opties:

+
+ {question.options.map((opt) => ( + + ))} +
+
+ ) : ( +
+