Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/notifications/useNotificationsStream.ts
T
2026-07-08 16:15:37 +02:00

160 lines
6.7 KiB
TypeScript

// 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;
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<string, unknown>;
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,
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<NotificationCenterItemVM[]>(() => initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS));
const [connectionStatusKey, setConnectionStatusKey] = useState(
streamUrl ? "notifications.center.realtime.connecting" : "notifications.center.realtime.unavailable"
);
const cursorRef = useRef<string | null>(null);
const reconnectTimerRef = useRef<number | null>(null);
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
useEffect(() => {
// Revalidated loader data merges over the live state (never truncates it).
setItems((currentItems) => mergeNotificationCenterItems(initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS), currentItems));
}, [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();
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));
reconnectDelayRef.current = RECONNECT_DELAY_MS;
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleOpen() {
reconnectDelayRef.current = RECONNECT_DELAY_MS;
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleError() {
if (closed) return;
source?.close();
setConnectionStatusKey("notifications.center.realtime.reconnecting");
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,
};
}