import { getContext, setContext } from 'svelte'; import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications'; export type { NotificationItem }; export const NOTIFICATION_KEY = Symbol('notification'); export interface NotificationStore { readonly notifications: NotificationItem[]; readonly unreadCount: number; fetchNotifications(): Promise; fetchUnreadCount(): Promise; optimisticMarkRead(id: string): void; optimisticMarkAllRead(): void; init(): void; destroy(): void; /** Test-only: seed the notification list and derive the unread count. */ setNotifications(items: NotificationItem[]): void; /** Test-only: override the 401 → redirect side-effect. */ setNavigate(fn: (url: string) => void): void; } export function createNotificationStore(): NotificationStore { let notifications = $state([]); let unreadCount = $state(0); let eventSource: EventSource | null = null; let refCount = 0; let errorCount = 0; let navigate: (url: string) => void = (url) => { window.location.href = url; }; async function fetchNotifications(): Promise { try { const res = await fetch('/api/notifications?size=10'); if (res.ok) { const data = await res.json(); notifications = data.content ?? []; } } catch (e) { console.error('Failed to fetch notifications', e); } } async function fetchUnreadCount(): Promise { try { const res = await fetch('/api/notifications/unread-count'); if (res.ok) { const data = await res.json(); unreadCount = data.count; } } catch (e) { console.error('Failed to fetch unread count', e); } } function optimisticMarkRead(id: string): void { const notification = notifications.find((n) => n.id === id); if (notification && !notification.read) { notification.read = true; unreadCount = Math.max(0, unreadCount - 1); } } function optimisticMarkAllRead(): void { for (const n of notifications) { n.read = true; } unreadCount = 0; } function init(): void { refCount += 1; if (refCount > 1) return; fetchUnreadCount(); eventSource = new EventSource('/api/notifications/stream'); eventSource.addEventListener('notification', (e) => { const notification = parseNotificationEvent((e as MessageEvent).data); if (!notification) return; notifications = [notification, ...notifications]; if (!notification.read) unreadCount += 1; }); eventSource.onopen = () => { fetchUnreadCount(); errorCount = 0; }; eventSource.onerror = async () => { if (eventSource?.readyState === EventSource.CLOSED) { const res = await fetch('/api/notifications/unread-count'); if (res.status === 401) navigate('/login'); return; } errorCount += 1; if (errorCount >= 3) { eventSource?.close(); eventSource = null; errorCount = 0; const res = await fetch('/api/notifications/unread-count'); if (res.status === 401) navigate('/login'); } }; } function destroy(): void { if (refCount === 0) return; refCount -= 1; if (refCount === 0) { eventSource?.close(); eventSource = null; } } return { get notifications() { return notifications; }, get unreadCount() { return unreadCount; }, fetchNotifications, fetchUnreadCount, optimisticMarkRead, optimisticMarkAllRead, init, destroy, setNotifications(items: NotificationItem[]): void { notifications = items; unreadCount = items.filter((n) => !n.read).length; }, setNavigate(fn: (url: string) => void): void { navigate = fn; } }; } /** * Create a notification store and put it on the context. Call once high in the * tree (root +layout.svelte). Descendants read it via getNotificationStore(). */ export function provideNotificationStore(): NotificationStore { const store = createNotificationStore(); setContext(NOTIFICATION_KEY, store); return store; } export function getNotificationStore(): NotificationStore { let store: NotificationStore | undefined; try { store = getContext(NOTIFICATION_KEY); } catch { throw new Error( 'NotificationStore not found — call provideNotificationStore() in +layout.svelte' ); } if (!store) throw new Error( 'NotificationStore not found — call provideNotificationStore() in +layout.svelte' ); return store; }