// AK-P3 (AD-28/S5) — shared SSE subscription for in-app notifications. // Hosts (routes/NavBar) own this hook and inject the resulting items into the // presentational screens as props; screens themselves never subscribe (DoD A.5). // Stream records are mapped through the same pure VM mapper as loader data so // live updates and initial payloads share one shape (deeplink, bounce keys). import { useEffect, useRef, useState } from "react"; import { mergeNotificationCenterItems, toNotificationCenterItemVM, NOTIFICATION_CENTER_MAX_ITEMS, type NotificationCenterItemVM, } from "@hivegrid/view-models"; const RECONNECT_DELAY_MS = 1_000; const MAX_RECONNECT_DELAY_MS = 30_000; /** * A connection only counts as healthy once it survived this long. Resetting the * backoff on every `open`/message meant a stream that dropped right after the * snapshot reconnected once per second forever instead of backing off. */ const STABLE_CONNECTION_MS = 15_000; type StreamPayload = { cursor?: unknown; id?: unknown; items?: unknown; deliveries?: unknown; item?: unknown; delivery?: unknown; notification?: unknown; }; function serializeDateField(value: unknown): string | null { if (value instanceof Date) return value.toISOString(); return typeof value === "string" && value.trim().length > 0 ? value : null; } function normalizeStreamItem(value: unknown): NotificationCenterItemVM | null { if (!value || typeof value !== "object") return null; const record = value as Record; if (typeof record.id !== "string" || record.id.trim().length === 0) return null; return toNotificationCenterItemVM({ id: record.id, messageKey: typeof record.messageKey === "string" ? record.messageKey : "notification.delivery", category: typeof record.category === "string" ? record.category : "notification", channel: typeof record.channel === "string" ? record.channel : "IN_APP", status: typeof record.status === "string" ? record.status : "PENDING", attemptsMade: typeof record.attemptsMade === "number" && Number.isFinite(record.attemptsMade) ? record.attemptsMade : 0, createdAtIso: serializeDateField(record.createdAt), updatedAtIso: serializeDateField(record.updatedAt), sentAtIso: serializeDateField(record.sentAt), readAtIso: serializeDateField(record.readAt), lastError: typeof record.lastError === "string" ? record.lastError : null, payloadMetadata: typeof record.payloadMetadata === "object" ? record.payloadMetadata : undefined, deeplink: typeof record.deeplink === "string" ? record.deeplink : null, }); } function getPayloadItems(payload: StreamPayload): NotificationCenterItemVM[] { const snapshotItems = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.deliveries) ? payload.deliveries : null; if (snapshotItems) return snapshotItems.map(normalizeStreamItem).filter((item): item is NotificationCenterItemVM => Boolean(item)); const singleItem = normalizeStreamItem(payload.item ?? payload.delivery ?? payload.notification ?? payload); return singleItem ? [singleItem] : []; } function getEventCursor(event: MessageEvent, payload: StreamPayload): string | null { if (typeof payload.cursor === "string" && payload.cursor.trim().length > 0) return payload.cursor; if (typeof payload.id === "string" && payload.id.trim().length > 0) return payload.id; return event.lastEventId?.trim() ? event.lastEventId : null; } function parseStreamEvent(event: MessageEvent): { cursor: string | null; items: NotificationCenterItemVM[] } | null { try { const payload = JSON.parse(event.data || "{}"); if (!payload || typeof payload !== "object") return null; return { cursor: getEventCursor(event, payload as StreamPayload), items: getPayloadItems(payload as StreamPayload) }; } catch { return null; } } function buildReconnectUrl(streamUrl: string, cursor: string | null) { if (!cursor) return streamUrl; const url = new URL(streamUrl, window.location.origin); url.searchParams.set("afterCursor", cursor); return url.toString(); } export type NotificationsStreamState = { items: NotificationCenterItemVM[]; unreadCount: number; connectionStatusKey: string; }; export function useNotificationsStream( streamUrl: string | null | undefined, initialItems: readonly NotificationCenterItemVM[] ): NotificationsStreamState { const [items, setItems] = useState(() => initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS)); const [connectionStatusKey, setConnectionStatusKey] = useState( streamUrl ? "notifications.center.realtime.connecting" : "notifications.center.realtime.unavailable" ); const cursorRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectDelayRef = useRef(RECONNECT_DELAY_MS); const connectedAtRef = useRef(null); useEffect(() => { // Revalidated loader data merges over the live state (never truncates it). // merge(current, incoming): incoming wins per id — the fresh loader data // must be the incoming side, otherwise stale live state shadows it. setItems((currentItems) => mergeNotificationCenterItems(currentItems, initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS))); }, [initialItems]); useEffect(() => { if (!streamUrl || typeof window === "undefined" || typeof window.EventSource === "undefined") return; const activeStreamUrl = streamUrl; let closed = false; let source: EventSource | null = null; function clearReconnectTimer() { if (reconnectTimerRef.current) { window.clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } } function connect() { clearReconnectTimer(); connectedAtRef.current = null; source = new EventSource(buildReconnectUrl(activeStreamUrl, cursorRef.current)); setConnectionStatusKey("notifications.center.realtime.connecting"); function handleMessage(event: MessageEvent) { const parsed = parseStreamEvent(event); if (!parsed) return; if (parsed.cursor && parsed.items.length > 0) cursorRef.current = parsed.cursor; setItems((currentItems) => mergeNotificationCenterItems(currentItems, parsed.items)); setConnectionStatusKey("notifications.center.realtime.connected"); } function handleOpen() { connectedAtRef.current = Date.now(); setConnectionStatusKey("notifications.center.realtime.connected"); } function handleError() { if (closed) return; source?.close(); setConnectionStatusKey("notifications.center.realtime.reconnecting"); // Only a connection that actually held counts as healthy. Otherwise a // stream that dies right after opening keeps resetting the backoff and // reconnects once per second forever. const openedAt = connectedAtRef.current; if (openedAt !== null && Date.now() - openedAt >= STABLE_CONNECTION_MS) { reconnectDelayRef.current = RECONNECT_DELAY_MS; } connectedAtRef.current = null; const reconnectDelay = reconnectDelayRef.current; reconnectDelayRef.current = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS); reconnectTimerRef.current = window.setTimeout(connect, reconnectDelay); } source.addEventListener("open", handleOpen); source.addEventListener("notification.snapshot", handleMessage); source.addEventListener("notification.update", handleMessage); source.addEventListener("error", handleError); } connect(); return () => { closed = true; clearReconnectTimer(); source?.close(); }; }, [streamUrl]); return { items, unreadCount: items.filter((item) => !item.isRead).length, connectionStatusKey, }; }