// ST-1105: Client hook die de notificatie-SSE stream beheert (M11). // // Mount via in (app)/layout zodat hij Server Action- // refreshes overleeft. Opent EventSource('/api/realtime/notifications'), // dispatcht state-/message-events naar de notifications-store. // // Vereenvoudigde versie van useSoloRealtime — geen view-transitions, geen // connecting-indicator-debounce. Wel: reconnect met exponential backoff en // pause bij hidden tab. 'use client' import { useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' import { useIdeaStore } from '@/stores/idea-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 // Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive // (DB-check-constraint). Voor story-questions blijft het pad onveranderd; // idea-questions worden naar de idea-store doorgezet. interface QuestionPayload { op: 'I' | 'U' entity: 'question' id: string product_id: string story_id: string | null task_id: string | null idea_id?: string | null assignee_id: string | null status: 'open' | 'answered' | 'cancelled' | 'expired' } // Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify. interface IdeaJobPayload { type: 'claude_job_enqueued' | 'claude_job_status' job_id: string idea_id: string user_id: string product_id?: string | null kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' status: string error?: string } type AnyPayload = QuestionPayload | IdeaJobPayload function isQuestionPayload(p: AnyPayload): p is QuestionPayload { return 'entity' in p && p.entity === 'question' } function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload { return ( 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') ) } interface StateEvent { questions: NotificationQuestion[] } export function useNotificationsRealtime() { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const router = useRouter() useEffect(() => { const init = useNotificationsStore.getState().init const remove = useNotificationsStore.getState().remove const close = () => { if (sourceRef.current) { sourceRef.current.close() sourceRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null } } const connect = () => { close() const source = new EventSource('/api/realtime/notifications', { withCredentials: true, }) sourceRef.current = source source.addEventListener('open', () => { backoffRef.current = BACKOFF_START_MS }) source.addEventListener('state', (ev) => { try { const data = JSON.parse((ev as MessageEvent).data) as StateEvent init(data.questions ?? []) } catch { // ignore malformed } }) source.addEventListener('message', (ev) => { try { const payload = JSON.parse(ev.data) as AnyPayload // M12 — idea-job events naar idea-store dispatchen. if (isIdeaJobPayload(payload)) { useIdeaStore.getState().handleIdeaJobEvent({ type: payload.type, job_id: payload.job_id, idea_id: payload.idea_id, user_id: payload.user_id, product_id: payload.product_id ?? null, kind: payload.kind, // The store-types narrow this; cast is safe because the server // emits valid statuses. status: payload.status as 'queued', error: payload.error, }) // Refresh zodra job klaar is — server heeft nu grill_md/plan_md // geschreven en de idea-status bijgewerkt. router.refresh() triggert // een server-component re-fetch zodat de Timeline de nieuwe // GRILL_RESULT/PLAN_RESULT logs en de bijgewerkte status oppikt. if (payload.status === 'done' || payload.status === 'failed') { router.refresh() } return } if (!isQuestionPayload(payload)) return // M12 — idea-question events naar idea-store dispatchen. if (payload.idea_id) { useIdeaStore.getState().handleIdeaQuestionEvent({ op: payload.op, entity: 'question', id: payload.id, product_id: payload.product_id, story_id: null, idea_id: payload.idea_id, task_id: payload.task_id, assignee_id: payload.assignee_id, status: payload.status, }) // M12 hotfix: óók in notifications-bel. Open → reconnect zodat // initial-state de full question-detail levert; non-open → remove. if (payload.status === 'open') { close() connect() } else { remove(payload.id) } // M12 hotfix: refresh de current page (server-component) zodat de // IdeaTimeline-tab op /ideas/[id] de nieuwe vraag oppikt zonder // dat de gebruiker handmatig moet refreshen. Geen-op als de // gebruiker elders zit; goedkoop genoeg om altijd te triggeren. router.refresh() return } // Story-questions: bestaande bell-pad onveranderd. if (payload.status === 'open') { // Inkomende open vraag: we hebben de details nog niet — beste optie is // herfetchen door opnieuw te verbinden, of via een API. Voor v1 // forceren we een reconnect zodat het volgende state-event de // volledige details meelevert. close() connect() return } // Niet-open status (answered/cancelled/expired) → verwijderen uit lijst remove(payload.id) } catch { // ignore malformed } }) source.addEventListener('error', () => { // EventSource herconnect zelf bij netwerkfouten; voor server-close // (after 240s) doen we een eigen backoff if (sourceRef.current?.readyState === EventSource.CLOSED) { const delay = backoffRef.current backoffRef.current = Math.min(delay * 2, BACKOFF_MAX_MS) reconnectTimerRef.current = setTimeout(connect, delay) } }) } // PBI-74: stream blijft open op hidden. Reconnect alleen als hij door // netwerkfout/server-close weg is. Bij visible-overgang en bij online // triggeren we router.refresh() zodat de notifications-bel verse state // pakt — gemiste vraag-events via NOTIFY-throttling worden hierdoor // alsnog zichtbaar. const onVisibilityChange = () => { if (document.visibilityState !== 'visible') return if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { connect() } router.refresh() } const onOnline = () => { router.refresh() } connect() document.addEventListener('visibilitychange', onVisibilityChange) window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) window.removeEventListener('online', onOnline) close() } }, [router]) }