// 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' 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) 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, }) 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) } 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) } }) } 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() } }, []) }