Files
bakery-ia/frontend/src/hooks/useEventNotifications.ts

319 lines
8.9 KiB
TypeScript
Raw Normal View History

/**
* 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 '../types/events';
import { isNotification } from '../types/events';
interface UseEventNotificationsReturn {
notifications: Notification[];
recentNotifications: Notification[];
isLoading: boolean;
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<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Use refs to track previous values and prevent unnecessary updates
const prevEventIdsRef = useRef<string>('');
const prevEventTypesRef = useRef<string[]>([]);
const prevMaxAgeRef = useRef<number>(maxAge);
const prevDomainsRef = useRef<string[]>(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,
clearNotifications,
};
}
/**
* useNotificationsByDomain Hook
*
* Get notifications grouped by domain.
*/
export function useNotificationsByDomain(config: UseNotificationsConfig = {}) {
const { notifications, ...rest } = useEventNotifications(config);
const notificationsByDomain = useMemo(() => {
const grouped: Partial<Record<EventDomain, Notification[]>> = {};
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',
],
});
}