/** * useSSE Hook * * Wrapper around SSEContext that collects and manages events. * Provides a clean interface for subscribing to SSE events with channel filtering. * * Examples: * const { events } = useSSE(); // All events * const { events } = useSSE({ channels: ['inventory.alerts'] }); * const { events } = useSSE({ channels: ['*.notifications'] }); */ import { useContext, useEffect, useState, useCallback } from 'react'; import { SSEContext } from '../contexts/SSEContext'; import type { Event, Alert, Notification, Recommendation } from '../api/types/events'; import { convertLegacyAlert } from '../api/types/events'; interface UseSSEConfig { channels?: string[]; } interface UseSSEReturn { events: Event[]; isConnected: boolean; clearEvents: () => void; } const MAX_EVENTS = 200; // Keep last 200 events in memory export function useSSEEvents(config: UseSSEConfig = {}): UseSSEReturn { const context = useContext(SSEContext); const [events, setEvents] = useState([]); if (!context) { throw new Error('useSSE must be used within SSEProvider'); } // Create a stable key for the config channels to avoid unnecessary re-renders // Use JSON.stringify for reliable comparison of channel arrays const channelsKey = JSON.stringify(config.channels || []); useEffect(() => { const unsubscribers: (() => void)[] = []; // Listen to 'alert' events (can be Alert or legacy format) const handleAlert = (data: any) => { console.log('🟢 [useSSE] handleAlert triggered', { data }); let event: Event; // Check if it's new format (has event_class) or legacy format if (data.event_class === 'alert' || data.event_class === 'notification' || data.event_class === 'recommendation') { // New format event = data as Event; } else if (data.item_type === 'alert' || data.item_type === 'recommendation') { // Legacy format - convert event = convertLegacyAlert(data); } else { // Assume it's an alert if no clear classification event = { ...data, event_class: 'alert', event_domain: 'operations' } as Alert; } console.log('🟢 [useSSE] Setting events state with new alert', { eventId: event.id, eventClass: event.event_class, eventDomain: event.event_domain, }); setEvents(prev => { // Check if this event already exists to prevent duplicate processing const existingIndex = prev.findIndex(e => e.id === event.id); if (existingIndex !== -1) { // Update existing event instead of adding duplicate const newEvents = [...prev]; newEvents[existingIndex] = event; return newEvents.slice(0, MAX_EVENTS); } // Add new event if not duplicate const filtered = prev.filter(e => e.id !== event.id); const newEvents = [event, ...filtered].slice(0, MAX_EVENTS); console.log('🟢 [useSSE] Events array updated', { prevCount: prev.length, newCount: newEvents.length, newEventIds: newEvents.map(e => e.id).join(','), }); return newEvents; }); }; unsubscribers.push(context.addEventListener('alert', handleAlert)); // Listen to 'notification' events const handleNotification = (data: Notification) => { setEvents(prev => { // Check if this notification already exists to prevent duplicate processing const existingIndex = prev.findIndex(e => e.id === data.id); if (existingIndex !== -1) { // Update existing notification instead of adding duplicate const newEvents = [...prev]; newEvents[existingIndex] = data; return newEvents.slice(0, MAX_EVENTS); } // Add new notification if not duplicate const filtered = prev.filter(e => e.id !== data.id); return [data, ...filtered].slice(0, MAX_EVENTS); }); }; unsubscribers.push(context.addEventListener('notification', handleNotification)); // Listen to 'recommendation' events const handleRecommendation = (data: any) => { let event: Recommendation; // Handle both new and legacy formats if (data.event_class === 'recommendation') { event = data as Recommendation; } else if (data.item_type === 'recommendation') { event = convertLegacyAlert(data) as Recommendation; } else { event = { ...data, event_class: 'recommendation', event_domain: 'operations' } as Recommendation; } setEvents(prev => { // Check if this recommendation already exists to prevent duplicate processing const existingIndex = prev.findIndex(e => e.id === event.id); if (existingIndex !== -1) { // Update existing recommendation instead of adding duplicate const newEvents = [...prev]; newEvents[existingIndex] = event; return newEvents.slice(0, MAX_EVENTS); } // Add new recommendation if not duplicate const filtered = prev.filter(e => e.id !== event.id); return [event, ...filtered].slice(0, MAX_EVENTS); }); }; unsubscribers.push(context.addEventListener('recommendation', handleRecommendation)); // Listen to 'initial_state' event (batch load on connection) const handleInitialState = (data: any) => { if (Array.isArray(data)) { // Convert each event to proper format const initialEvents = data.map(item => { if (item.event_class) { return item as Event; } else if (item.item_type) { return convertLegacyAlert(item); } else { return { ...item, event_class: 'alert', event_domain: 'operations' } as Event; } }); setEvents(initialEvents.slice(0, MAX_EVENTS)); } }; unsubscribers.push(context.addEventListener('initial_state', handleInitialState)); // Also listen to legacy 'initial_items' event const handleInitialItems = (data: any) => { if (Array.isArray(data)) { const initialEvents = data.map(item => { if (item.event_class) { return item as Event; } else { return convertLegacyAlert(item); } }); setEvents(initialEvents.slice(0, MAX_EVENTS)); } }; unsubscribers.push(context.addEventListener('initial_items', handleInitialItems)); return () => { unsubscribers.forEach(unsub => unsub()); }; }, [context, channelsKey]); // Fixed: Added channelsKey dependency const clearEvents = useCallback(() => { setEvents([]); }, []); return { events, isConnected: context.isConnected, clearEvents, }; } /** * useSSEWithDedupe Hook * * Enhanced version that deduplicates events more aggressively * based on event_type + entity_id for state change notifications. */ export function useSSEEventsWithDedupe(config: UseSSEConfig = {}) { const { events: rawEvents, ...rest } = useSSEEvents(config); const [deduplicatedEvents, setDeduplicatedEvents] = useState([]); useEffect(() => { // Deduplicate notifications by event_type + entity_id const seen = new Set(); const deduplicated: Event[] = []; for (const event of rawEvents) { let key: string; if (event.event_class === 'notification') { const notification = event as Notification; // Deduplicate by entity (keep only latest state) if (notification.entity_type && notification.entity_id) { key = `${notification.event_type}:${notification.entity_type}:${notification.entity_id}`; } else { key = event.id; } } else { // For alerts and recommendations, use ID key = event.id; } if (!seen.has(key)) { seen.add(key); deduplicated.push(event); } } setDeduplicatedEvents(deduplicated); }, [rawEvents]); return { events: deduplicatedEvents, ...rest, }; }