(function () { 'use strict'; const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d']; const FALLBACK_TAB_KEYS = { surge: [ 'surge_tickets', 'surge_game', 'surge_stale', 'surge_needs_response', 'surge_unclaimed', 'surge_tier3_unclaimed', 'surge_no_staff' ], patterns: [ 'user_tickets', 'user_reopen', 'user_crossgame', 'game_surge', 'game_backlog', 'game_resolution', 'game_spike', 'tag_top', 'tag_escalation', 'untagged_closes', 'tag_game_corr', 'user_esc', 'game_esc_rate', 'rapid_t2_t3', 'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate', 'staff_esc', 'staff_game_esc', 'game_tag_spike', 'overnight_gap', 'staff_always_esc' ], unclaimed: ['unclaimed_reminder'], chat: ['chat_messages', 'chat_time'] }; const FALLBACK_ALERT_DESCRIPTIONS = { surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.', surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.', surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.', surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.', surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.', surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.", surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.', user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.', user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.', user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.', game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.', game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.', game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.', game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.', tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.', tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.', untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.', tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.', user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.', game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.', rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.', staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.', staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.', staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.', staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.', staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.', staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.', game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.', overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.', staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.', unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.', chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.', chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.' }; let notificationThresholdsState = {}; // Phase 9: notification enable state. Fetched from /api/notifications/state // on init. `master` is the global kill switch; `perKey` is a flat map of // alertKey → boolean. Both default to off so a fresh deploy is silent. let enableState = { master: false, perKey: {} }; // Active sources. Start as fallback; replaced/merged when the bot-side // registry (GET /api/notifications/alerts) returns successfully. On 404 or // network failure the fallbacks remain authoritative. let activeTabKeys = FALLBACK_TAB_KEYS; let activeAlertDescriptions = FALLBACK_ALERT_DESCRIPTIONS; async function fetchAlertRegistry() { try { const res = await fetch('/api/notifications/alerts', { credentials: 'same-origin' }); if (!res.ok) return null; const data = await res.json(); if (!data || typeof data !== 'object' || Array.isArray(data)) return null; // Accept only if at least one known category is a non-empty array const hasShape = ['surge', 'patterns', 'unclaimed'].some( cat => Array.isArray(data[cat]) && data[cat].length > 0 ); return hasShape ? data : null; } catch (_) { return null; } } async function fetchEnableState() { try { const res = await fetch('/api/notifications/state', { credentials: 'same-origin' }); if (!res.ok) return null; const data = await res.json(); if (!data || typeof data !== 'object' || Array.isArray(data)) return null; if (typeof data.master !== 'boolean') return null; if (!data.perKey || typeof data.perKey !== 'object') return null; return { master: data.master, perKey: { ...data.perKey } }; } catch (_) { return null; } } async function postToggle(body) { const res = await fetch('/api/notifications/toggle', { method: 'POST', credentials: 'same-origin', headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) }); if (!res.ok) throw new Error(`toggle ${res.status}`); const data = await res.json(); if (!data || !data.state || typeof data.state.master !== 'boolean' || !data.state.perKey) { throw new Error('bad toggle response'); } return { master: data.state.master, perKey: { ...data.state.perKey } }; } // Merge bot registry with fallback, preserving fallback order for existing // keys (so rapid_t2_t3 and chat keys stay where the UI expects them). // Registry-only keys get appended to their category; registry descriptions // override fallback text. function mergeRegistryWithFallback(registry) { const tabKeys = {}; const alertDescriptions = { ...FALLBACK_ALERT_DESCRIPTIONS }; Object.keys(FALLBACK_TAB_KEYS).forEach(cat => { tabKeys[cat] = [...FALLBACK_TAB_KEYS[cat]]; }); Object.entries(registry).forEach(([category, entries]) => { if (!Array.isArray(entries)) return; if (!tabKeys[category]) tabKeys[category] = []; const seen = new Set(tabKeys[category]); for (const e of entries) { if (!e || typeof e.key !== 'string') continue; if (!seen.has(e.key)) { tabKeys[category].push(e.key); seen.add(e.key); } if (typeof e.description === 'string') { alertDescriptions[e.key] = e.description; } } }); return { tabKeys, alertDescriptions }; } function applyMergedRegistry(section, registry) { const merged = mergeRegistryWithFallback(registry); activeTabKeys = merged.tabKeys; activeAlertDescriptions = merged.alertDescriptions; window.Notifications.registry = registry; Object.entries(activeTabKeys).forEach(([category, keys]) => { const select = section.querySelector(`[data-notif-category="${category}"]`); if (!select) return; const existing = new Set(Array.from(select.options).map(o => o.value)); keys.forEach(key => { if (!existing.has(key)) { const option = document.createElement('option'); option.value = key; option.textContent = toHumanLabel(key); select.appendChild(option); } }); renderAlertDescription(category); }); } function initNotificationsEditor(config) { const section = document.getElementById('s-notifications'); if (!section) return; const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]'); if (!hiddenField) return; notificationThresholdsState = parseNotificationThresholdsConfig(config); hiddenField.value = serializeNotificationThresholds(notificationThresholdsState); section.querySelectorAll('.notif-tab-btn').forEach(btn => { btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab)); }); Object.entries(activeTabKeys).forEach(([category, keys]) => { const select = section.querySelector(`[data-notif-category="${category}"]`); const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`); const input = section.querySelector(`[data-notif-input="${category}"]`); const addBtn = section.querySelector(`[data-notif-add="${category}"]`); const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`); if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return; keys.forEach(key => { const option = document.createElement('option'); option.value = key; option.textContent = toHumanLabel(key); select.appendChild(option); }); if (keys.length) select.value = keys[0]; select.addEventListener('change', () => { renderThresholdChips(category); renderAlertDescription(category); renderPerAlertToggle(category); }); addBtn.addEventListener('click', () => addThresholdFromInput(category)); input.addEventListener('keydown', (evt) => { if (evt.key === 'Enter') { evt.preventDefault(); addThresholdFromInput(category); } }); NOTIFICATION_PRESETS.forEach(preset => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = preset; btn.addEventListener('click', () => addThresholdValue(category, preset)); presetsWrap.appendChild(btn); }); renderThresholdChips(category); renderAlertDescription(category); }); setNotificationTab('surge'); wireEnableToggles(section); // Background: pull canonical registry from the bot, merge with fallback, // append any registry-only keys and refresh descriptions. Fallback stays // in use if the endpoint 404s (settings-site deployed ahead of bot) or // the fetch fails (network/proxy error). fetchAlertRegistry().then(registry => { if (!registry) return; applyMergedRegistry(section, registry); renderAllEnableUI(section); }).catch(() => {}); // Phase 9: pull enable state and paint the toggles. Runs in parallel with // the registry fetch above; renderAll is idempotent so ordering is OK. fetchEnableState().then(state => { if (!state) return; enableState = state; window.Notifications.state = state; renderAllEnableUI(section); }).catch(() => {}); } function keysForCategory(category) { return activeTabKeys[category] || []; } function renderMasterCheckboxes(section) { section.querySelectorAll('[data-notif-master]').forEach(cb => { cb.checked = enableState.master === true; }); } function renderCategoryCheckbox(section, category) { const cb = section.querySelector(`[data-notif-category-toggle="${category}"]`); if (!cb) return; const keys = keysForCategory(category); const allOn = keys.length > 0 && keys.every(k => enableState.perKey[k] === true); cb.checked = enableState.master === true && allOn; } function renderPerAlertToggle(category) { const section = document.getElementById('s-notifications'); if (!section) return; const panel = section.querySelector(`[data-notif-panel="${category}"]`); if (!panel) return; const alertCb = panel.querySelector('[data-notif-alert]'); const label = panel.querySelector('[data-notif-alert-label]'); const alertKey = getSelectedAlertKey(category); const on = alertKey ? enableState.perKey[alertKey] === true : false; if (alertCb) alertCb.checked = on; if (label) label.textContent = on ? 'Alert enabled' : 'Alert disabled'; // Disable the chip/input editor when master is off or this alert is off. const editor = panel.querySelector('.notif-editor'); if (editor) { const disabled = !enableState.master || !on; editor.classList.toggle('notif-disabled', disabled); } } function renderAllEnableUI(section) { renderMasterCheckboxes(section); Object.keys(activeTabKeys).forEach(cat => renderCategoryCheckbox(section, cat)); Object.keys(activeTabKeys).forEach(cat => renderPerAlertToggle(cat)); } function wireEnableToggles(section) { // Master — multiple DOM copies; they share state via enableState and // re-render after each mutation, so clicking any one updates all. section.querySelectorAll('[data-notif-master]').forEach(cb => { cb.addEventListener('change', async () => { const prev = enableState.master; const next = cb.checked; try { const newState = await postToggle({ master: true, enabled: next }); enableState = newState; window.Notifications.state = newState; renderAllEnableUI(section); } catch (e) { cb.checked = prev; renderAllEnableUI(section); Util.showToast('Failed to update master toggle.', 'error'); } }); }); // Per-category "All in category" toggles. section.querySelectorAll('[data-notif-category-toggle]').forEach(cb => { const category = cb.dataset.notifCategoryToggle; cb.addEventListener('change', async () => { const next = cb.checked; const prev = !next; // pre-toggle state, for revert on failure try { const newState = await postToggle({ category, enabled: next }); enableState = newState; window.Notifications.state = newState; renderAllEnableUI(section); } catch (e) { cb.checked = prev; renderAllEnableUI(section); Util.showToast(`Failed to update ${category} category toggle.`, 'error'); } }); }); // Per-alert toggles — one per category panel, scoped to the currently // selected alertKey in that panel's dropdown. section.querySelectorAll('.notif-panel').forEach(panel => { const category = panel.dataset.notifPanel; const alertCb = panel.querySelector('[data-notif-alert]'); if (!alertCb) return; alertCb.addEventListener('change', async () => { const alertKey = getSelectedAlertKey(category); if (!alertKey) { alertCb.checked = false; return; } const prev = enableState.perKey[alertKey] === true; const next = alertCb.checked; try { const newState = await postToggle({ key: alertKey, enabled: next }); enableState = newState; window.Notifications.state = newState; renderAllEnableUI(section); } catch (e) { alertCb.checked = prev; renderAllEnableUI(section); Util.showToast(`Failed to update ${alertKey} toggle.`, 'error'); } }); }); } function parseNotificationThresholdsConfig(config) { const rawJson = config.NOTIFICATION_THRESHOLDS_JSON; if (rawJson && String(rawJson).trim()) { try { const parsed = JSON.parse(rawJson); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} } if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) { return config.NOTIFICATION_THRESHOLDS; } return {}; } function serializeNotificationThresholds(obj) { const ordered = {}; Object.keys(obj).sort().forEach(key => { const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : []; ordered[key] = arr; }); return JSON.stringify(ordered); } function setNotificationTab(category) { document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.notifTab === category); }); document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => { panel.classList.toggle('hidden', panel.dataset.notifPanel !== category); }); } function addThresholdFromInput(category) { const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`); if (!input) return; const value = input.value.trim().toLowerCase(); if (addThresholdValue(category, value)) input.value = ''; } function addThresholdValue(category, rawValue) { const value = String(rawValue || '').trim().toLowerCase(); if (!isValidThresholdValue(value)) { Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error'); return false; } const alertKey = getSelectedAlertKey(category); if (!alertKey) return false; const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : []; if (current.includes(value)) return false; current.push(value); notificationThresholdsState[alertKey] = current; syncNotificationThresholdsField(); renderThresholdChips(category); return true; } function removeThresholdValue(category, valueToRemove) { const alertKey = getSelectedAlertKey(category); if (!alertKey) return; const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : []; notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove)); syncNotificationThresholdsField(); renderThresholdChips(category); } function renderThresholdChips(category) { const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`); if (!chipsWrap) return; const alertKey = getSelectedAlertKey(category); const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey]) ? notificationThresholdsState[alertKey] : []; chipsWrap.replaceChildren(); thresholds.forEach(value => { const chip = document.createElement('span'); chip.className = 'notif-chip'; chip.textContent = value; const remove = document.createElement('button'); remove.type = 'button'; remove.title = `Remove ${value}`; remove.textContent = '×'; remove.addEventListener('click', () => removeThresholdValue(category, value)); chip.appendChild(remove); chipsWrap.appendChild(chip); }); } function renderAlertDescription(category) { const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`); if (!descriptionEl) return; const alertKey = getSelectedAlertKey(category); descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.'; } function syncNotificationThresholdsField() { const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]'); if (!hiddenField) return; const serialized = serializeNotificationThresholds(notificationThresholdsState); hiddenField.value = serialized; Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized); hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON')); } function getSelectedAlertKey(category) { const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`); return select ? select.value : ''; } function isValidThresholdValue(value) { if (!value) return false; if (/^\d+$/.test(value)) return true; return /^(\d+[mhd])+$/.test(value); } function toHumanLabel(key) { return String(key) .split('_') .map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3' ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } window.Notifications = { initNotificationsEditor, isValidThresholdValue, toHumanLabel, fetchAlertRegistry, fetchEnableState, NOTIFICATION_PRESETS, FALLBACK_TAB_KEYS, FALLBACK_ALERT_DESCRIPTIONS, registry: null, state: enableState, get tabKeys() { return activeTabKeys; }, get alertDescriptions() { return activeAlertDescriptions; } }; })();