319 lines
8.9 KiB
TypeScript
319 lines
8.9 KiB
TypeScript
|
|
/**
|
||
|
|
* 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',
|
||
|
|
],
|
||
|
|
});
|
||
|
|
}
|