From 0f62fb902054bc8da366a2c13c155996be0b2fba Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 18 Apr 2026 19:14:51 +0000 Subject: [PATCH] phase 5 dynamic alert registry (bot canonical, settings-site with fallback) --- routes/internalApi.js | 6 + services/notificationRegistry.js | 201 +++++++++++++++++++++++ services/patternChecker.js | 16 ++ services/staffNotifications.js | 5 + services/surgeChecker.js | 15 ++ settings-site/public/js/notifications.js | 96 ++++++++++- settings-site/server.js | 1 + 7 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 services/notificationRegistry.js diff --git a/routes/internalApi.js b/routes/internalApi.js index 53e43a7..fda8f36 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -4,6 +4,7 @@ const { ChannelType } = require('discord.js'); const { CONFIG } = require('../config'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { logSystem } = require('../services/debugLog'); +const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); const router = express.Router(); @@ -209,4 +210,9 @@ router.get('/restart/status', (req, res) => { res.json({ scheduledRestart: !!scheduledRestart }); }); +// GET /notifications/alerts — canonical bot-side notification alert catalog +router.get('/notifications/alerts', (req, res) => { + res.json(NOTIFICATION_REGISTRY); +}); + module.exports = router; diff --git a/services/notificationRegistry.js b/services/notificationRegistry.js new file mode 100644 index 0000000..3004ea5 --- /dev/null +++ b/services/notificationRegistry.js @@ -0,0 +1,201 @@ +/** + * Canonical notification alert registry. + * + * Single source of truth for the 30 standard-threshold-driven alert keys used + * across surgeChecker, patternChecker, and staffNotifications. Consumed by: + * - the three checker services (startup drift-check) + * - routes/internalApi.js GET /notifications/alerts + * - settings-site UI (via proxied /api/notifications/alerts, with fallback) + * + * Not covered here (intentionally fallback-only in the UI): + * - rapid_t2_t3 — uses count-milestone firing, not shouldFire() + * - chat_messages/time — owned by chatAlertChecker.js, out of Phase 5 scope + * + * `windowType` is the reset window used by shouldFire() for pattern keys + * (today/week/month). For surge and unclaimed, firing is cooldown-escalating + * rather than window-based, so windowType is null. + */ + +const REGISTRY = Object.freeze({ + surge: Object.freeze([ + Object.freeze({ + key: 'surge_tickets', + description: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.', + windowType: null + }), + Object.freeze({ + key: 'surge_game', + description: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.', + windowType: null + }), + Object.freeze({ + key: 'surge_stale', + description: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.', + windowType: null + }), + Object.freeze({ + key: 'surge_needs_response', + description: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.', + windowType: null + }), + Object.freeze({ + key: 'surge_unclaimed', + description: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.', + windowType: null + }), + Object.freeze({ + key: 'surge_tier3_unclaimed', + description: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.", + windowType: null + }), + Object.freeze({ + key: 'surge_no_staff', + description: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.', + windowType: null + }) + ]), + + patterns: Object.freeze([ + Object.freeze({ + key: 'user_tickets', + description: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.', + windowType: 'today' + }), + Object.freeze({ + key: 'user_reopen', + description: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.', + windowType: 'week' + }), + Object.freeze({ + key: 'user_crossgame', + description: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.', + windowType: 'week' + }), + Object.freeze({ + key: 'game_surge', + description: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.', + windowType: 'today' + }), + Object.freeze({ + key: 'game_backlog', + description: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.', + windowType: 'today' + }), + Object.freeze({ + key: 'game_resolution', + description: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.', + windowType: 'week' + }), + Object.freeze({ + key: 'game_spike', + description: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.', + windowType: 'today' + }), + Object.freeze({ + key: 'tag_top', + description: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.', + windowType: 'today' + }), + Object.freeze({ + key: 'tag_escalation', + description: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.', + windowType: 'week' + }), + Object.freeze({ + key: 'untagged_closes', + description: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.', + windowType: 'today' + }), + Object.freeze({ + key: 'tag_game_corr', + description: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.', + windowType: 'week' + }), + Object.freeze({ + key: 'user_esc', + description: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.', + windowType: 'week' + }), + Object.freeze({ + key: 'game_esc_rate', + description: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.', + windowType: 'week' + }), + Object.freeze({ + key: 'staff_no_close', + description: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.', + windowType: 'today' + }), + Object.freeze({ + key: 'staff_overloaded', + description: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.', + windowType: 'today' + }), + Object.freeze({ + key: 'staff_stale', + description: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.', + windowType: 'today' + }), + Object.freeze({ + key: 'staff_transfer_rate', + description: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.', + windowType: 'today' + }), + Object.freeze({ + key: 'staff_esc', + description: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.', + windowType: 'week' + }), + Object.freeze({ + key: 'staff_game_esc', + description: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.', + windowType: 'week' + }), + Object.freeze({ + key: 'game_tag_spike', + description: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.', + windowType: 'today' + }), + Object.freeze({ + key: 'overnight_gap', + description: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.', + windowType: 'week' + }), + Object.freeze({ + key: 'staff_always_esc', + description: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.', + windowType: 'month' + }) + ]), + + unclaimed: Object.freeze([ + Object.freeze({ + key: 'unclaimed_reminder', + description: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.', + windowType: null + }) + ]) +}); + +const ALL_KEYS = Object.freeze([ + ...REGISTRY.surge.map(e => e.key), + ...REGISTRY.patterns.map(e => e.key), + ...REGISTRY.unclaimed.map(e => e.key) +]); + +const ALL_KEYS_SET = new Set(ALL_KEYS); + +/** + * Throws if any of `keys` is not in the registry. Call at module load from + * each checker that references registry keys so drift fails fast. + */ +function assertKeysRegistered(moduleName, keys) { + const missing = keys.filter(k => !ALL_KEYS_SET.has(k)); + if (missing.length > 0) { + throw new Error( + `[notificationRegistry] ${moduleName} references keys not in REGISTRY: ${missing.join(', ')}` + ); + } +} + +module.exports = { REGISTRY, ALL_KEYS, assertKeysRegistered }; diff --git a/services/patternChecker.js b/services/patternChecker.js index 4a70085..725162d 100644 --- a/services/patternChecker.js +++ b/services/patternChecker.js @@ -7,6 +7,22 @@ const { CONFIG, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); +const { assertKeysRegistered } = require('./notificationRegistry'); + +// Alert keys this module fires via shouldFire()/standard threshold path. +// rapid_t2_t3 is intentionally excluded — it uses count-milestone firing below +// via firedCountMilestones, not the shouldFire() pipeline, so it is not part +// of the notification registry. +const PATTERN_ALERT_KEYS = [ + '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', + 'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate', + 'staff_esc', 'staff_game_esc', + 'game_tag_spike', 'overnight_gap', 'staff_always_esc' +]; +assertKeysRegistered('patternChecker', PATTERN_ALERT_KEYS); const Ticket = mongoose.model('Ticket'); diff --git a/services/staffNotifications.js b/services/staffNotifications.js index d4e1c00..d59fa17 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -12,6 +12,11 @@ const { mongoose } = require('../db-connection'); const { CONFIG, parseThresholdString } = require('../config'); const { increment } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); +const { assertKeysRegistered } = require('./notificationRegistry'); + +// Alert key this module drives. Registered to fail fast on drift. +const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder']; +assertKeysRegistered('staffNotifications', UNCLAIMED_ALERT_KEYS); const Ticket = mongoose.model('Ticket'); const StaffNotification = mongoose.model('StaffNotification'); diff --git a/services/surgeChecker.js b/services/surgeChecker.js index 73e6774..7ada53d 100644 --- a/services/surgeChecker.js +++ b/services/surgeChecker.js @@ -8,6 +8,21 @@ const { mongoose } = require('../db-connection'); const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore'); const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); const { enqueueSend } = require('./channelQueue'); +const { assertKeysRegistered } = require('./notificationRegistry'); + +// Alert keys this module drives. Asserted against the registry at load so any +// future drift (rename, typo, unregistered key) fails fast rather than +// silently breaking the settings-site config editor. +const SURGE_ALERT_KEYS = [ + 'surge_tickets', + 'surge_game', + 'surge_stale', + 'surge_needs_response', + 'surge_unclaimed', + 'surge_tier3_unclaimed', + 'surge_no_staff' +]; +assertKeysRegistered('surgeChecker', SURGE_ALERT_KEYS); const Ticket = mongoose.model('Ticket'); diff --git a/settings-site/public/js/notifications.js b/settings-site/public/js/notifications.js index 90765fd..ef4cac0 100644 --- a/settings-site/public/js/notifications.js +++ b/settings-site/public/js/notifications.js @@ -3,7 +3,7 @@ const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d']; - const NOTIFICATION_TAB_KEYS = { + const FALLBACK_TAB_KEYS = { surge: [ 'surge_tickets', 'surge_game', @@ -42,7 +42,7 @@ chat: ['chat_messages', 'chat_time'] }; - const NOTIFICATION_ALERT_DESCRIPTIONS = { + 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.', @@ -80,6 +80,77 @@ let notificationThresholdsState = {}; + // 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; + } + } + + // 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; @@ -94,7 +165,7 @@ btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab)); }); - Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => { + 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}"]`); @@ -136,6 +207,15 @@ }); setNotificationTab('surge'); + + // 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); + }).catch(() => {}); } function parseNotificationThresholdsConfig(config) { @@ -234,7 +314,7 @@ const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`); if (!descriptionEl) return; const alertKey = getSelectedAlertKey(category); - descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.'; + descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.'; } function syncNotificationThresholdsField() { @@ -270,8 +350,12 @@ initNotificationsEditor, isValidThresholdValue, toHumanLabel, + fetchAlertRegistry, NOTIFICATION_PRESETS, - NOTIFICATION_TAB_KEYS, - NOTIFICATION_ALERT_DESCRIPTIONS + FALLBACK_TAB_KEYS, + FALLBACK_ALERT_DESCRIPTIONS, + registry: null, + get tabKeys() { return activeTabKeys; }, + get alertDescriptions() { return activeAlertDescriptions; } }; })(); diff --git a/settings-site/server.js b/settings-site/server.js index 3309ef5..78718b5 100644 --- a/settings-site/server.js +++ b/settings-site/server.js @@ -182,6 +182,7 @@ app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config')); app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild')); app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart')); app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status')); +app.get('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts')); app.get('/*splat', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html'));