/** * NotificationManager & UI helpers for real-time document events. * - Handles WebSocket auth, reconnection with backoff, heartbeats * - Exposes simple hooks for message handling and state updates * - Provides small UI helpers: connection badge, status badge, event list */ (function(){ // ---------------------------------------------------------------------------- // Utilities // ---------------------------------------------------------------------------- function getAuthToken() { try { return (window.app && window.app.token) || localStorage.getItem('auth_token') || null; } catch (_) { return null; } } function buildWsUrl(path) { const loc = window.location; const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; const token = encodeURIComponent(getAuthToken() || ''); const sep = path.includes('?') ? '&' : '?'; return `${proto}//${loc.host}${path}${sep}token=${token}`; } function nowIso() { try { return new Date().toISOString(); } catch(_) { return String(Date.now()); } } function clamp(min, v, max) { return Math.max(min, Math.min(max, v)); } // ---------------------------------------------------------------------------- // NotificationManager // ---------------------------------------------------------------------------- class NotificationManager { /** * @param {Object} options * @param {() => string} options.getUrl - function returning WS path (starting with /api/...) * @param {(msg: object) => void} [options.onMessage] * @param {(state: string) => void} [options.onStateChange] * @param {boolean} [options.autoConnect] * @param {boolean} [options.debug] */ constructor({ getUrl, onMessage = null, onStateChange = null, autoConnect = true, debug = false } = {}) { this._getUrl = typeof getUrl === 'function' ? getUrl : null; this._onMessage = typeof onMessage === 'function' ? onMessage : null; this._onStateChange = typeof onStateChange === 'function' ? onStateChange : null; this._ws = null; this._closed = false; this._state = 'idle'; this._backoffMs = 1000; this._reconnectTimer = null; this._pingTimer = null; this._debug = !!debug; this._lastUrl = null; // offline/online handling this._offline = !navigator.onLine; this._handleOnline = () => { this._offline = false; this._setState('online'); if (this._ws == null && !this._closed) { // reconnect immediately when back online this._scheduleReconnect(0); } }; this._handleOffline = () => { this._offline = true; this._setState('offline'); this._teardownSocket(); }; window.addEventListener('online', this._handleOnline); window.addEventListener('offline', this._handleOffline); if (autoConnect) { this.connect(); } } _log(level, msg, extra = null) { if (!this._debug) return; try { // eslint-disable-next-line no-console console[level](`[NotificationManager] ${msg}`, extra || ''); } catch (_) {} } _setState(next) { if (this._state === next) return; this._state = next; if (typeof this._onStateChange === 'function') { try { this._onStateChange(next); } catch (_) {} } } getState() { return this._state; } connect() { if (!this._getUrl) throw new Error('NotificationManager: getUrl not provided'); if (this._ws && this._ws.readyState <= 1) return; // already open/connecting if (this._offline) { this._setState('offline'); return; } const path = this._getUrl(); this._lastUrl = path; const url = buildWsUrl(path); this._log('info', 'connecting', { url }); this._setState('connecting'); try { this._ws = new WebSocket(url); } catch (e) { this._log('error', 'WebSocket ctor failed', e); this._scheduleReconnect(); return; } this._ws.onopen = () => { this._log('info', 'connected'); this._setState('open'); this._backoffMs = 1000; // heartbeat: send ping every 30s this._pingTimer = setInterval(() => { try { this.send({ type: 'ping', timestamp: nowIso() }); } catch(_) {} }, 30000); }; this._ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (!msg || typeof msg !== 'object') return; // handle standard types if (msg.type === 'heartbeat') return; // no-op if (this._debug && msg.type === 'welcome') this._log('info', 'welcome', msg); if (typeof this._onMessage === 'function') { this._onMessage(msg); } } catch (_) { // ignore parse errors } }; this._ws.onerror = () => { this._log('warn', 'ws error'); this._setState('error'); }; this._ws.onclose = (ev) => { this._log('info', 'closed', { code: ev && ev.code, reason: ev && ev.reason }); if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; } this._setState(this._offline ? 'offline' : 'closed'); if (!this._closed) { this._scheduleReconnect(); } }; } _teardownSocket() { try { if (this._ws && this._ws.readyState <= 1) this._ws.close(); } catch(_) {} this._ws = null; if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; } } _scheduleReconnect(delayMs = null) { if (this._offline) return; // wait for online event if (this._closed) return; if (this._reconnectTimer) clearTimeout(this._reconnectTimer); const ms = delayMs == null ? this._backoffMs : delayMs; const next = clamp(1000, this._backoffMs * 2, 30000); this._log('info', `reconnecting in ${ms}ms`); this._setState('reconnecting'); this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; this._backoffMs = next; this.connect(); }, ms); } reconnectNow() { if (this._offline) return; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._backoffMs = 1000; this._teardownSocket(); this.connect(); } send(payload) { if (!this._ws || this._ws.readyState !== 1) return false; try { this._ws.send(JSON.stringify(payload)); return true; } catch (_) { return false; } } close() { this._closed = true; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._teardownSocket(); this._setState('closed'); window.removeEventListener('online', this._handleOnline); window.removeEventListener('offline', this._handleOffline); } } // ---------------------------------------------------------------------------- // UI helpers // ---------------------------------------------------------------------------- function createConnectionBadge() { const dot = document.createElement('span'); dot.className = 'inline-flex items-center gap-1 text-xs'; const circle = document.createElement('span'); circle.className = 'inline-block w-2.5 h-2.5 rounded-full bg-neutral-400'; const label = document.createElement('span'); label.textContent = 'offline'; label.className = 'text-neutral-500'; dot.appendChild(circle); dot.appendChild(label); function update(state) { const map = { open: ['bg-green-500', 'text-green-600', 'live'], connecting: ['bg-amber-500', 'text-amber-600', 'connecting'], reconnecting: ['bg-amber-500', 'text-amber-600', 'reconnecting'], closed: ['bg-neutral-400', 'text-neutral-500', 'disconnected'], error: ['bg-red-500', 'text-red-600', 'error'], offline: ['bg-neutral-400', 'text-neutral-500', 'offline'], online: ['bg-amber-500', 'text-amber-600', 'connecting'] }; const cfg = map[state] || map.closed; circle.className = `inline-block w-2.5 h-2.5 rounded-full ${cfg[0]}`; label.className = `${cfg[1]}`; label.textContent = cfg[2]; } return { element: dot, update }; } function createStatusBadge(status) { const span = document.createElement('span'); const s = String(status || '').toLowerCase(); let cls = 'bg-neutral-100 text-neutral-700 border border-neutral-300'; if (s === 'processing') cls = 'bg-amber-100 text-amber-700 border border-amber-400'; else if (s === 'completed' || s === 'success') cls = 'bg-green-100 text-green-700 border border-green-400'; else if (s === 'failed' || s === 'error') cls = 'bg-red-100 text-red-700 border border-red-400'; span.className = `inline-block px-2 py-0.5 text-xs rounded ${cls}`; span.textContent = (s || '').toUpperCase() || 'UNKNOWN'; return span; } function appendEvent(listEl, { fileNo, status, message = null, timestamp = null, max = 50 }) { if (!listEl) return; const row = document.createElement('div'); row.className = 'flex items-center justify-between gap-3 p-2 border rounded-lg'; const left = document.createElement('div'); left.className = 'flex items-center gap-2 text-sm'; const code = document.createElement('code'); code.textContent = fileNo ? `#${fileNo}` : ''; left.appendChild(code); if (message) { const msg = document.createElement('span'); msg.className = 'text-neutral-600 dark:text-neutral-300'; msg.textContent = String(message); left.appendChild(msg); } const right = document.createElement('div'); right.className = 'flex items-center gap-2'; right.appendChild(createStatusBadge(status)); if (timestamp) { const time = document.createElement('span'); time.className = 'text-xs text-neutral-500'; try { time.textContent = new Date(timestamp).toLocaleTimeString(); } catch(_) { time.textContent = String(timestamp); } right.appendChild(time); } row.appendChild(left); row.appendChild(right); listEl.prepend(row); while (listEl.children.length > (max || 50)) { listEl.removeChild(listEl.lastElementChild); } } // ---------------------------------------------------------------------------- // High-level helpers for pages // ---------------------------------------------------------------------------- function connectFileNotifications({ fileNo, onEvent, onState }) { if (!fileNo) return null; const mgr = new NotificationManager({ getUrl: () => `/api/documents/ws/status/${encodeURIComponent(fileNo)}`, onMessage: (msg) => { if (!msg || !msg.type) return; // Types: document_processing, document_completed, document_failed if (/^document_/.test(String(msg.type))) { const status = String(msg.type).replace('document_', ''); const data = msg.data || {}; const payload = { fileNo: data.file_no || fileNo, status, timestamp: msg.timestamp || nowIso(), message: data.filename || data.file_name || data.message || null, data }; if (typeof onEvent === 'function') onEvent(payload); // Default toast try { const friendly = status === 'processing' ? 'Processing started' : (status === 'completed' ? 'Document ready' : 'Generation failed'); const tone = status === 'completed' ? 'success' : (status === 'failed' ? 'danger' : 'info'); if (window.alerts && window.alerts.show) window.alerts.show(`${friendly} for #${payload.fileNo}`, tone, { duration: status==='processing' ? 2500 : 5000 }); } catch(_) {} } }, onStateChange: (s) => { if (typeof onState === 'function') onState(s); // Optional user feedback on connectivity try { if (s === 'offline' && window.alerts) window.alerts.warning('You are offline. Live updates paused.', { duration: 3000 }); if (s === 'open' && window.alerts) window.alerts.success('Live document updates connected.', { duration: 1500 }); } catch(_) {} }, autoConnect: true, debug: false }); return mgr; } function connectAdminDocumentStream({ onEvent, onState }) { const mgr = new NotificationManager({ getUrl: () => `/api/admin/ws/documents`, onMessage: (msg) => { if (!msg || !msg.type) return; if (msg.type === 'admin_document_event') { const data = msg.data || {}; const payload = { fileNo: data.file_no || null, status: (data.status || '').toLowerCase(), timestamp: msg.timestamp || nowIso(), message: data.message || null, data }; if (typeof onEvent === 'function') onEvent(payload); } }, onStateChange: (s) => { if (typeof onState === 'function') onState(s); }, autoConnect: true, debug: false }); return mgr; } // ---------------------------------------------------------------------------- // Exports // ---------------------------------------------------------------------------- window.notifications = window.notifications || {}; window.notifications.NotificationManager = NotificationManager; window.notifications.createConnectionBadge = createConnectionBadge; window.notifications.createStatusBadge = createStatusBadge; window.notifications.appendEvent = appendEvent; window.notifications.connectFileNotifications = connectFileNotifications; window.notifications.connectAdminDocumentStream = connectAdminDocumentStream; })();