diff --git a/app/api/debug/emit-test-notify/route.ts b/app/api/debug/emit-test-notify/route.ts deleted file mode 100644 index e480258..0000000 --- a/app/api/debug/emit-test-notify/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -// TIJDELIJKE debug-endpoint. Stuurt een handmatige pg_notify op -// `scrum4me_changes` zonder een echte UPDATE te doen. Bedoeld om de -// SSE-pipe te testen los van Prisma/triggers. -// -// VERWIJDEREN voor M8 out-of-draft. - -import { Client } from 'pg' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -const CHANNEL = 'scrum4me_changes' - -export async function POST(request: Request) { - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) - } - - let body: unknown = null - try { - body = await request.json() - } catch { - // empty body is OK — we vullen defaults in - } - - const overrides = (body && typeof body === 'object' ? body : {}) as Record - const payload = { - op: 'U', - entity: 'task', - id: `debug-${Date.now()}`, - story_id: 'debug-story', - product_id: 'debug-product', - sprint_id: null, - assignee_id: null, - task_status: 'TO_DO', - task_sort_order: 1, - task_title: 'manual debug emit', - changed_fields: ['status'], - debug: true, - emitted_at: new Date().toISOString(), - ...overrides, - } - - const client = new Client({ connectionString: directUrl }) - try { - await client.connect() - // pg_notify met JSON-string als payload — zelfde formaat als de trigger - await client.query('SELECT pg_notify($1, $2)', [CHANNEL, JSON.stringify(payload)]) - return Response.json({ ok: true, payload }) - } catch (err) { - return Response.json( - { ok: false, error: err instanceof Error ? err.message : String(err) }, - { status: 500 }, - ) - } finally { - try { await client.end() } catch {} - } -} diff --git a/app/api/debug/realtime-stream/route.ts b/app/api/debug/realtime-stream/route.ts deleted file mode 100644 index e909bfc..0000000 --- a/app/api/debug/realtime-stream/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -// TIJDELIJKE debug-endpoint voor M8-acceptance. -// Geen auth, geen filtering — alle pg_notify-events op `scrum4me_changes` -// stromen rauw door naar de browser. Bedoeld om te isoleren of de -// SSE + LISTEN-pipe op Vercel werkt, los van iron-session, productfilter -// of solo-store. -// -// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' - -export async function GET(request: NextRequest) { - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) - } - - const isPooled = directUrl.includes('pooler.') - const hostHint = directUrl.match(/@([^/]+)/)?.[1] ?? 'unknown-host' - console.log(`[debug-realtime] connecting (${isPooled ? 'POOLED' : 'direct'}) host=${hostHint}`) - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - let closed = false - let heartbeatTimer: ReturnType | null = null - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - try { - await pgClient.end() - } catch { - // ignore - } - try { - controller.close() - } catch { - // already closed - } - console.log(`[debug-realtime] closed: ${reason}`) - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - console.log('[debug-realtime] LISTEN ready') - } catch (err) { - console.error('[debug-realtime] pg connect/listen failed:', err) - enqueue(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - console.log(`[debug-realtime] RAW notification length=${msg.payload?.length ?? 0}`) - if (!msg.payload) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[debug-realtime] pg client error:', err) - await cleanup('pg error') - }) - - pgClient.on('end', () => { - console.log('[debug-realtime] pg client end') - }) - - enqueue( - `event: ready\ndata: ${JSON.stringify({ - host: hostHint, - pooled: isPooled, - channel: CHANNEL, - time: new Date().toISOString(), - })}\n\n`, - ) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat ${new Date().toISOString()}\n\n`) - }, 25_000) - - 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 22de74b..06127ff 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -109,26 +109,18 @@ export async function GET(request: NextRequest) { } catch { // already closed } - // Tijdelijk altijd loggen (geen NODE_ENV-guard) zodat we Vercel - // function logs kunnen zien tijdens M8 acceptance. Strippen na - // ST-806. - console.log(`[realtime/solo] closed: ${reason}`) + 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 - const isPooled = directUrl.includes('pooler.') - const hostHint = directUrl.match(/@([^/]+)/)?.[1] ?? 'unknown-host' - console.log( - `[realtime/solo] connecting (${isPooled ? 'POOLED — LISTEN may not work!' : 'direct'}) host=${hostHint} sprint=${activeSprintId}`, - ) - try { await pgClient.connect() await pgClient.query(`LISTEN ${CHANNEL}`) - console.log(`[realtime/solo] LISTEN ${CHANNEL} ready`) } catch (err) { console.error('[realtime/solo] pg connect/listen failed:', err) enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) @@ -137,7 +129,6 @@ export async function GET(request: NextRequest) { } pgClient.on('notification', (msg) => { - console.log(`[realtime/solo] RAW notification on channel=${msg.channel} length=${msg.payload?.length ?? 0}`) if (!msg.payload) return let payload: NotifyPayload try { @@ -145,11 +136,7 @@ export async function GET(request: NextRequest) { } catch { return } - const emit = shouldEmit(payload, productId, activeSprintId, userId) - console.log( - `[realtime/solo] NOTIFY ${payload.entity}:${payload.id} ${payload.op} → ${emit ? 'EMIT' : 'skip'} (sprint=${payload.sprint_id} assignee=${payload.assignee_id} user=${userId})`, - ) - if (!emit) return + if (!shouldEmit(payload, productId, activeSprintId, userId)) return enqueue(`data: ${msg.payload}\n\n`) }) @@ -158,10 +145,6 @@ export async function GET(request: NextRequest) { await cleanup('pg error') }) - pgClient.on('end', () => { - console.log('[realtime/solo] pg client end (connection closed)') - }) - // Stuur eerst een "ready"-event zodat de client weet dat de connectie staat enqueue( `event: ready\ndata: ${JSON.stringify({ diff --git a/app/debug-realtime/client.tsx b/app/debug-realtime/client.tsx deleted file mode 100644 index 67ce1ef..0000000 --- a/app/debug-realtime/client.tsx +++ /dev/null @@ -1,490 +0,0 @@ -'use client' - -import { useEffect, useMemo, useRef, useState } from 'react' -import { flushSync } from 'react-dom' -import { useDebugStore, type DebugRealtimeEvent } from './debug-store' -import { StorePanel } from './store-panel' - -type RowType = 'ready' | 'message' | 'error' | 'open' | 'close' - -interface Row { - receivedAt: number - type: RowType - raw: string - parsed?: Record -} - -interface Stats { - reconnects: number - totalEvents: number - firstEventAt: number | null - lastEventAt: number | null - largestGapMs: number - emitInFlight: boolean - lastEmitResult: string | null -} - -const MAX_ROWS = 500 -const HEARTBEAT_WARN_MS = 30_000 - -export function DebugRealtimeClient() { - const [rows, setRows] = useState([]) - const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting') - const [stats, setStats] = useState({ - reconnects: 0, - totalEvents: 0, - firstEventAt: null, - lastEventAt: null, - largestGapMs: 0, - emitInFlight: false, - lastEmitResult: null, - }) - const [filterType, setFilterType] = useState<'all' | RowType>('all') - const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all') - const [tickNow, setTickNow] = useState(() => Date.now()) - - // Layer-toggles — elke combinatie isoleert een potentiële bron van bugs. - // - dispatchToStore: voert applyEvent uit op een mini Zustand-store - // - useFlushSync: forceert React om synchroon te renderen tijdens dispatch - // - useViewTransition: wrap dispatch in document.startViewTransition - const [dispatchToStore, setDispatchToStore] = useState(true) - const [useFlushSync, setUseFlushSync] = useState(false) - const [useViewTransition, setUseViewTransition] = useState(false) - const dispatchToStoreRef = useRef(dispatchToStore) - const useFlushSyncRef = useRef(useFlushSync) - const useViewTransitionRef = useRef(useViewTransition) - useEffect(() => { dispatchToStoreRef.current = dispatchToStore }, [dispatchToStore]) - useEffect(() => { useFlushSyncRef.current = useFlushSync }, [useFlushSync]) - useEffect(() => { useViewTransitionRef.current = useViewTransition }, [useViewTransition]) - - const sourceRef = useRef(null) - const lastEventTimeRef = useRef(null) - - // Tick every second om "since last event"-counter levend te houden - useEffect(() => { - const t = setInterval(() => setTickNow(Date.now()), 1000) - return () => clearInterval(t) - }, []) - - useEffect(() => { - const append = (row: Row) => { - setRows((prev) => [row, ...prev].slice(0, MAX_ROWS)) - lastEventTimeRef.current = row.receivedAt - setStats((s) => { - const prevLast = s.lastEventAt - const gap = prevLast ? row.receivedAt - prevLast : 0 - return { - ...s, - totalEvents: s.totalEvents + 1, - firstEventAt: s.firstEventAt ?? row.receivedAt, - lastEventAt: row.receivedAt, - largestGapMs: Math.max(s.largestGapMs, gap), - } - }) - } - - const open = () => { - const source = new EventSource('/api/debug/realtime-stream') - sourceRef.current = source - - append({ receivedAt: Date.now(), type: 'open', raw: '(EventSource opening)' }) - setStats((s) => ({ ...s, reconnects: s.reconnects + 1 })) - setStatus('connecting') - - source.addEventListener('ready', (e) => { - setStatus('open') - const data = (e as MessageEvent).data ?? '' - const parsed = safeParse(data) - append({ receivedAt: Date.now(), type: 'ready', raw: data, parsed }) - }) - - source.addEventListener('error', (e) => { - setStatus('error') - const data = (e as MessageEvent).data ?? '(no data)' - append({ receivedAt: Date.now(), type: 'error', raw: data }) - }) - - source.onmessage = (e) => { - const parsed = safeParse(e.data ?? '') - append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed }) - - // Dispatch naar de mini Zustand-store, optioneel met flushSync - // en/of startViewTransition om de echte solo-flow te mimeken. - if (dispatchToStoreRef.current && parsed) { - const dispatch = () => - useDebugStore.getState().applyEvent(parsed as DebugRealtimeEvent) - const wrapWithFlush = useFlushSyncRef.current - ? () => flushSync(dispatch) - : dispatch - if ( - useViewTransitionRef.current && - typeof document !== 'undefined' && - typeof (document as Document & { startViewTransition?: unknown }) - .startViewTransition === 'function' - ) { - ;( - document as Document & { - startViewTransition: (cb: () => void) => unknown - } - ).startViewTransition(wrapWithFlush) - } else { - wrapWithFlush() - } - } - } - - source.onerror = () => { - setStatus('error') - append({ receivedAt: Date.now(), type: 'close', raw: '(EventSource error/close)' }) - } - } - - open() - - return () => { - sourceRef.current?.close() - sourceRef.current = null - } - }, []) - - async function emitTestEvent() { - setStats((s) => ({ ...s, emitInFlight: true, lastEmitResult: null })) - try { - const res = await fetch('/api/debug/emit-test-notify', { method: 'POST' }) - const json = (await res.json()) as { ok?: boolean; error?: string } - setStats((s) => ({ - ...s, - emitInFlight: false, - lastEmitResult: json.ok ? 'sent ✓' : `failed: ${json.error ?? 'unknown'}`, - })) - } catch (err) { - setStats((s) => ({ - ...s, - emitInFlight: false, - lastEmitResult: `failed: ${err instanceof Error ? err.message : String(err)}`, - })) - } - } - - function reset() { - setRows([]) - setStats({ - reconnects: 0, - totalEvents: 0, - firstEventAt: null, - lastEventAt: null, - largestGapMs: 0, - emitInFlight: false, - lastEmitResult: null, - }) - lastEventTimeRef.current = null - } - - const filteredRows = useMemo(() => { - return rows.filter((row) => { - if (filterType !== 'all' && row.type !== filterType) return false - if (filterEntity !== 'all') { - const entity = (row.parsed?.entity as string | undefined) ?? null - if (filterEntity === 'debug') { - if (!row.parsed?.debug) return false - } else { - if (entity !== filterEntity) return false - } - } - return true - }) - }, [rows, filterType, filterEntity]) - - const sinceLastEvent = lastEventTimeRef.current - ? tickNow - lastEventTimeRef.current - : null - - return ( -
- - - {dispatchToStore && } - - - - - - - - - - - {filteredRows.length === 0 ? ( - - - - ) : ( - filteredRows.map((row, idx) => ( - - - - - - - )) - )} - -
received_attypeentity / oppayload
- Wachten op events… trigger een mutatie via UI / script of klik "emit test event". -
- {new Date(row.receivedAt).toISOString()} - - {row.type} - - {row.parsed?.entity ? ( - <> - {row.parsed.entity as string} - {row.parsed.op ? ` / ${row.parsed.op as string}` : ''} - {row.parsed.debug ? ' [debug]' : ''} - - ) : ( - '—' - )} - - {row.raw} -
-
- ) -} - -function ControlBar({ - status, - stats, - sinceLastEvent, - filterType, - setFilterType, - filterEntity, - setFilterEntity, - onEmit, - onReset, -}: { - status: 'connecting' | 'open' | 'closed' | 'error' - stats: Stats - sinceLastEvent: number | null - filterType: 'all' | RowType - setFilterType: (t: 'all' | RowType) => void - filterEntity: 'all' | 'task' | 'story' | 'debug' - setFilterEntity: (e: 'all' | 'task' | 'story' | 'debug') => void - onEmit: () => void - onReset: () => void -}) { - const heartbeatStale = sinceLastEvent !== null && sinceLastEvent > HEARTBEAT_WARN_MS - return ( -
-
- - - - - 0 - ? `${(stats.largestGapMs / 1000).toFixed(1)}s` - : '—' - } - /> - -
-
- - {stats.lastEmitResult && ( - {stats.lastEmitResult} - )} - - filter: - - -
-
- ) -} - -function LayerToggles({ - dispatchToStore, - setDispatchToStore, - useFlushSync, - setUseFlushSync, - useViewTransition, - setUseViewTransition, -}: { - dispatchToStore: boolean - setDispatchToStore: (v: boolean) => void - useFlushSync: boolean - setUseFlushSync: (v: boolean) => void - useViewTransition: boolean - setUseViewTransition: (v: boolean) => void -}) { - return ( -
- Layers: - - - -
- ) -} - -function Stat({ label, value, color }: { label: string; value: string; color?: string }) { - return ( -
- {label} - {value} -
- ) -} - -function statusColor(status: 'connecting' | 'open' | 'closed' | 'error') { - switch (status) { - case 'open': - return 'green' - case 'error': - return 'red' - case 'closed': - return 'gray' - default: - return 'orange' - } -} - -function typeColor(type: RowType) { - switch (type) { - case 'message': - return '#0070cc' - case 'ready': - return 'green' - case 'error': - return 'red' - case 'open': - return '#888' - case 'close': - return '#888' - } -} - -function safeParse(raw: string): Record | undefined { - try { - return JSON.parse(raw) as Record - } catch { - return undefined - } -} diff --git a/app/debug-realtime/debug-store.ts b/app/debug-realtime/debug-store.ts deleted file mode 100644 index 32e3f65..0000000 --- a/app/debug-realtime/debug-store.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Mini Zustand-store voor de debug-pagina. Mirrort het patroon van -// stores/solo-store.ts maar dan minimaal: alleen tasks-record + applyEvent. -// Doel: testen of de SSE-event → store → component-render keten werkt -// zonder de complexiteit van de echte Solo Paneel. - -import { create } from 'zustand' - -export interface DebugTask { - id: string - status: string - title: string - story_id: string - updated_at: string -} - -export interface DebugRealtimeEvent { - op: 'I' | 'U' | 'D' - entity: 'task' | 'story' - id: string - story_id?: string - task_status?: string - task_title?: string - debug?: boolean - emitted_at?: string - [key: string]: unknown -} - -interface DebugStore { - tasks: Record - applyCount: number - skipCount: number - applyEvent: (event: DebugRealtimeEvent) => void - reset: () => void -} - -export const useDebugStore = create((set, get) => ({ - tasks: {}, - applyCount: 0, - skipCount: 0, - - applyEvent: (event) => { - if (event.entity !== 'task') { - set((s) => ({ skipCount: s.skipCount + 1 })) - return - } - if (event.op === 'D') { - set((s) => { - const next = { ...s.tasks } - delete next[event.id] - return { tasks: next, applyCount: s.applyCount + 1 } - }) - return - } - // INSERT/UPDATE — schrijf altijd, ongeacht of de task al bestond - set((s) => ({ - tasks: { - ...s.tasks, - [event.id]: { - id: event.id, - status: event.task_status ?? get().tasks[event.id]?.status ?? '?', - title: event.task_title ?? get().tasks[event.id]?.title ?? '(no title)', - story_id: event.story_id ?? get().tasks[event.id]?.story_id ?? '?', - updated_at: new Date().toISOString(), - }, - }, - applyCount: s.applyCount + 1, - })) - }, - - reset: () => set({ tasks: {}, applyCount: 0, skipCount: 0 }), -})) diff --git a/app/debug-realtime/page.tsx b/app/debug-realtime/page.tsx deleted file mode 100644 index 4dc28f3..0000000 --- a/app/debug-realtime/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -// TIJDELIJKE debug-pagina voor M8-acceptance. -// Geen auth, geen styling — toont alle inkomende pg_notify-events op -// `scrum4me_changes` in een tabel zodat we kunnen zien of de SSE + LISTEN- -// pipe überhaupt events doorstroomt op Vercel. -// -// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. - -import { DebugRealtimeClient } from './client' - -export const dynamic = 'force-dynamic' - -export default function DebugRealtimePage() { - return ( -
-

Realtime debug — scrum4me_changes

-

- Live SSE-stream rechtstreeks van Postgres LISTEN op channel{' '} - scrum4me_changes. Geen auth, geen filtering. Verwijderen na M8 acceptance. -

- -
- ) -} diff --git a/app/debug-realtime/store-panel.tsx b/app/debug-realtime/store-panel.tsx deleted file mode 100644 index 35495ed..0000000 --- a/app/debug-realtime/store-panel.tsx +++ /dev/null @@ -1,111 +0,0 @@ -'use client' - -import { useDebugStore } from './debug-store' - -export function StorePanel() { - const tasks = useDebugStore((s) => s.tasks) - const applyCount = useDebugStore((s) => s.applyCount) - const skipCount = useDebugStore((s) => s.skipCount) - const reset = useDebugStore((s) => s.reset) - - const taskList = Object.values(tasks) - - return ( -
-
- Store layer: - - applyCount: {applyCount} - - - skipCount: {skipCount} - - - taskCount: {taskList.length} - - -
- - - - - - - - - - - - {taskList.length === 0 ? ( - - - - ) : ( - taskList.map((t) => ( - - - - - - - )) - )} - -
idstatustitleupdated_at (lokaal)
- Store is leeg. Trigger een event en kijk of de tabel hier vult. -
- {t.id} - - {t.status} - {t.title} - {t.updated_at} -
-
- ) -} - -function statusColor(status: string) { - switch (status) { - case 'TO_DO': - return '#888' - case 'IN_PROGRESS': - return '#0070cc' - case 'REVIEW': - return '#cc7a00' - case 'DONE': - return 'green' - default: - return 'inherit' - } -} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 4589922..934f984 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -17,8 +17,8 @@ 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 >2s niet open is (animatie B -// zit in useSoloRealtime). Default groen tijdens de eerste 2s zodat micro- +// 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, 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/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index a99cd68..6f0340f 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -24,7 +24,7 @@ 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 = 2_000 +const CONNECTING_INDICATOR_DELAY_MS = 4_000 export function useSoloRealtime(productId: string | null) { const sourceRef = useRef(null) @@ -61,7 +61,7 @@ export function useSoloRealtime(productId: string | null) { if (next === 'open') { setStatus('open', false) } else { - // Status meteen bijwerken, indicator pas na 2s — voorkomt flikker + // Status meteen bijwerken, indicator pas na 4s — voorkomt flikker // bij microscopische disconnects. setStatus(next, false) indicatorTimerRef.current = setTimeout(() => { diff --git a/next.config.ts b/next.config.ts index d5922a5..f457fae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,11 +2,7 @@ import type { NextConfig } from "next" import pkg from "./package.json" const nextConfig: NextConfig = { - // Strict Mode dubbel-mount maakt langlopende connecties (zoals de SSE- - // stream uit M8) tijdens dev moeilijk te observeren. Productie kent dit - // gedrag niet. Tijdelijk uit voor lokale acceptatie van M8 — overweeg om - // weer aan te zetten zodra we andere effects-bugs gerichter kunnen vangen. - reactStrictMode: false, + reactStrictMode: true, serverExternalPackages: ['sharp'], env: { NEXT_PUBLIC_APP_VERSION: pkg.version,