Spaces:
Running
Running
| /** | |
| * alerts-controller.js - Notification Management with Acknowledgement | |
| * =================================================================== | |
| * Handles polling, rendering, and acknowledging system alerts. | |
| * * Hardened for Enterprise MVC: | |
| * - Uses central `apiFetch` to automatically inherit HTTP-Only Cookies and CSRF headers. | |
| * - Relies on `fetch-wrapper.js` for 401 session expiration handling. | |
| * - Uses `SecurityUtils` for XSS-safe DOM generation. | |
| */ | |
| (function() { | |
| 'use strict'; | |
| // Safe HTML Escaping (Fallback to native if SecurityUtils isn't loaded yet) | |
| const escapeHTML = (text) => { | |
| if (window.SecurityUtils) return window.SecurityUtils.escapeHTML(text); | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| }; | |
| // ββ Data Fetching βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadAlerts() { | |
| // Only fetch if user is authenticated (UI hint check) | |
| const user = JSON.parse(localStorage.getItem('user') || 'null'); | |
| if (!user || (!user.email && !user.temp)) return; | |
| try { | |
| // Enterprise Architecture: Centralized apiFetch handles CSRF, Cookies, and 401s automatically | |
| const data = await window.apiFetch('/alerts?limit=20'); | |
| if (data.success) { | |
| renderAlerts(data.alerts || []); | |
| // Ensure we only count unacknowledged alerts for the badge | |
| const unread = data.alerts ? data.alerts.filter(a => !a.acknowledged).length : 0; | |
| updateBadgeCount(unread); | |
| } | |
| } catch (error) { | |
| // Silently fail for background polling to prevent spamming the user, | |
| // but log for debugging purposes. | |
| console.error('[Alerts] Background sync failed:', error); | |
| } | |
| } | |
| // ββ Rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderAlerts(alerts) { | |
| const list = document.querySelector('.notifications-list'); | |
| if (!list) return; | |
| if (alerts.length === 0) { | |
| list.innerHTML = '<p class="empty-message" style="padding:1.5rem;text-align:center;color:var(--md-on-surface-muted);font-size:0.85rem;">No new alerts</p>'; | |
| return; | |
| } | |
| list.innerHTML = alerts.map(alert => ` | |
| <div class="notification-item ${alert.acknowledged ? 'acknowledged' : ''}" data-alert-id="${escapeHTML(alert._id)}"> | |
| <span class="notification-icon">${getSeverityIcon(alert.severity)}</span> | |
| <div class="notification-content"> | |
| <p class="notification-title">${escapeHTML(alert.message)}</p> | |
| <p class="notification-time">${escapeHTML(formatTimeAgo(alert.created_at))}</p> | |
| </div> | |
| ${!alert.acknowledged ? ` | |
| <button class="notification-ack-btn" data-alert-id="${escapeHTML(alert._id)}" title="Mark as read"> | |
| <span class="material-symbols-rounded">check</span> | |
| </button> | |
| ` : ''} | |
| </div> | |
| `).join(''); | |
| list.querySelectorAll('.notification-ack-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| acknowledgeAlert(btn.dataset.alertId); | |
| }); | |
| }); | |
| } | |
| async function acknowledgeAlert(alertId) { | |
| try { | |
| // Use apiFetch so CSRF headers are automatically injected | |
| const data = await window.apiFetch(`/alerts/${alertId}`, { method: 'PUT' }); | |
| if (data.success) { | |
| const el = document.querySelector(`[data-alert-id="${alertId}"]`); | |
| if (el) { | |
| el.style.opacity = '0'; | |
| el.style.transform = 'translateX(20px)'; | |
| el.style.transition = '0.3s ease'; | |
| setTimeout(() => { | |
| el.remove(); | |
| loadAlerts(); // Refresh to update count and get fresh data | |
| }, 300); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('[Alerts] Ack failed:', error); | |
| if (window.ErrorHandler) { | |
| window.ErrorHandler.showError(error); | |
| } else if (window.SecurityUtils) { | |
| window.SecurityUtils.showToast('Failed to acknowledge alert', 'error'); | |
| } | |
| } | |
| } | |
| // ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateBadgeCount(count) { | |
| const badge = document.getElementById('notification-count'); | |
| if (!badge) return; | |
| badge.textContent = count > 99 ? '99+' : count; | |
| badge.hidden = (count <= 0); | |
| } | |
| function getSeverityIcon(sev) { | |
| const icons = { | |
| 'critical': ['warning', 'var(--md-error)'], | |
| 'high': ['error', 'var(--md-error)'], | |
| 'warning': ['info', 'var(--md-warning)'], | |
| 'medium': ['info', 'var(--md-warning)'] | |
| }; | |
| const [icon, color] = icons[sev.toLowerCase()] || ['notifications', 'var(--md-on-surface-muted)']; | |
| return `<span class="material-symbols-rounded" style="color:${color}">${icon}</span>`; | |
| } | |
| function formatTimeAgo(dateStr) { | |
| if (!dateStr) return 'Unknown'; | |
| const date = new Date(dateStr); | |
| if (isNaN(date.getTime())) return 'Unknown'; | |
| const seconds = Math.floor((Date.now() - date.getTime()) / 1000); | |
| if (seconds < 60) return 'Just now'; | |
| if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; | |
| if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; | |
| return `${Math.floor(seconds / 86400)}d ago`; | |
| } | |
| // ββ Initialization & Lifecycle ββββββββββββββββββββββββββββββββββββββββββ | |
| function init() { | |
| // Prevent Multiple Polling Intervals (Memory Leak Guard for SPAs) | |
| if (window.AlertsController && window.AlertsController._poll) { | |
| clearInterval(window.AlertsController._poll); | |
| } | |
| // Only begin polling if user is authenticated | |
| const user = JSON.parse(localStorage.getItem('user') || 'null'); | |
| if (!user || (!user.email && !user.temp)) { | |
| console.info('[Alerts] User not authenticated. Alerts polling disabled.'); | |
| return; | |
| } | |
| loadAlerts(); | |
| // 30s polling interval | |
| const _poll = setInterval(loadAlerts, 30000); | |
| // Expose globally to allow manual refresh and clean teardown | |
| window.AlertsController = { | |
| reload: loadAlerts, | |
| _poll: _poll, | |
| stop: () => clearInterval(_poll) | |
| }; | |
| console.log('β Qualora Alerts Controller Initialized'); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| })(); |