/** * @fileoverview Manages application-wide notifications. * Handles persistence via IndexedDB, real-time updates via SSE, and state management. */ import dssDB from '../db/indexed-db.js'; const NOTIFICATION_STORE = 'notifications'; class NotificationService extends EventTarget { constructor() { super(); this.notifications = []; this.unreadCount = 0; this._eventSource = null; this._initialized = false; this._broadcastChannel = null; } async init() { if (this._initialized) return; this._initialized = true; await this._loadFromStorage(); this._connectToEvents(); this._setupCrossTabSync(); } /** * Setup cross-tab synchronization via BroadcastChannel API * When notifications are modified in another tab, reload them */ _setupCrossTabSync() { try { this._broadcastChannel = new BroadcastChannel('dss-notifications'); this._broadcastChannel.onmessage = (event) => { if (event.data?.type === 'notifications-updated') { console.log('[NotificationService] Notifications updated in another tab, reloading...'); this._loadFromStorage(); } }; } catch (error) { console.warn('[NotificationService] BroadcastChannel not available:', error); } } /** * Notify other tabs about notification changes * @private */ _notifyOtherTabs() { if (this._broadcastChannel) { try { this._broadcastChannel.postMessage({ type: 'notifications-updated' }); } catch (error) { console.warn('[NotificationService] Failed to broadcast update:', error); } } } async _loadFromStorage() { try { this.notifications = await dssDB.getAll(NOTIFICATION_STORE) || []; this._sortNotifications(); this._updateUnreadCount(); this.dispatchEvent(new CustomEvent('notifications-updated', { detail: { notifications: this.notifications } })); } catch (error) { console.error('Failed to load notifications from storage:', error); this.notifications = []; } } _connectToEvents() { // Only connect if SSE endpoint exists and we have an auth token try { // Get access token from localStorage const authTokens = localStorage.getItem('auth_tokens'); if (!authTokens) { console.log('[NotificationService] No auth token available, skipping SSE connection'); return; } const { accessToken } = JSON.parse(authTokens); if (!accessToken) { console.log('[NotificationService] No access token found, skipping SSE connection'); return; } // Construct SSE URL with token parameter (EventSource can't send custom headers) const sseUrl = `/api/notifications/events?token=${encodeURIComponent(accessToken)}`; console.log('[NotificationService] Connecting to SSE with authentication...'); this._eventSource = new EventSource(sseUrl); this._eventSource.onmessage = (event) => { try { const notificationData = JSON.parse(event.data); console.log('[NotificationService] SSE notification received:', notificationData.type); // From SSE, persist to local storage this.create(notificationData, true); } catch (error) { console.error('[NotificationService] Error parsing SSE notification:', error); } }; this._eventSource.onerror = (err) => { console.warn('[NotificationService] SSE connection error, using local-only mode'); this._eventSource.close(); this._eventSource = null; }; this._eventSource.onopen = () => { console.log('[NotificationService] ✅ SSE connection established successfully'); }; } catch (error) { console.warn('[NotificationService] SSE setup error:', error); } } _sortNotifications() { this.notifications.sort((a, b) => b.timestamp - a.timestamp); } _updateUnreadCount() { const newCount = this.notifications.filter(n => !n.read).length; if (newCount !== this.unreadCount) { this.unreadCount = newCount; this.dispatchEvent(new CustomEvent('unread-count-changed', { detail: { count: this.unreadCount } })); } } _dispatchUpdate() { this._sortNotifications(); this._updateUnreadCount(); this.dispatchEvent(new CustomEvent('notifications-updated', { detail: { notifications: this.notifications } })); } /** * Creates a new notification. * @param {object} notificationData - The notification data. * @param {string} notificationData.title - Notification title * @param {string} [notificationData.message] - Notification message * @param {string} [notificationData.type='info'] - 'info', 'success', 'warning', 'error' * @param {string} [notificationData.source] - Source service name * @param {Array} [notificationData.actions] - Action buttons * @param {boolean} [persist=true] - Whether to save to IndexedDB. * @returns {Promise} The created notification. */ async create(notificationData, persist = true) { const newNotification = { id: crypto.randomUUID(), timestamp: Date.now(), read: false, type: 'info', ...notificationData, }; this.notifications.unshift(newNotification); if (persist) { try { await dssDB.put(NOTIFICATION_STORE, newNotification); } catch (error) { console.error('Failed to persist notification:', error); } } this._dispatchUpdate(); this._notifyOtherTabs(); // Also show as toast for immediate visibility if (typeof window.showToast === 'function') { window.showToast({ message: `${newNotification.title}${newNotification.message ? `
${newNotification.message}` : ''}`, type: newNotification.type, duration: 5000, dismissible: true }); } return newNotification; } /** * Retrieves all notifications. * @returns {object[]} */ getAll() { return [...this.notifications]; } /** * Get unread count * @returns {number} */ getUnreadCount() { return this.unreadCount; } /** * Marks a specific notification as read. * @param {string} id - The ID of the notification to update. * @returns {Promise} */ async markAsRead(id) { const notification = this.notifications.find(n => n.id === id); if (notification && !notification.read) { notification.read = true; try { await dssDB.put(NOTIFICATION_STORE, notification); } catch (error) { console.error('Failed to update notification:', error); } this._dispatchUpdate(); this._notifyOtherTabs(); } } /** * Marks all unread notifications as read. * @returns {Promise} */ async markAllAsRead() { const updates = []; this.notifications.forEach(n => { if (!n.read) { n.read = true; updates.push(dssDB.put(NOTIFICATION_STORE, n)); } }); if (updates.length > 0) { try { await Promise.all(updates); } catch (error) { console.error('Failed to mark all as read:', error); } this._dispatchUpdate(); this._notifyOtherTabs(); } } /** * Deletes a notification by its ID. * @param {string} id * @returns {Promise} */ async delete(id) { this.notifications = this.notifications.filter(n => n.id !== id); try { await dssDB.delete(NOTIFICATION_STORE, id); } catch (error) { console.error('Failed to delete notification:', error); } this._dispatchUpdate(); this._notifyOtherTabs(); } /** * Clears all notifications. * @returns {Promise} */ async clearAll() { this.notifications = []; try { await dssDB.clear(NOTIFICATION_STORE); } catch (error) { console.error('Failed to clear notifications:', error); } this._dispatchUpdate(); this._notifyOtherTabs(); } /** * Cleanup on app shutdown */ destroy() { if (this._eventSource) { this._eventSource.close(); this._eventSource = null; } if (this._broadcastChannel) { this._broadcastChannel.close(); this._broadcastChannel = null; } } } // Export a singleton instance const notificationService = new NotificationService(); export default notificationService;