// 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 { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 interface NotifyPayload { op: 'I' | 'U' entity: 'question' id: string product_id: string story_id: string task_id: string | null assignee_id: string | null status: 'open' | 'answered' | 'cancelled' | 'expired' } interface StateEvent { questions: NotificationQuestion[] } export function useNotificationsRealtime() { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) 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 NotifyPayload if (payload.entity !== 'question') return // Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst // mee in de payload, dus we doen een mini-fetch via de same SSE's // initial-state on reconnect; hier voor MVP alleen status-handling). 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) } }) } const onVisibilityChange = () => { if (document.visibilityState === 'visible') { if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { connect() } } else { close() } } connect() document.addEventListener('visibilitychange', onVisibilityChange) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) close() } }, []) }