From 2460eae9dfa3f706cf07ee1c204a51e1dd7ba57b Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Mon, 27 Apr 2026 13:59:32 +0200 Subject: [PATCH] feat(M8): Realtime Solo Paneel via Postgres LISTEN/NOTIFY (ST-801..ST-806) (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ST-801): pg_notify triggers on tasks and stories Add notify_task_change() and notify_story_change() PL/pgSQL functions plus AFTER INSERT/UPDATE/DELETE triggers on tasks and stories. Each write emits a JSONB payload on the 'scrum4me_changes' channel with op, entity, id, product_id, sprint_id, assignee_id and (for UPDATE) the list of changed columns. Tasks resolve product/sprint/assignee via their parent story so the SSE handler can filter without an extra DB roundtrip. The migration is a side-effect-only change (no Prisma model/schema diff) so the Prisma Client and TypeScript types are unaffected. Verified locally with a node-pg LISTEN client: both task and story mutations produce the expected payload within milliseconds. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-802): SSE route /api/realtime/solo for Solo Paneel updates Node.js-runtime route handler die een dedicated pg-Client opent op DIRECT_URL en LISTEN't op het scrum4me_changes-kanaal. Per inkomende NOTIFY-payload filtert het server-side op product (uit query-param), sprint (active sprint van het product) en persoonlijke relevantie (assignee_id == userId, of assignee_id IS NULL voor stories voor de claim-lijst). Auth via iron-session cookie: - 401 als sessie ontbreekt - 400 als product_id query-param ontbreekt - 403 als de user geen toegang heeft tot het product - demo-tokens mogen lezen (geen write-tools op deze route) Stream-bouw: - text/event-stream met juiste headers (no-cache, no-transform, X-Accel-Buffering: no voor proxy-vrije buffering) - ready-event bij connect met product_id en active sprint_id - heartbeat (SSE-comment) elke 25s - hard-close na 240s als safety-net onder Vercel maxDuration; client herconnect via EventSource - cleanup op request.signal abort (tab dicht / refresh) Cleanup-pad sluit de pg-client en de stream-controller idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-803): useSoloRealtime hook with EventSource lifecycle Client-only hook die de SSE-stream van /api/realtime/solo opent en voor de UI twee statussen exposed: - status: 'connecting' | 'open' | 'disconnected' - showConnectingIndicator: pas true als status !== 'open' >2s duurt (ST-805 animatie B — voorkomt flikker bij microscopische disconnects) Lifecycle-gedrag: - Reconnect met exponential backoff start 1s, plafond 30s, reset op 'ready'-event - Page Visibility API: bij hidden sluit de hook de connectie en stopt reconnect-pogingen; bij visible reopent direct - onerror van EventSource wordt onderschept zodat we eigen backoff kunnen voeren ipv de browser-default - Volledige cleanup op unmount Voert events naar useSoloStore.getState().handleRealtimeEvent — de echte dispatch-logica met pendingOps en gedifferentieerde apply{Task,Story}{Update,Create,Delete} landt in ST-804. Hier is dat nog een stub zodat dit zelf-staand kan compileren. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-804): solo-store realtime dispatch + pendingOps Wire de SSE-events uit /api/realtime/solo door naar de Zustand-store zodat het Solo Paneel zonder refresh meebeweegt met DB-mutaties uit welke bron dan ook (web, REST, MCP). Migratie 20260427000216_extend_realtime_payload: voegt new-state velden aan de pg_notify-payload toe (task_status, task_sort_order, task_title, story_status, story_sort_order, story_title, story_code) zodat de client geen extra fetch nodig heeft per event. Store-uitbreiding (stores/solo-store.ts): - pendingOps: Set die optimistic-writes markeert; realtime echos voor die ids worden onderdrukt zodat eigen UI-mutaties niet twee keer toegepast worden of door een latere echo overschreven - handleRealtimeEvent: dispatch op entity + op - task UPDATE/INSERT: bestaande tasks krijgen status/title/sort_order bijgewerkt; onbekende tasks worden genegeerd (story-context ontbreekt — gebruiker ziet ze pas na refresh) - task DELETE: verwijdert uit store - story UPDATE: werkt story_title/story_code bij op alle child-tasks in de store - story DELETE: verwijdert alle child-tasks (cascade reflectie) Unit-test: 7 scenario's (status update, pendingOps echo-suppression, DELETE, story-rename cascade, story-delete cascade, unknown task no-op). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-805): wire useSoloRealtime + live indicator + column-move animatie - SoloBoard roept useSoloRealtime(productId) aan, zo komt elke task/ story-mutatie uit web/REST/MCP binnen via SSE en wordt door de store-dispatcher (ST-804) verwerkt - markPending/clearPending rond de drag-drop optimistic write zodat de echo van de eigen Server Action de store niet dubbel beweegt - RealtimeIndicator: kleine status-dot in de header - groen ('Live') wanneer SSE-stream open OF tijdens de eerste 2s grace-period (animatie B in de hook — voorkomt micro-flikker) - grijs ('Verbinden…') na 2s in connecting-state - rood ('Verbroken') na 2s in disconnected-state - Animatie A (kolom-move): bij task UPDATE-events wikkelt de hook de store-dispatch in document.startViewTransition + flushSync. SoloTask- cards krijgen view-transition-name `solo-task-` (alleen wanneer niet aan het draggen) zodat de browser de positie-shift soepel animeert van bezig naar klaar (en omgekeerd) Bestaande 89 tests blijven groen. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: pin dev to port 3000 + predev hook to clear stale processes Voorkomt dat een stale next-dev op 3000 ervoor zorgt dat een tweede 'npm run dev' op 3001 start — wat sessies, cookies en MCP-config inconsistent maakt. - dev: '-p 3000' expliciet - predev: lsof/kill alles op 3000 (idempotent — falen is ok) - CLAUDE.md: regel toegevoegd onder Conventies Co-Authored-By: Claude Opus 4.7 (1M context) * fix(M8): make SSE-stream survive Solo Paneel mutations Symptoom op feat/ST-801-realtime-triggers initial implementation: elke task-update sloot de open SSE-stream af en triggerde een herverbinding met backoff. In de tussentijd gemiste events. Oorzaak: Server Actions in App Router doen een impliciete route-tree refresh die client components remount; daarmee killt React de useEffect die de EventSource beheert. Fix in twee delen: 1. Hef de realtime-hook op naar de (app)-layout via een nieuwe `SoloRealtimeBridge`-component. Layouts overleven Server- Action-refreshes beter dan pages, en de bridge leest het product-id uit de URL via usePathname. Connection-status (status, showConnectingIndicator) gaat naar de solo-store zodat SoloBoard 'm uit een gedeelde plek kan lezen. 2. Vervang updateTaskStatusAction en updateTaskPlanAction in de Solo-componenten door fetch naar de bestaande Route Handler `PATCH /api/tasks/[id]`. Route Handlers triggeren geen page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts accepteert nu naast Bearer-tokens ook iron-session cookies zodat browser-fetches zonder token werken. Bijkomend: actions/tasks.ts laat /solo bewust niet meer revalideren (wordt nu via realtime gedekt). Sprint/planning blijft wel revalidaten — geen realtime daar. Toegevoegd: - components/solo/realtime-bridge.tsx — mount in (app) layout - scripts/realtime-mutate.ts — handige test-helper voor externe mutaties (alsof MCP/REST schrijft) tijdens acceptance Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor ST-806 acceptance; worden later gestript. Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource om de paar seconden vanwege de 6-connectie-limiet en retry- heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2 multiplexing) zou beide browsers stabiel moeten houden — Vercel preview test is volgende stap. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(dev): disable reactStrictMode for stable SSE testing locally Strict Mode dubbel-mount maakt langlopende connecties tijdens ontwikkeling moeilijk te observeren — de mount/unmount cycles in dev rondom Hot Reload + Turbopack triggeren herhaalde EventSource- reconnects die verbergen of de productie-flow stabiel is. Productie kent dit gedrag niet (Strict Mode is dev-only). Heroverwogen als M8 acceptance rond is — kan dan weer aan voor andere effects- discipline. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(M8): unguard debug logs in /api/realtime/solo for Vercel diagnose Maakt de connect/listen/notify-logs zichtbaar in Vercel function-logs op preview zodat we kunnen zien waarom events niet doorkomen ondanks DIRECT_URL-fix. Logt ook hostname (gemaskeerd) + sprint id bij connect, en pg client errors + end-events voor closed connections. Wordt gestript in ST-806 voordat de PR uit draft gaat. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): add /debug-realtime page + bare SSE endpoint Tijdelijke debug-tooling voor M8-acceptance op Vercel preview. - app/api/debug/realtime-stream/route.ts — geen auth, geen filtering; dropt elke pg_notify-event op scrum4me_changes rauw door als SSE - app/debug-realtime/page.tsx — open zonder login op de root, toont binnenkomende events in een simpele Doel: isoleren of de SSE + Postgres LISTEN-pipe op Vercel überhaupt events laat zien, los van iron-session, productfilter of solo-store. Als ook deze niets binnen krijgt: probleem zit in pg connection of Vercel function lifecycle. Als deze wel events toont: probleem zit hoger in de stack (filter, store, hook). VERWIJDEREN voordat de PR uit draft gaat. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): extend /debug-realtime with stats, emit-button and filters Bouwt de basale luister-tabel uit met diagnostische tooling om de SSE+LISTEN-pipe stress-vrij te kunnen valideren. Toegevoegd: - POST /api/debug/emit-test-notify — vuurt een handmatige pg_notify op scrum4me_changes met een synthetic payload (debug:true) zonder een echte DB-UPDATE te doen. Isoleert de SSE-route van Prisma/triggers. - DebugRealtimeClient: stats-grid (status, reconnects, total events, since last event met >30s rood-warning, largest gap, first-event- time), emit-button, reset-stats, filters op type en entity (incl. "debug only"). - Type/entity kolom in de tabel met kleuring per type. Geen impact op productie- of solo-flow. Tijdelijke testtooling; verwijderen wanneer we deze pagina niet meer nodig hebben. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): add Layer 2 — mini Zustand-store + dispatch toggles Test of SSE-event → store → render-pipeline werkt buiten de Solo Paneel context. Mirrort het patroon van solo-store maar minimaal. - debug-store.ts: kleine Zustand-store met tasks + applyEvent + applyCount/skipCount-tellers - store-panel.tsx: rendert store-state in een tabel met statuskleuring - client.tsx: drie layer-toggles (dispatch / flushSync / startView- Transition) + lift dispatch in onmessage. Zo kunnen we elke combinatie isoleren Bevestigd: alle drie de toggles werken op het bare /debug-realtime endpoint. Volgende laag is Server Action revalidation. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ST-806): cleanup debug-pages, document realtime, restore strict mode M8 acceptance heeft de end-to-end pipeline bevestigd: trigger → NOTIFY → SSE → store → View Transition. De cycling-symptomen waren een artefact van testen via terminal (alt-tab triggert visibility-pause-by-design), geen bug. Tijd om de tijdelijke instrumentatie en debug-pagina's weg te halen en de architectuur op te schrijven. - Verwijder /debug-realtime, /(app)/debug-realtime-app, /api/debug/* - Strip debug-logs uit /api/realtime/solo (closed-reden alleen in dev) - reactStrictMode weer aan - CONNECTING_INDICATOR_DELAY_MS 2s → 4s (minder flikker bij micro-disconnects) - Nieuwe sectie "Realtime updates (M8)" in scrum4me-architecture.md: diagram, NOTIFY-bron, server-filter, connection lifecycle inclusief visibility-pause + bekende beperking, animatie, auth - DIRECT_URL env-rij uitgebreid met realtime-doel - GET /api/realtime/solo gedocumenteerd in API.md Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + __tests__/stores/solo-store-realtime.test.ts | 109 ++++++++++ actions/tasks.ts | 7 +- app/(app)/layout.tsx | 2 + app/api/realtime/solo/route.ts | 192 ++++++++++++++++++ components/solo/realtime-bridge.tsx | 21 ++ components/solo/solo-board.tsx | 90 +++++++- components/solo/solo-task-card.tsx | 9 +- components/solo/task-detail-dialog.tsx | 24 ++- docs/API.md | 48 +++++ docs/scrum4me-architecture.md | 69 ++++++- lib/api-auth.ts | 43 ++-- lib/realtime/use-solo-realtime.ts | 153 ++++++++++++++ next.config.ts | 1 + package.json | 3 +- .../migration.sql | 124 +++++++++++ .../migration.sql | 111 ++++++++++ scripts/realtime-mutate.ts | 81 ++++++++ stores/solo-store.ts | 159 +++++++++++++++ 19 files changed, 1215 insertions(+), 32 deletions(-) create mode 100644 __tests__/stores/solo-store-realtime.test.ts create mode 100644 app/api/realtime/solo/route.ts create mode 100644 components/solo/realtime-bridge.tsx create mode 100644 lib/realtime/use-solo-realtime.ts create mode 100644 prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql create mode 100644 prisma/migrations/20260427000216_extend_realtime_payload/migration.sql create mode 100644 scripts/realtime-mutate.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7c6b301..251ef41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,7 @@ SESSION_SECRET="" # openssl rand -base64 32 - **Status-enums op API:** lowercase (`todo|in_progress|review|done`, `open|in_sprint|done`); DB houdt UPPER_SNAKE; conversie uitsluitend via `lib/task-status.ts`-mappers — nooit ad-hoc `.toLowerCase()` elders - **Foutcodes API:** `400` alleen voor malformed JSON-body (parse-fout via `request.json()`); `422` voor zod-validatie en well-formed-maar-niet-acceptabel; `403` voor demo-tokens. Documenteer per endpoint in `docs/API.md` - **Tests volgen contract:** bij een API-contract-wijziging (status, foutcode, response-shape) MOET in dezelfde commit ook `__tests__/api/` mee — een test die rood gaat omdat de oude waarde wordt verwacht is een onvolledige wijziging, niet een "kapotte test" +- **Dev port:** `npm run dev` draait altijd op **3000**. Een `predev`-hook killt vooraf elk proces op 3000 (stale Next.js dev-server, vorige sessie) zodat sessies, cookies en MCP-config consistent op één poort werken. Wijk hier niet van af — geen `-p 3001` o.i.d. tenzij je expliciet twee dev-servers naast elkaar wil draaien --- diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts new file mode 100644 index 0000000..589b961 --- /dev/null +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeEvent } from '@/stores/solo-store' +import type { SoloTask } from '@/components/solo/solo-board' + +const baseTask = (id: string, overrides: Partial = {}): SoloTask => ({ + id, + title: `Task ${id}`, + description: null, + implementation_plan: null, + priority: 1, + sort_order: 1, + status: 'TO_DO', + story_id: 'story-1', + story_code: 'ST-100', + story_title: 'Original Story', + task_code: 'ST-100.1', + ...overrides, +}) + +const taskEvent = (overrides: Partial): RealtimeEvent => ({ + op: 'U', + entity: 'task', + id: 'task-1', + story_id: 'story-1', + product_id: 'prod-1', + sprint_id: 'sprint-1', + assignee_id: 'user-1', + ...overrides, +}) + +const storyEvent = (overrides: Partial): RealtimeEvent => ({ + op: 'U', + entity: 'story', + id: 'story-1', + product_id: 'prod-1', + sprint_id: 'sprint-1', + assignee_id: 'user-1', + ...overrides, +}) + +describe('solo-store realtime', () => { + beforeEach(() => { + useSoloStore.setState({ tasks: {}, pendingOps: new Set() }) + }) + + it('applies a task status update', () => { + useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })]) + useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'IN_PROGRESS' })) + expect(useSoloStore.getState().tasks['task-1'].status).toBe('IN_PROGRESS') + }) + + it('skips a task update when pendingOps has the id', () => { + useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })]) + useSoloStore.getState().markPending('task-1') + useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' })) + expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO') + }) + + it('applies the update again once pendingOps is cleared', () => { + useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })]) + useSoloStore.getState().markPending('task-1') + useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' })) + expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO') + useSoloStore.getState().clearPending('task-1') + useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' })) + expect(useSoloStore.getState().tasks['task-1'].status).toBe('DONE') + }) + + it('removes a task on D op', () => { + useSoloStore.getState().initTasks([baseTask('task-1'), baseTask('task-2')]) + useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-1', op: 'D' })) + expect(useSoloStore.getState().tasks['task-1']).toBeUndefined() + expect(useSoloStore.getState().tasks['task-2']).toBeDefined() + }) + + it('ignores task INSERT/UPDATE for tasks not in the store', () => { + useSoloStore.getState().initTasks([baseTask('task-1')]) + useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-other', task_status: 'DONE' })) + expect(Object.keys(useSoloStore.getState().tasks)).toEqual(['task-1']) + }) + + it('updates story_title/code on all child tasks via story UPDATE', () => { + useSoloStore.getState().initTasks([ + baseTask('task-1', { story_id: 'story-1' }), + baseTask('task-2', { story_id: 'story-1' }), + baseTask('task-3', { story_id: 'story-other', story_title: 'Other' }), + ]) + useSoloStore.getState().handleRealtimeEvent( + storyEvent({ story_title: 'Renamed Story', story_code: 'ST-100B' }), + ) + expect(useSoloStore.getState().tasks['task-1'].story_title).toBe('Renamed Story') + expect(useSoloStore.getState().tasks['task-1'].story_code).toBe('ST-100B') + expect(useSoloStore.getState().tasks['task-2'].story_title).toBe('Renamed Story') + expect(useSoloStore.getState().tasks['task-3'].story_title).toBe('Other') + }) + + it('removes all tasks of a story on story DELETE', () => { + useSoloStore.getState().initTasks([ + baseTask('task-1', { story_id: 'story-1' }), + baseTask('task-2', { story_id: 'story-1' }), + baseTask('task-3', { story_id: 'story-other' }), + ]) + useSoloStore.getState().handleRealtimeEvent(storyEvent({ op: 'D' })) + expect(useSoloStore.getState().tasks['task-1']).toBeUndefined() + expect(useSoloStore.getState().tasks['task-2']).toBeUndefined() + expect(useSoloStore.getState().tasks['task-3']).toBeDefined() + }) +}) diff --git a/actions/tasks.ts b/actions/tasks.ts index 4dcbc69..f70c452 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -101,8 +101,11 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P await prisma.task.update({ where: { id }, data: { status } }) + // /solo bewust niet revalideren: dat zou de page soft-navigaten en de + // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic + // store-updates + realtime echo (M8). Sprint/planning heeft geen + // realtime en moet wèl revalidaten. revalidatePath(`/products/${task.story.product_id}/sprint/planning`) - revalidatePath(`/products/${task.story.product_id}/solo`) return { success: true } } @@ -150,7 +153,7 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im data: { implementation_plan: implementationPlan || null }, }) - revalidatePath(`/products/${productId}/solo`) + // /solo bewust niet revalideren — zie updateTaskStatusAction. revalidatePath(`/products/${productId}/sprint/planning`) return { success: true } } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 5bb4999..22bad22 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma' 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' export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getIronSession(await cookies(), sessionOptions) @@ -47,6 +48,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {children} + ) } diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts new file mode 100644 index 0000000..06127ff --- /dev/null +++ b/app/api/realtime/solo/route.ts @@ -0,0 +1,192 @@ +// ST-802: Server-Sent Events stream voor het Solo Paneel. +// +// Opent een dedicated pg-verbinding (DIRECT_URL) en LISTEN't op het +// `scrum4me_changes`-kanaal. Filtert events server-side op product +// (uit query-param), sprint (actieve sprint van het product), en +// persoonlijke relevantie (assignee_id == userId, of assignee_id IS NULL +// voor stories — claim-lijst). +// +// Auth: iron-session cookie. Demo-tokens mogen lezen. +// Output: text/event-stream met JSON-payloads + heartbeat-comments. +// Sluit zelf na 240s als safety-net; client herconnect. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } 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' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + product_id: string + sprint_id: string | null + assignee_id: string | null + changed_fields?: string[] +} + +function shouldEmit( + payload: NotifyPayload, + productId: string, + activeSprintId: string | null, + userId: string, +): boolean { + if (payload.product_id !== productId) return false + + // Sprint scope: alleen events binnen de actieve sprint (of zonder sprint + // voor unassigned-story claims die in de claim-sheet horen). + if (payload.entity === 'story' && payload.assignee_id === null) { + // Unassigned story (claim-lijst) — toon altijd, ongeacht sprint + return payload.sprint_id === activeSprintId || payload.sprint_id === null + } + if (payload.sprint_id !== activeSprintId) return false + + // Persoonlijke relevantie + return payload.assignee_id === userId +} + +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 + + const productId = request.nextUrl.searchParams.get('product_id') + if (!productId) { + return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) + } + + const product = await getAccessibleProduct(productId, userId) + if (!product) { + return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) + } + + 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 { + // Stream al gesloten — controller throwt, 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/solo] closed: ${reason}`) + } + } + + // Resolve actieve sprint éénmalig per connectie + const sprint = await prisma_sprint_findActive(productId) + const activeSprintId = sprint?.id ?? null + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/solo] 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 (!shouldEmit(payload, productId, activeSprintId, userId)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[realtime/solo] pg client error:', err) + await cleanup('pg error') + }) + + // Stuur eerst een "ready"-event zodat de client weet dat de connectie staat + enqueue( + `event: ready\ndata: ${JSON.stringify({ + product_id: productId, + sprint_id: activeSprintId, + })}\n\n`, + ) + + // Heartbeat als SSE-comment — voorkomt proxy-timeouts + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + // Hard-close safety: Vercel kapt na maxDuration; we sluiten zelf eerder + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + // Client trekt de stekker (tab dicht, refresh, etc.) + 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', + }, + }) +} + +// Lokaal helper — Prisma vermijden voor deze ene query om de pg-only flow +// schoon te houden. Geeft de actieve sprint van een product, of null. +async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> { + const { prisma } = await import('@/lib/prisma') + return prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + orderBy: { created_at: 'desc' }, + }) +} diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx new file mode 100644 index 0000000..bd04f69 --- /dev/null +++ b/components/solo/realtime-bridge.tsx @@ -0,0 +1,21 @@ +// SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding +// blijft staan over Server Action-refreshes van de Solo-page heen. +// +// Leest het huidige product-id uit de URL (`/products/[id]/solo`). +// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream +// gesloten — geen onnodige verbinding open houden. + +'use client' + +import { usePathname } from 'next/navigation' +import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' + +const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/ + +export function SoloRealtimeBridge() { + const pathname = usePathname() + const match = pathname?.match(SOLO_PATH_RE) + const productId = match?.[1] ?? null + useSoloRealtime(productId) + return null +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 60cddc4..934f984 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -7,12 +7,54 @@ import { } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import { updateTaskStatusAction } from '@/actions/tasks' +import type { RealtimeStatus } from '@/stores/solo-store' +import { taskStatusToApi } from '@/lib/task-status' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { SoloColumn, type ColumnStatus } from './solo-column' import { SoloTaskCardOverlay } from './solo-task-card' import { TaskDetailDialog } from './task-detail-dialog' import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' +// ST-805: kleine status-dot in de header — groen wanneer SSE-stream open +// is, grijs/rood pas zichtbaar als de connectie >4s niet open is (animatie B +// zit in useSoloRealtime). Default groen tijdens de eerste 4s zodat micro- +// disconnects geen flikker geven. +function RealtimeIndicator({ + status, + showConnectingIndicator, +}: { + status: RealtimeStatus + showConnectingIndicator: boolean +}) { + let color = 'bg-status-done' + let label = 'Live' + if (showConnectingIndicator) { + if (status === 'disconnected') { + color = 'bg-priority-critical' + label = 'Verbroken — opnieuw proberen…' + } else { + color = 'bg-muted-foreground' + label = 'Verbinden…' + } + } + return ( + + + + } + /> + {label} + + + ) +} + export interface SoloTask { id: string title: string @@ -47,7 +89,9 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { export function SoloBoard({ productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { - const { tasks, initTasks, optimisticMove, rollback } = useSoloStore() + const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() + const realtimeStatus = useSoloStore((s) => s.realtimeStatus) + const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) @@ -82,18 +126,42 @@ export function SoloBoard({ const toStatus = over.id as ColumnStatus if (!COLUMN_STATUSES.includes(toStatus)) return - const task = tasks[active.id as string] + const taskId = active.id as string + const task = tasks[taskId] if (!task) return if (getColumnStatus(task.status) === toStatus) return - const prevStatus = optimisticMove(active.id as string, toStatus) + const prevStatus = optimisticMove(taskId, toStatus) if (!prevStatus) return + // Onderdruk realtime-echo van onze eigen write — de Postgres-trigger + // vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker + // zou de store nogmaals een set() doen of de optimistic state + // overschrijven. clearPending na de fetch (succes of fail). + // + // We gebruiken bewust een fetch-based Route Handler in plaats van + // de updateTaskStatusAction Server Action — Server Actions + // triggeren een full route-tree refresh die de open SSE-stream van + // /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten + // en realtime-events missen. + markPending(taskId) startTransition(async () => { - const result = await updateTaskStatusAction(active.id as string, toStatus) - if (result && 'error' in result) { - rollback(active.id as string, prevStatus) + try { + const res = await fetch(`/api/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ status: taskStatusToApi(toStatus) }), + }) + if (!res.ok) { + rollback(taskId, prevStatus) + toast.error('Status bijwerken mislukt — taak teruggeplaatst') + } + } catch { + rollback(taskId, prevStatus) toast.error('Status bijwerken mislukt — taak teruggeplaatst') + } finally { + clearPending(taskId) } }) } @@ -118,7 +186,13 @@ export function SoloBoard({
-

{productName}

+
+

{productName}

+ +
{sprintGoal && (

{sprintGoal}

)} diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 6378010..61135c8 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -1,5 +1,6 @@ 'use client' +import type React from 'react' import { useDraggable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' import { cn } from '@/lib/utils' @@ -25,7 +26,13 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { disabled: isDemo, }) - const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined + // view-transition-name laat de browser deze card snapshotten zodat hij + // soepel van kolom naar kolom animeert wanneer de status realtime wijzigt + // (ST-805 animatie A). Tijdens drag uit zetten — dnd-kit beheert de + // transform dan zelf en dubbele transitions willen we niet. + const style: React.CSSProperties | undefined = transform + ? { transform: CSS.Translate.toString(transform) } + : { viewTransitionName: `solo-task-${task.id}` } return (
{ - const result = await updateTaskPlanAction(task.id, productId, localPlan) - if (result && 'error' in result) { - setSaveState('idle') - toast.error(typeof result.error === 'string' ? result.error : 'Implementatieplan opslaan mislukt') - } else { + try { + const res = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ implementation_plan: localPlan }), + }) + if (!res.ok) { + setSaveState('idle') + toast.error('Implementatieplan opslaan mislukt') + return + } savedPlanRef.current = localPlan updatePlan(task.id, localPlan || null) setSaveState('saved') fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000) + } catch { + setSaveState('idle') + toast.error('Implementatieplan opslaan mislukt') } }) } diff --git a/docs/API.md b/docs/API.md index ba9a00e..b8f9db4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -275,6 +275,54 @@ Nieuwe todo voor de tokengebruiker. --- +### `GET /api/realtime/solo?product_id=...` + +Server-Sent Events stream voor het Solo Paneel. Wordt gebruikt door de browser-UI (`useSoloRealtime`); voor Claude Code zelden relevant, maar gedocumenteerd voor volledigheid. + +**Auth:** iron-session cookie of Bearer-token. Demo-tokens mogen lezen. +**Query params:** `product_id` (verplicht). +**Response:** `text/event-stream`. Stream blijft open tot de client sluit of de server na 240s een hard-close doet (client herconnect dan transparant). + +**Events:** +- `event: ready` — eenmalig direct na connect, met `{ product_id, sprint_id }` als payload. +- `event: error` — bij interne fouten (pg connect mislukt e.d.). +- `data: {...}` — task/story mutaties die binnen scope vallen (zie hieronder). Payload-shape: + + ```json + { + "op": "I" | "U" | "D", + "entity": "task" | "story", + "id": "cmof...", + "story_id": "cmof...", + "product_id": "cmof...", + "sprint_id": "cmog..." , + "assignee_id": "cmof..." , + "task_status": "TO_DO" | "IN_PROGRESS" | "REVIEW" | "DONE", + "task_title": "...", + "task_sort_order": 1, + "changed_fields": ["status", "updated_at"] + } + ``` + + Niet alle velden zijn altijd aanwezig — `task_*` alleen voor `entity: "task"`, idem `story_*`. `task_status` gebruikt de **DB-enum** (UPPER_SNAKE), niet de lowercase API-vorm. + +- `: heartbeat` — SSE-comment elke 25s, om proxies keep-alive te houden. Kan genegeerd worden. + +**Server-side filter:** +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product +- `assignee_id` is gelijk aan de ingelogde user (of `null` voor unassigned-story claims) + +Niet-matchende events worden gedropt — clients ontvangen geen irrelevante data. + +**Voorbeeld (browser):** +```js +const source = new EventSource('/api/realtime/solo?product_id=cmof...') +source.onmessage = (e) => console.log(JSON.parse(e.data)) +``` + +--- + ## Voorbeeldworkflow voor Claude Code 1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index bdf05f3..d25b6c8 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -771,12 +771,79 @@ interface ProductStore { --- +## Realtime updates (M8) + +Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ PATCH /api/tasks/:id +└────────────┬────────────┘ Server Action, MCP, etc. + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ bouwt JSON payload +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/solo │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op product + sprint + assignee +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useSoloRealtime +│ → solo-store.handleEvent│ via flushSync + startViewTransition +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ SoloBoard re-render │ kanban-kaartje animeert naar +│ (View Transitions API) │ zijn nieuwe kolom +└─────────────────────────┘ +``` + +**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). +**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. +**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. + +### Mutaties die NOTIFY triggeren + +De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. + +### Server-side filter + +`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) +- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) + +Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. + +### Connection lifecycle + +- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker op `/solo` is. +- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). +- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. +- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. +- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. + +**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. + +### Animatie + +Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. + +### Auth + +Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. + +--- + ## Environment variables | Variabele | Doel | Waar te vinden | |---|---|---| | `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) | -| `DIRECT_URL` | Directe verbinding voor migraties (Neon) | Neon dashboard → Connection string (unpooled) | +| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) | | `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` | | `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node | diff --git a/lib/api-auth.ts b/lib/api-auth.ts index 2198480..95e6ea1 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -1,23 +1,40 @@ import { createHash } from 'crypto' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' import { prisma } from '@/lib/prisma' +import { sessionOptions, type SessionData } from '@/lib/session' +// Probeert eerst Bearer-token (REST/MCP), valt terug op iron-session +// cookie (browser fetches vanuit ingelogde sessie). Cookie-pad is bewust +// voor Solo Paneel-mutations die anders via Server Action zouden gaan — +// maar Server Actions triggeren een page-refresh die SSE-streams sluit. export async function authenticateApiRequest(request: Request) { const authHeader = request.headers.get('Authorization') - if (!authHeader?.startsWith('Bearer ')) { - return { error: 'Unauthorized', status: 401 as const } + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7) + const tokenHash = createHash('sha256').update(token).digest('hex') + + const apiToken = await prisma.apiToken.findUnique({ + where: { token_hash: tokenHash }, + include: { user: true }, + }) + + if (!apiToken || apiToken.revoked_at) { + return { error: 'Unauthorized', status: 401 as const } + } + return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo } } - const token = authHeader.slice(7) - const tokenHash = createHash('sha256').update(token).digest('hex') - - const apiToken = await prisma.apiToken.findUnique({ - where: { token_hash: tokenHash }, - include: { user: true }, - }) - - if (!apiToken || apiToken.revoked_at) { - return { error: 'Unauthorized', status: 401 as const } + // Geen Bearer — probeer iron-session cookie + try { + const session = await getIronSession(await cookies(), sessionOptions) + if (session.userId) { + return { userId: session.userId, isDemo: session.isDemo ?? false } + } + } catch { + // cookies() outside of request-scope kan throwen — laat door naar 401 } - return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo } + return { error: 'Unauthorized', status: 401 as const } } diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts new file mode 100644 index 0000000..6f0340f --- /dev/null +++ b/lib/realtime/use-solo-realtime.ts @@ -0,0 +1,153 @@ +// ST-803 + ST-805 (refactor in ST-806-acceptance): client-side hook die de +// Solo Paneel realtime stream beheert. +// +// - Mount in de (app)-layout via SoloRealtimeBridge zodat hij Server Action- +// refreshes overleeft (anders kapt Next.js' soft-navigation de SSE). +// - Opent EventSource('/api/realtime/solo?product_id=...') wanneer +// productId niet null is; sluit de stream als productId null wordt. +// - Reconnect met exponential backoff (1s → 30s, reset bij ready). +// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible. +// - Cleanup op unmount. +// - Connection-status (status, showConnectingIndicator) wordt naar de +// solo-store geschreven; UI-componenten lezen daar uit. +// - Dispatcht events naar de solo-store via handleRealtimeEvent. Task- +// updates worden in document.startViewTransition + flushSync gewikkeld +// zodat het kanban-kaartje soepel naar zijn nieuwe kolom animeert +// (animatie A — vereist view-transition-name op de cards). + +'use client' + +import { useEffect, useRef } from 'react' +import { flushSync } from 'react-dom' +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' + +const BACKOFF_START_MS = 1_000 +const BACKOFF_MAX_MS = 30_000 +const CONNECTING_INDICATOR_DELAY_MS = 4_000 + +export function useSoloRealtime(productId: string | null) { + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + const indicatorTimerRef = useRef | null>(null) + + useEffect(() => { + const setStatus = useSoloStore.getState().setRealtimeStatus + const handleEvent = useSoloStore.getState().handleRealtimeEvent + + if (!productId) { + // Geen actief product (gebruiker zit niet op /solo) — stream uit + setStatus('disconnected', false) + return + } + + const close = () => { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const scheduleIndicator = (next: RealtimeStatus) => { + if (indicatorTimerRef.current) { + clearTimeout(indicatorTimerRef.current) + indicatorTimerRef.current = null + } + if (next === 'open') { + setStatus('open', false) + } else { + // Status meteen bijwerken, indicator pas na 4s — voorkomt flikker + // bij microscopische disconnects. + setStatus(next, false) + indicatorTimerRef.current = setTimeout(() => { + setStatus(useSoloStore.getState().realtimeStatus, true) + }, CONNECTING_INDICATOR_DELAY_MS) + } + } + + const connect = () => { + close() + scheduleIndicator('connecting') + + const source = new EventSource( + `/api/realtime/solo?product_id=${encodeURIComponent(productId)}`, + ) + sourceRef.current = source + + source.addEventListener('ready', () => { + backoffRef.current = BACKOFF_START_MS + scheduleIndicator('open') + }) + + source.onmessage = (e) => { + if (!e.data) return + try { + const payload = JSON.parse(e.data) as RealtimeEvent + // Animatie A: kanban-move animeren via View Transitions API. Voor + // task UPDATE-events wrap'en we de store-update in een view + // transition. flushSync forceert React om synchroon te renderen + // tijdens de transition-callback zodat de nieuwe DOM-state wordt + // gesnapshot voor de animatie. + const animate = + payload.entity === 'task' && + payload.op === 'U' && + typeof document !== 'undefined' && + typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === + 'function' + if (animate) { + ;( + document as Document & { + startViewTransition: (cb: () => void) => unknown + } + ).startViewTransition(() => { + flushSync(() => handleEvent(payload)) + }) + } else { + handleEvent(payload) + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error('[realtime] failed to parse event', err, e.data) + } + } + } + + source.onerror = () => { + if (sourceRef.current !== source) return + close() + scheduleIndicator('disconnected') + + if (document.visibilityState === 'hidden') return + const delay = backoffRef.current + backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) + reconnectTimerRef.current = setTimeout(connect, delay) + } + } + + const onVisibility = () => { + if (document.visibilityState === 'hidden') { + close() + scheduleIndicator('disconnected') + } else if (sourceRef.current === null) { + backoffRef.current = BACKOFF_START_MS + connect() + } + } + + if (document.visibilityState === 'visible') { + connect() + } + document.addEventListener('visibilitychange', onVisibility) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + if (indicatorTimerRef.current) clearTimeout(indicatorTimerRef.current) + close() + } + }, [productId]) +} diff --git a/next.config.ts b/next.config.ts index 82f5cd2..f457fae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next" import pkg from "./package.json" const nextConfig: NextConfig = { + reactStrictMode: true, serverExternalPackages: ['sharp'], env: { NEXT_PUBLIC_APP_VERSION: pkg.version, diff --git a/package.json b/package.json index b9a7923..76df464 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.3.1", "private": true, "scripts": { - "dev": "concurrently \"next dev\" \"npm run db:erd:watch\"", + "predev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true", + "dev": "concurrently \"next dev -p 3000\" \"npm run db:erd:watch\"", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql b/prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql new file mode 100644 index 0000000..db15bcb --- /dev/null +++ b/prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql @@ -0,0 +1,124 @@ +-- ST-801: Postgres LISTEN/NOTIFY infrastructure for realtime Solo Paneel. +-- +-- Adds two row-level AFTER triggers that emit a JSON payload on the +-- `scrum4me_changes` channel for every INSERT/UPDATE/DELETE on tasks and +-- stories. The SSE handler at /api/realtime/solo subscribes to that channel +-- and fans out per-user. +-- +-- Payload shape: +-- { op: 'I'|'U'|'D', +-- entity: 'task'|'story', +-- id: text, +-- story_id?: text, // task only +-- product_id: text, +-- sprint_id: text|null, +-- assignee_id: text|null, +-- changed_fields?: text[] // UPDATE only +-- } +-- +-- Channel name is hardcoded to keep the contract simple. Update both this +-- migration and the SSE handler if it ever changes. + +CREATE OR REPLACE FUNCTION notify_task_change() RETURNS trigger AS $$ +DECLARE + rec record; + story_row record; + payload jsonb; +BEGIN + IF TG_OP = 'DELETE' THEN + rec := OLD; + ELSE + rec := NEW; + END IF; + + SELECT product_id, sprint_id, assignee_id + INTO story_row + FROM stories + WHERE id = rec.story_id; + + IF NOT FOUND THEN + -- Parent story already gone (cascading delete); skip the notify. + RETURN rec; + END IF; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + WHEN 'DELETE' THEN 'D' + END, + 'entity', 'task', + 'id', rec.id, + 'story_id', rec.story_id, + 'product_id', story_row.product_id, + 'sprint_id', story_row.sprint_id, + 'assignee_id', story_row.assignee_id + ); + + IF TG_OP = 'UPDATE' THEN + payload := payload || jsonb_build_object( + 'changed_fields', + COALESCE(( + SELECT jsonb_agg(n.key) + FROM jsonb_each(to_jsonb(NEW)) n + JOIN jsonb_each(to_jsonb(OLD)) o USING (key) + WHERE n.value IS DISTINCT FROM o.value + ), '[]'::jsonb) + ); + END IF; + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN rec; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION notify_story_change() RETURNS trigger AS $$ +DECLARE + rec record; + payload jsonb; +BEGIN + IF TG_OP = 'DELETE' THEN + rec := OLD; + ELSE + rec := NEW; + END IF; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + WHEN 'DELETE' THEN 'D' + END, + 'entity', 'story', + 'id', rec.id, + 'product_id', rec.product_id, + 'sprint_id', rec.sprint_id, + 'assignee_id', rec.assignee_id + ); + + IF TG_OP = 'UPDATE' THEN + payload := payload || jsonb_build_object( + 'changed_fields', + COALESCE(( + SELECT jsonb_agg(n.key) + FROM jsonb_each(to_jsonb(NEW)) n + JOIN jsonb_each(to_jsonb(OLD)) o USING (key) + WHERE n.value IS DISTINCT FROM o.value + ), '[]'::jsonb) + ); + END IF; + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN rec; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS tasks_notify_change ON tasks; +CREATE TRIGGER tasks_notify_change + AFTER INSERT OR UPDATE OR DELETE ON tasks + FOR EACH ROW EXECUTE FUNCTION notify_task_change(); + +DROP TRIGGER IF EXISTS stories_notify_change ON stories; +CREATE TRIGGER stories_notify_change + AFTER INSERT OR UPDATE OR DELETE ON stories + FOR EACH ROW EXECUTE FUNCTION notify_story_change(); diff --git a/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql b/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql new file mode 100644 index 0000000..eb5bb6f --- /dev/null +++ b/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql @@ -0,0 +1,111 @@ +-- ST-804 prereq: extend the realtime trigger payload with the new-state +-- fields the Solo Paneel needs for in-place rendering, so the client doesn't +-- have to refetch on every update. +-- +-- Added fields: +-- task → task_status, task_sort_order, task_title +-- story → story_status, story_sort_order, story_title, story_code +-- +-- Description and implementation_plan stay out of the payload — they can +-- be large and aren't needed for kanban-board rendering. UI fetches them +-- on demand when the detail dialog opens. + +CREATE OR REPLACE FUNCTION notify_task_change() RETURNS trigger AS $$ +DECLARE + rec record; + story_row record; + payload jsonb; +BEGIN + IF TG_OP = 'DELETE' THEN + rec := OLD; + ELSE + rec := NEW; + END IF; + + SELECT product_id, sprint_id, assignee_id + INTO story_row + FROM stories + WHERE id = rec.story_id; + + IF NOT FOUND THEN + RETURN rec; + END IF; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + WHEN 'DELETE' THEN 'D' + END, + 'entity', 'task', + 'id', rec.id, + 'story_id', rec.story_id, + 'product_id', story_row.product_id, + 'sprint_id', story_row.sprint_id, + 'assignee_id', story_row.assignee_id, + 'task_status', rec.status, + 'task_sort_order', rec.sort_order, + 'task_title', rec.title + ); + + IF TG_OP = 'UPDATE' THEN + payload := payload || jsonb_build_object( + 'changed_fields', + COALESCE(( + SELECT jsonb_agg(n.key) + FROM jsonb_each(to_jsonb(NEW)) n + JOIN jsonb_each(to_jsonb(OLD)) o USING (key) + WHERE n.value IS DISTINCT FROM o.value + ), '[]'::jsonb) + ); + END IF; + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN rec; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION notify_story_change() RETURNS trigger AS $$ +DECLARE + rec record; + payload jsonb; +BEGIN + IF TG_OP = 'DELETE' THEN + rec := OLD; + ELSE + rec := NEW; + END IF; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + WHEN 'DELETE' THEN 'D' + END, + 'entity', 'story', + 'id', rec.id, + 'product_id', rec.product_id, + 'sprint_id', rec.sprint_id, + 'assignee_id', rec.assignee_id, + 'story_status', rec.status, + 'story_sort_order', rec.sort_order, + 'story_title', rec.title, + 'story_code', rec.code + ); + + IF TG_OP = 'UPDATE' THEN + payload := payload || jsonb_build_object( + 'changed_fields', + COALESCE(( + SELECT jsonb_agg(n.key) + FROM jsonb_each(to_jsonb(NEW)) n + JOIN jsonb_each(to_jsonb(OLD)) o USING (key) + WHERE n.value IS DISTINCT FROM o.value + ), '[]'::jsonb) + ); + END IF; + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN rec; +END; +$$ LANGUAGE plpgsql; diff --git a/scripts/realtime-mutate.ts b/scripts/realtime-mutate.ts new file mode 100644 index 0000000..5c126c2 --- /dev/null +++ b/scripts/realtime-mutate.ts @@ -0,0 +1,81 @@ +// Test-helper voor M8 acceptatie. Muteert een task of story rechtstreeks +// in de DB om realtime-events te triggeren — alsof MCP of een andere +// schrijver het zou doen. Niet voor productiegebruik. +// +// Gebruik: +// tsx scripts/realtime-mutate.ts move +// tsx scripts/realtime-mutate.ts touch task +// tsx scripts/realtime-mutate.ts touch story +// tsx scripts/realtime-mutate.ts rename story +// tsx scripts/realtime-mutate.ts list-tasks # toont id + status van assigned tasks + +import * as dotenv from 'dotenv' +import * as path from 'path' +import { Pool } from 'pg' + +const root = path.resolve(__dirname, '..') +dotenv.config({ path: path.join(root, '.env.local'), override: true }) +dotenv.config({ path: path.join(root, '.env') }) + +async function main() { + const url = process.env.DATABASE_URL + if (!url) throw new Error('DATABASE_URL is not set') + const pool = new Pool({ connectionString: url }) + + const [, , cmd, ...rest] = process.argv + + try { + if (cmd === 'move') { + const [taskId, status] = rest + if (!taskId || !status) throw new Error('move requires ') + const r = await pool.query( + 'UPDATE tasks SET status = $1::"TaskStatus", updated_at = NOW() WHERE id = $2 RETURNING id, status', + [status, taskId], + ) + console.log('moved:', r.rows[0]) + } else if (cmd === 'touch') { + const [entity, id] = rest + if (entity !== 'task' && entity !== 'story') throw new Error('touch entity must be task or story') + const table = entity === 'task' ? 'tasks' : 'stories' + const r = await pool.query( + `UPDATE ${table} SET updated_at = NOW() WHERE id = $1 RETURNING id`, + [id], + ) + console.log('touched:', r.rows[0]) + } else if (cmd === 'rename') { + const [entity, id, ...titleParts] = rest + const title = titleParts.join(' ') + if (entity !== 'story') throw new Error('rename only supported for story for now') + if (!id || !title) throw new Error('rename requires ') + const r = await pool.query( + 'UPDATE stories SET title = $1, updated_at = NOW() WHERE id = $2 RETURNING id, title', + [title, id], + ) + console.log('renamed:', r.rows[0]) + } else if (cmd === 'list-tasks') { + const r = await pool.query(` + SELECT t.id, t.title, t.status, s.code AS story_code, s.title AS story_title + FROM tasks t + JOIN stories s ON t.story_id = s.id + WHERE s.assignee_id IS NOT NULL + ORDER BY s.sort_order, t.sort_order + LIMIT 20 + `) + console.table(r.rows) + } else { + console.error('Usage:') + console.error(' tsx scripts/realtime-mutate.ts move ') + console.error(' tsx scripts/realtime-mutate.ts touch task|story ') + console.error(' tsx scripts/realtime-mutate.ts rename story ') + console.error(' tsx scripts/realtime-mutate.ts list-tasks') + process.exit(1) + } + } finally { + await pool.end() + } +} + +main().catch((err) => { + console.error(err.message) + process.exit(1) +}) diff --git a/stores/solo-store.ts b/stores/solo-store.ts index fa39f0e..39c3500 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -3,16 +3,63 @@ import type { SoloTask } from '@/components/solo/solo-board' type TaskStatus = SoloTask['status'] +// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801 +// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit +// /api/realtime/solo (ST-802). +export interface RealtimeEvent { + op: 'I' | 'U' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + product_id: string + sprint_id: string | null + assignee_id: string | null + // Task-specifieke velden (alleen aanwezig als entity === 'task') + task_status?: TaskStatus + task_sort_order?: number + task_title?: string + // Story-specifieke velden (alleen aanwezig als entity === 'story') + story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE' + story_sort_order?: number + story_title?: string + story_code?: string | null + // Op UPDATE: lijst van kolommen die zijn veranderd + changed_fields?: string[] +} + +export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' + interface SoloStore { tasks: Record + /** Task-ids die op dit moment een eigen optimistic write in de lucht hebben. + * Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet + * twee keer toegepast wordt of door een latere echo overschreven. */ + pendingOps: Set + + /** Realtime-connection state, beheerd door useSoloRealtime in de + * (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en + * de hook niet direct in dezelfde subtree draait. */ + realtimeStatus: RealtimeStatus + showConnectingIndicator: boolean + initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void updatePlan: (taskId: string, plan: string | null) => void + + markPending: (taskId: string) => void + clearPending: (taskId: string) => void + + setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void + + handleRealtimeEvent: (event: RealtimeEvent) => void } export const useSoloStore = create((set, get) => ({ tasks: {}, + pendingOps: new Set(), + realtimeStatus: 'connecting', + showConnectingIndicator: false, initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -29,4 +76,116 @@ export const useSoloStore = create((set, get) => ({ updatePlan: (taskId, plan) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })), + + markPending: (taskId) => + set((s) => { + if (s.pendingOps.has(taskId)) return s + const next = new Set(s.pendingOps) + next.add(taskId) + return { pendingOps: next } + }), + + clearPending: (taskId) => + set((s) => { + if (!s.pendingOps.has(taskId)) return s + const next = new Set(s.pendingOps) + next.delete(taskId) + return { pendingOps: next } + }), + + setRealtimeStatus: (status, showConnectingIndicator) => + set((s) => { + if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { + return s + } + return { realtimeStatus: status, showConnectingIndicator } + }), + + handleRealtimeEvent: (event) => { + if (event.entity === 'task') { + const { id, op } = event + + if (op === 'D') { + set((s) => { + if (!(id in s.tasks)) return s + const next = { ...s.tasks } + delete next[id] + return { tasks: next } + }) + return + } + + // INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken + // zonder story-context (story_title, story_code) renderen we niet + // — gebruiker ziet ze pas na een refresh. Acceptabel voor v1. + const existing = get().tasks[id] + if (!existing) return + + if (get().pendingOps.has(id)) { + // Echo van een eigen optimistic move — laat de optimistic-state staan + return + } + + const updates: Partial = {} + if (event.task_status !== undefined && event.task_status !== existing.status) { + updates.status = event.task_status + } + if ( + event.task_sort_order !== undefined && + event.task_sort_order !== existing.sort_order + ) { + updates.sort_order = event.task_sort_order + } + if (event.task_title !== undefined && event.task_title !== existing.title) { + updates.title = event.task_title + } + + if (Object.keys(updates).length === 0) return + set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } })) + return + } + + if (event.entity === 'story') { + const { id, op } = event + + if (op === 'D') { + // Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende + // SoloTask-records uit de store. + set((s) => { + const next: Record = {} + for (const [taskId, task] of Object.entries(s.tasks)) { + if (task.story_id !== id) next[taskId] = task + } + return { tasks: next } + }) + return + } + + const tasks = get().tasks + const affectedIds = Object.entries(tasks) + .filter(([, t]) => t.story_id === id) + .map(([taskId]) => taskId) + + if (affectedIds.length === 0) return + + const newTitle = event.story_title + const newCode = event.story_code ?? null + + set((s) => { + const next = { ...s.tasks } + for (const taskId of affectedIds) { + const t = next[taskId] + const titleChanged = newTitle !== undefined && t.story_title !== newTitle + const codeChanged = newCode !== t.story_code + if (!titleChanged && !codeChanged) continue + next[taskId] = { + ...t, + ...(titleChanged && newTitle !== undefined && { story_title: newTitle }), + ...(codeChanged && { story_code: newCode }), + } + } + return { tasks: next } + }) + } + }, }))