/** * useEventNotifications Hook * * Subscribe to lightweight informational notifications from the new event architecture. * Subscribes to domain-specific notification channels via SSE. * * Examples: * const { notifications } = useEventNotifications(); // All notifications * const { notifications } = useEventNotifications({ domains: ['production'], maxAge: 3600 }); * const { notifications } = useEventNotifications({ eventTypes: ['batch_completed', 'batch_started'] }); */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useSSEEvents } from './useSSE'; import type { Notification, EventDomain, UseNotificationsConfig, } from '../api/types/events'; import { isNotification } from '../api/types/events'; interface UseEventNotificationsReturn { notifications: Notification[]; recentNotifications: Notification[]; isLoading: boolean; isConnected: boolean; // Added isConnected to interface clearNotifications: () => void; } const DEFAULT_MAX_AGE = 3600; // 1 hour export function useEventNotifications(config: UseNotificationsConfig = {}): UseEventNotificationsReturn { const { domains, eventTypes, maxAge = DEFAULT_MAX_AGE, } = config; // Determine which channels to subscribe to const channels = useMemo(() => { if (!domains || domains.length === 0) { // Subscribe to all notification channels return ['*.notifications']; } // Subscribe to specific domain notification channels return domains.map(domain => `${domain}.notifications`); }, [domains]); // Subscribe to SSE with channel filter const { events, isConnected } = useSSEEvents({ channels }); // Filter events to notifications only const [notifications, setNotifications] = useState([]); const [isLoading, setIsLoading] = useState(true); // Use refs to track previous values and prevent unnecessary updates const prevEventIdsRef = useRef(''); const prevEventTypesRef = useRef([]); const prevMaxAgeRef = useRef(maxAge); const prevDomainsRef = useRef(domains || []); useEffect(() => { // Check if the configuration has actually changed const currentEventTypes = eventTypes || []; const currentDomains = domains || []; const configChanged = JSON.stringify(currentEventTypes) !== JSON.stringify(prevEventTypesRef.current) || prevMaxAgeRef.current !== maxAge || JSON.stringify(currentDomains) !== JSON.stringify(prevDomainsRef.current); // Update refs with current values prevEventTypesRef.current = currentEventTypes; prevMaxAgeRef.current = maxAge; prevDomainsRef.current = currentDomains; // Create current event IDs string for comparison const currentEventIds = events.map(e => e.id).join(','); // Only process if config changed OR events actually changed if (!configChanged && currentEventIds === prevEventIdsRef.current) { return; // No changes, skip processing } // Update the previous event IDs prevEventIdsRef.current = currentEventIds; console.log('🔵 [useEventNotifications] Effect triggered', { eventsCount: events.length, eventTypes, maxAge, domains, channels, }); // Filter and process incoming events const notificationEvents = events.filter(isNotification); console.log('🔵 [useEventNotifications] After isNotification filter:', { notificationEventsCount: notificationEvents.length, eventIds: notificationEvents.map(e => e.id).join(','), }); // Apply filters let filtered = notificationEvents; // Filter by event types if (eventTypes && eventTypes.length > 0) { filtered = filtered.filter(notification => eventTypes.includes(notification.event_type) ); console.log('🔵 [useEventNotifications] After event type filter:', { filteredCount: filtered.length, requestedTypes: eventTypes, }); } // Filter by age (maxAge in seconds) if (maxAge) { const maxAgeMs = maxAge * 1000; const now = Date.now(); filtered = filtered.filter(notification => { const notificationTime = new Date(notification.created_at).getTime(); return now - notificationTime <= maxAgeMs; }); console.log('🔵 [useEventNotifications] After age filter:', { filteredCount: filtered.length, maxAge, }); } // Filter expired notifications (TTL) filtered = filtered.filter(notification => { if (notification.expires_at) { const expiryTime = new Date(notification.expires_at).getTime(); return Date.now() < expiryTime; } return true; }); console.log('🔵 [useEventNotifications] After expiry filter:', { filteredCount: filtered.length, }); // Sort by timestamp (newest first) filtered.sort((a, b) => { const timeA = new Date(a.created_at).getTime(); const timeB = new Date(b.created_at).getTime(); return timeB - timeA; }); console.log('🔵 [useEventNotifications] Setting notifications state', { filteredCount: filtered.length, filteredIds: filtered.map(n => n.id).join(','), firstId: filtered[0]?.id, }); // Only update state if the IDs have actually changed to prevent infinite loops setNotifications(prev => { const prevIds = prev.map(n => n.id).join(','); const newIds = filtered.map(n => n.id).join(','); console.log('🔵 [useEventNotifications] Comparing IDs:', { prevIds, newIds, hasChanged: prevIds !== newIds, }); if (prevIds === newIds) { console.log('🔵 [useEventNotifications] IDs unchanged, keeping previous array reference'); return prev; // Keep same reference if IDs haven't changed } console.log('🔵 [useEventNotifications] IDs changed, updating state'); return filtered; }); setIsLoading(false); }, [events, eventTypes, maxAge, domains, channels]); // Include all dependencies // Computed values const recentNotifications = useMemo(() => { // Last 10 notifications return notifications.slice(0, 10); }, [notifications]); const clearNotifications = useCallback(() => { setNotifications([]); }, []); return { notifications, recentNotifications, isLoading: isLoading || !isConnected, isConnected, // Added this missing return property clearNotifications, }; } /** * useNotificationsByDomain Hook * * Get notifications grouped by domain. */ export function useNotificationsByDomain(config: UseNotificationsConfig = {}) { const { notifications, ...rest } = useEventNotifications(config); const notificationsByDomain = useMemo(() => { const grouped: Partial> = {}; notifications.forEach(notification => { if (!grouped[notification.event_domain]) { grouped[notification.event_domain] = []; } grouped[notification.event_domain]!.push(notification); }); return grouped; }, [notifications]); return { notifications, notificationsByDomain, ...rest, }; } /** * useProductionNotifications Hook * * Convenience hook for production domain notifications. */ export function useProductionNotifications(eventTypes?: string[]) { return useEventNotifications({ domains: ['production'], eventTypes, }); } /** * useInventoryNotifications Hook * * Convenience hook for inventory domain notifications. */ export function useInventoryNotifications(eventTypes?: string[]) { return useEventNotifications({ domains: ['inventory'], eventTypes, }); } /** * useSupplyChainNotifications Hook * * Convenience hook for supply chain domain notifications. */ export function useSupplyChainNotifications(eventTypes?: string[]) { return useEventNotifications({ domains: ['supply_chain'], eventTypes, }); } /** * useOperationsNotifications Hook * * Convenience hook for operations domain notifications (orchestration). */ export function useOperationsNotifications(eventTypes?: string[]) { return useEventNotifications({ domains: ['operations'], eventTypes, }); } /** * useBatchNotifications Hook * * Convenience hook for production batch-related notifications. */ export function useBatchNotifications() { return useEventNotifications({ domains: ['production'], eventTypes: [ 'batch_state_changed', 'batch_completed', 'batch_started', ], }); } /** * useDeliveryNotifications Hook * * Convenience hook for delivery-related notifications. */ export function useDeliveryNotifications() { return useEventNotifications({ domains: ['supply_chain'], eventTypes: [ 'delivery_scheduled', 'delivery_arriving_soon', 'delivery_received', ], }); } /** * useOrchestrationNotifications Hook * * Convenience hook for orchestration run notifications. */ export function useOrchestrationNotifications() { return useEventNotifications({ domains: ['operations'], eventTypes: [ 'orchestration_run_started', 'orchestration_run_completed', 'action_created', 'action_completed', ], }); }