Files
bakery-ia/frontend/src/hooks/useSSE.ts
2025-12-05 20:07:01 +01:00

239 lines
7.9 KiB
TypeScript

/**
* 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<Event[]>([]);
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<Event[]>([]);
useEffect(() => {
// Deduplicate notifications by event_type + entity_id
const seen = new Set<string>();
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,
};
}