// Notification Context and Provider // Manages notification state, SSE subscription, and notification actions const { useState, useCallback, useEffect, createContext, useContext, useRef } = React; // Token key constant (same value as in api.js and AuthContext) const NOTIFICATION_TOKEN_KEY = 'rosa_access_token'; const NotificationContext = createContext(); function NotificationProvider({ children }) { const toast = useToast(); const { isAuthenticated, user } = useAuth(); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(false); const websocketRef = useRef(null); const reconnectTimeoutRef = useRef(null); // Get auth headers helper const getAuthHeaders = () => { const token = localStorage.getItem(NOTIFICATION_TOKEN_KEY); return token ? { 'Authorization': `Bearer ${token}` } : {}; }; // Fetch notifications from API const fetchNotifications = useCallback(async (limit = 20) => { if (!isAuthenticated) return; setLoading(true); try { const response = await fetch(`/api/notifications?limit=${limit}`, { headers: getAuthHeaders(), }); if (response.ok) { const data = await response.json(); setNotifications(data.notifications || []); setUnreadCount(data.unread_count || 0); } } catch (err) { console.error('Failed to fetch notifications:', err); } finally { setLoading(false); } }, [isAuthenticated]); // Fetch just the unread count (lightweight) const fetchUnreadCount = useCallback(async () => { if (!isAuthenticated) return; try { const response = await fetch('/api/notifications/unread-count', { headers: getAuthHeaders(), }); if (response.ok) { const data = await response.json(); setUnreadCount(data.count || 0); } } catch (err) { console.error('Failed to fetch unread count:', err); } }, [isAuthenticated]); // Mark a single notification as read const markAsRead = useCallback(async (notificationId) => { try { const response = await fetch(`/api/notifications/${notificationId}/read`, { method: 'POST', headers: getAuthHeaders(), }); if (response.ok) { const data = await response.json(); // Update local state setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, is_read: true, read_at: new Date().toISOString() } : n ) ); setUnreadCount(prev => Math.max(0, prev - 1)); return data.notification; } } catch (err) { console.error('Failed to mark notification as read:', err); } return null; }, []); // Mark all notifications as read const markAllAsRead = useCallback(async () => { try { const response = await fetch('/api/notifications/read-all', { method: 'POST', headers: getAuthHeaders(), }); if (response.ok) { // Update local state setNotifications(prev => prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() })) ); setUnreadCount(0); return true; } } catch (err) { console.error('Failed to mark all notifications as read:', err); } return false; }, []); // Delete a notification const deleteNotification = useCallback(async (notificationId) => { try { const response = await fetch(`/api/notifications/${notificationId}`, { method: 'DELETE', headers: getAuthHeaders(), }); if (response.ok) { // Update local state setNotifications(prev => { const notification = prev.find(n => n.id === notificationId); if (notification && !notification.is_read) { setUnreadCount(c => Math.max(0, c - 1)); } return prev.filter(n => n.id !== notificationId); }); return true; } } catch (err) { console.error('Failed to delete notification:', err); } return false; }, []); // Handle incoming real-time notification const handleNewNotification = useCallback((notification) => { // Add to notifications list (at the beginning) setNotifications(prev => [notification, ...prev.slice(0, 49)]); setUnreadCount(prev => prev + 1); // Show toast notification toast(notification.title, 'info'); }, [toast]); // Set up WebSocket connection for real-time notifications + polling fallback useEffect(() => { let pollingInterval = null; if (!isAuthenticated) { // Clean up if not authenticated if (websocketRef.current) { websocketRef.current.close(); websocketRef.current = null; } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } return; } const connect = () => { // Close existing connection if (websocketRef.current) { websocketRef.current.close(); } // Get token for WebSocket authentication const token = localStorage.getItem(NOTIFICATION_TOKEN_KEY); if (!token) { console.warn('No auth token for notification WebSocket'); return; } // Determine WebSocket protocol based on current page protocol const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const wsUrl = `${protocol}//${host}/api/notifications/ws`; const ws = new WebSocket(wsUrl); websocketRef.current = ws; ws.onopen = () => { console.log('WebSocket connected, sending auth...'); // Send authentication message ws.send(JSON.stringify({ type: 'auth', token: token })); }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data); switch (message.type) { case 'auth_success': console.log('WebSocket authenticated successfully'); break; case 'notification': // Handle incoming notification handleNewNotification(message.data); break; case 'heartbeat': // Heartbeat to keep connection alive console.debug('WebSocket heartbeat received'); break; case 'error': console.error('WebSocket error from server:', message.message); break; default: console.warn('Unknown WebSocket message type:', message.type); } } catch (err) { console.error('Failed to parse WebSocket message:', err); } }; ws.onerror = (err) => { console.error('WebSocket error:', err); }; ws.onclose = (event) => { console.log('WebSocket closed:', event.code, event.reason); websocketRef.current = null; // Reconnect after 5 seconds if not a normal closure if (event.code !== 1000) { reconnectTimeoutRef.current = setTimeout(connect, 5000); } }; }; // Initial connection connect(); // Initial fetch fetchNotifications(); // Also set up polling as fallback (every 30 seconds) // This ensures notifications are fetched even if Redis/WebSocket is not working pollingInterval = setInterval(() => { fetchUnreadCount(); }, 30000); // Cleanup on unmount or auth change return () => { if (websocketRef.current) { websocketRef.current.close(); websocketRef.current = null; } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } if (pollingInterval) { clearInterval(pollingInterval); } }; }, [isAuthenticated, handleNewNotification, fetchNotifications, fetchUnreadCount]); const value = { notifications, unreadCount, loading, fetchNotifications, fetchUnreadCount, markAsRead, markAllAsRead, deleteNotification, }; return ( {children} ); } function useNotifications() { const context = useContext(NotificationContext); if (!context) { throw new Error('useNotifications must be used within a NotificationProvider'); } return context; } // Export to global scope window.NotificationContext = NotificationContext; window.NotificationProvider = NotificationProvider; window.useNotifications = useNotifications;