diff --git a/routes/internalApi.js b/routes/internalApi.js index 8c5a9f6..c2991e2 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -6,6 +6,12 @@ const { applyConfigUpdates, readAllConfig } = require('../services/configPersist const { logSystem } = require('../services/debugLog'); const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); +const { + getAllState: getNotificationState, + setKeyEnabled, + setCategoryEnabled, + setMasterEnabled +} = require('../services/notificationEnabled'); const router = express.Router(); @@ -161,6 +167,62 @@ router.get('/notifications/alerts', (req, res) => { res.json(NOTIFICATION_REGISTRY); }); +// GET /notifications/state — Phase 9: master flag + per-key enable map +router.get('/notifications/state', (req, res) => { + res.json(getNotificationState()); +}); + +// POST /notifications/toggle — Phase 9: mutate one of {master, category, key} +// +// Body shapes (exactly one of these must be used): +// { master: true, enabled: } +// { category: , enabled: } +// { key: , enabled: } +// +// Mutates CONFIG in memory via notificationEnabled, then persists through +// applyConfigUpdates so the value passes schema validation and ends up in .env. +router.post('/notifications/toggle', express.json(), async (req, res) => { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).json({ error: 'Invalid body' }); + } + if (typeof body.enabled !== 'boolean') { + return res.status(400).json({ error: '`enabled` must be boolean' }); + } + + const hasMaster = Object.prototype.hasOwnProperty.call(body, 'master'); + const hasCategory = Object.prototype.hasOwnProperty.call(body, 'category'); + const hasKey = Object.prototype.hasOwnProperty.call(body, 'key'); + const specifiedCount = Number(hasMaster) + Number(hasCategory) + Number(hasKey); + if (specifiedCount !== 1) { + return res.status(400).json({ error: 'Specify exactly one of: master, category, key' }); + } + + let updates; + if (hasMaster) { + setMasterEnabled(body.enabled); + updates = { NOTIFICATIONS_MASTER_ENABLED: body.enabled }; + } else if (hasCategory) { + if (typeof body.category !== 'string' || !Object.prototype.hasOwnProperty.call(NOTIFICATION_REGISTRY, body.category)) { + return res.status(400).json({ error: 'Unknown category' }); + } + const newJson = setCategoryEnabled(body.category, body.enabled); + updates = { NOTIFICATION_ENABLED_JSON: newJson }; + } else { + if (typeof body.key !== 'string' || !body.key) { + return res.status(400).json({ error: '`key` must be a non-empty string' }); + } + const newJson = setKeyEnabled(body.key, body.enabled); + updates = { NOTIFICATION_ENABLED_JSON: newJson }; + } + + const result = applyConfigUpdates(updates); + if (result.errors.length > 0) { + return res.status(500).json({ error: 'Persistence failed', details: result.errors }); + } + res.json({ state: getNotificationState() }); +}); + // Expose the allowlist for the Phase 8 schema smoke test. Attached to the // router function object; doesn't show up as a route. router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS); diff --git a/services/chatAlertChecker.js b/services/chatAlertChecker.js index 0f34c13..632fd2f 100644 --- a/services/chatAlertChecker.js +++ b/services/chatAlertChecker.js @@ -6,6 +6,11 @@ const { EmbedBuilder } = require('discord.js'); const { CONFIG, parseThresholdString } = require('../config'); const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); +const { assertKeysRegistered } = require('./notificationRegistry'); +const { isEnabled } = require('./notificationEnabled'); + +const CHAT_ALERT_KEYS = ['chat_messages', 'chat_time']; +assertKeysRegistered('chatAlertChecker', CHAT_ALERT_KEYS); // channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt } const chatState = new Map(); @@ -54,7 +59,7 @@ async function runChatAlertChecks(client) { for (const [channelId, state] of chatState) { // Message count threshold - if (state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) { + if (isEnabled('chat_messages') && state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) { const cooldownKey = `chat:messages:${channelId}`; if (shouldFireCooldownEscalating(cooldownKey, chatMessageThresholdsMs) !== null) { const embed = new EmbedBuilder() @@ -72,7 +77,7 @@ async function runChatAlertChecks(client) { // Time threshold const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000; - if (hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) { + if (isEnabled('chat_time') && hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) { const cooldownKey = `chat:time:${channelId}`; if (shouldFireCooldownEscalating(cooldownKey, chatTimeThresholdsMs) !== null) { const embed = new EmbedBuilder() diff --git a/services/configSchema.js b/services/configSchema.js index d9a7b12..64a718f 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -75,7 +75,9 @@ const ALLOWED_CONFIG_KEYS = new Set([ 'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT', 'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES', // Notification thresholds - 'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS' + 'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS', + // Notification enable state (Phase 9) + 'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED' ]); // ---------- Regex primitives ---------- @@ -206,6 +208,8 @@ const VALIDATORS = { function inferType(key) { // 1. Explicit overrides if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json'; + if (key === 'NOTIFICATION_ENABLED_JSON') return 'json'; + if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean'; if (key === 'LOGO_URL') return 'url'; if (/_EMAIL$/.test(key)) return 'email'; if (key.includes('COLOR')) return 'hex_color'; diff --git a/services/notificationEnabled.js b/services/notificationEnabled.js new file mode 100644 index 0000000..42218a9 --- /dev/null +++ b/services/notificationEnabled.js @@ -0,0 +1,102 @@ +/** + * Canonical enable/disable state accessor for per-alert notifications. + * + * State lives in two CONFIG keys: + * - NOTIFICATIONS_MASTER_ENABLED (boolean) — global kill switch + * - NOTIFICATION_ENABLED_JSON (JSON string → flat { [key]: boolean }) + * + * Defaults: master off, every key off. Unknown keys in the JSON are ignored + * on read (registry is the source of truth); keys missing from the JSON are + * treated as false. Master off short-circuits every read — isEnabled never + * returns true when master is off, so checkers bail without logs or metrics. + * + * Setters mutate CONFIG in memory and return the new value so the caller can + * persist it via configPersistence.applyConfigUpdates. .env writes happen + * there so schema validation and partial-success semantics stay consistent. + */ +'use strict'; + +const { CONFIG } = require('../config'); +const { REGISTRY } = require('./notificationRegistry'); + +function parseState() { + const raw = CONFIG.NOTIFICATION_ENABLED_JSON; + if (raw === undefined || raw === null || raw === '') return {}; + if (typeof raw === 'object' && !Array.isArray(raw)) return raw; + try { + const parsed = JSON.parse(String(raw)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch (_) {} + return {}; +} + +function isMasterOn() { + const v = CONFIG.NOTIFICATIONS_MASTER_ENABLED; + return v === true || v === 'true'; +} + +function isEnabled(alertKey) { + if (!isMasterOn()) return false; + const state = parseState(); + return state[alertKey] === true; +} + +function isCategoryEnabled(category) { + if (!isMasterOn()) return false; + const entries = REGISTRY[category]; + if (!Array.isArray(entries) || entries.length === 0) return false; + const state = parseState(); + return entries.every(e => state[e.key] === true); +} + +function getAllState() { + const state = parseState(); + const perKey = {}; + for (const entries of Object.values(REGISTRY)) { + if (!Array.isArray(entries)) continue; + for (const e of entries) { + perKey[e.key] = state[e.key] === true; + } + } + return { master: isMasterOn(), perKey }; +} + +function serialize(state) { + const ordered = {}; + Object.keys(state).sort().forEach(k => { ordered[k] = state[k] === true; }); + return JSON.stringify(ordered); +} + +function setKeyEnabled(key, enabled) { + const state = parseState(); + state[String(key)] = enabled === true; + const json = serialize(state); + CONFIG.NOTIFICATION_ENABLED_JSON = json; + return json; +} + +function setCategoryEnabled(category, enabled) { + const state = parseState(); + const entries = REGISTRY[category]; + if (Array.isArray(entries)) { + for (const e of entries) state[e.key] = enabled === true; + } + const json = serialize(state); + CONFIG.NOTIFICATION_ENABLED_JSON = json; + return json; +} + +function setMasterEnabled(enabled) { + const value = enabled === true; + CONFIG.NOTIFICATIONS_MASTER_ENABLED = value; + return value; +} + +module.exports = { + isEnabled, + isCategoryEnabled, + getAllState, + setKeyEnabled, + setCategoryEnabled, + setMasterEnabled +}; diff --git a/services/notificationRegistry.js b/services/notificationRegistry.js index 3004ea5..71ec650 100644 --- a/services/notificationRegistry.js +++ b/services/notificationRegistry.js @@ -1,19 +1,18 @@ /** * 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) + * Single source of truth for the 32 registered alert keys across surgeChecker, + * patternChecker, staffNotifications, and chatAlertChecker. Consumed by: + * - the checker services (startup drift-check, Phase 9 enable gating) * - 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 + * - rapid_t2_t3 — uses count-milestone firing, not shouldFire() * * `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. + * (today/week/month). For surge, unclaimed, and chat, firing is + * cooldown-escalating rather than window-based, so windowType is null. */ const REGISTRY = Object.freeze({ @@ -174,13 +173,27 @@ const REGISTRY = Object.freeze({ 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 }) + ]), + + chat: Object.freeze([ + Object.freeze({ + key: 'chat_messages', + description: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.', + windowType: null + }), + Object.freeze({ + key: 'chat_time', + description: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.', + 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) + ...REGISTRY.unclaimed.map(e => e.key), + ...REGISTRY.chat.map(e => e.key) ]); const ALL_KEYS_SET = new Set(ALL_KEYS); diff --git a/services/patternChecker.js b/services/patternChecker.js index 725162d..27b31ba 100644 --- a/services/patternChecker.js +++ b/services/patternChecker.js @@ -8,6 +8,7 @@ const { mongoose } = require('../db-connection'); const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); const { assertKeysRegistered } = require('./notificationRegistry'); +const { isEnabled } = require('./notificationEnabled'); // Alert keys this module fires via shouldFire()/standard threshold path. // rapid_t2_t3 is intentionally excluded — it uses count-milestone firing below @@ -94,7 +95,7 @@ async function checkUserPatterns(client) { for (const [userId, count] of todayCounts) { if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) { const key = `user_tickets:${userId}:today`; - if (shouldFire('user_tickets', key, 'today')) { + if (isEnabled('user_tickets') && shouldFire('user_tickets', key, 'today')) { postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( 'Repeat ticket user', `User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`, @@ -114,7 +115,7 @@ async function checkUserPatterns(client) { ]); for (const r of reopens) { const key = `user_reopen:${r._id}:week`; - if (shouldFire('user_reopen', key, 'week')) { + if (isEnabled('user_reopen') && shouldFire('user_reopen', key, 'week')) { postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( 'High reopen rate', `${r._id} reopened tickets ${r.count}x this week`, @@ -133,7 +134,7 @@ async function checkUserPatterns(client) { ]); for (const c of crossGame) { const key = `user_crossgame:${c._id}:week`; - if (shouldFire('user_crossgame', key, 'week')) { + if (isEnabled('user_crossgame') && shouldFire('user_crossgame', key, 'week')) { postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( 'Cross-game user', `${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`, @@ -150,7 +151,7 @@ async function checkGamePatterns(client) { for (const [game, count] of todayCounts) { if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) { const key = `game_surge:${game}:today`; - if (shouldFire('game_surge', key, 'today')) { + if (isEnabled('game_surge') && shouldFire('game_surge', key, 'today')) { postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( 'Game ticket surge', `**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`, @@ -171,7 +172,7 @@ async function checkGamePatterns(client) { for (const b of backlog) { const gameName = b._id || 'Unknown'; const key = `game_backlog:${gameName}:today`; - if (shouldFire('game_backlog', key, 'today')) { + if (isEnabled('game_backlog') && shouldFire('game_backlog', key, 'today')) { postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( 'Game backlog alert', `**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`, @@ -198,7 +199,7 @@ async function checkGamePatterns(client) { const lw = lastWeekMap.get(tw._id); if (lw && tw.avg > lw * 1.2) { const key = `game_resolution:${tw._id}:week`; - if (shouldFire('game_resolution', key, 'week')) { + if (isEnabled('game_resolution') && shouldFire('game_resolution', key, 'week')) { const twHrs = (tw.avg / 3600000).toFixed(1); const lwHrs = (lw / 3600000).toFixed(1); postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( @@ -223,7 +224,7 @@ async function checkGamePatterns(client) { for (const [game, count] of todayCounts) { if (count >= 3 && !recentGames.has(game)) { const key = `game_spike:${game}:today`; - if (shouldFire('game_spike', key, 'today')) { + if (isEnabled('game_spike') && shouldFire('game_spike', key, 'today')) { postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( 'Possible outage', `**${game}**: ${count} tickets today after 0 in the last 3 days.`, @@ -244,7 +245,7 @@ async function checkTagPatterns(client) { } if (topTag && topCount >= 5) { const key = `tag_top:${topTag}:today`; - if (shouldFire('tag_top', key, 'today')) { + if (isEnabled('tag_top') && shouldFire('tag_top', key, 'today')) { postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( 'Top issue tag today', `**${topTag}** used ${topCount} times today.`, @@ -263,7 +264,7 @@ async function checkTagPatterns(client) { ]); for (const te of tagEscalations) { const key = `tag_escalation:${te._id}:week`; - if (shouldFire('tag_escalation', key, 'week')) { + if (isEnabled('tag_escalation') && shouldFire('tag_escalation', key, 'week')) { postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( 'Tag frequently leads to escalation', `**${te._id}**: ${te.count} escalated tickets this week.`, @@ -277,7 +278,7 @@ async function checkTagPatterns(client) { const untaggedCount = get('untagged_closes', 'total', 'today'); if (untaggedCount >= 5) { const key = 'untagged_closes:today'; - if (shouldFire('untagged_closes', key, 'today')) { + if (isEnabled('untagged_closes') && shouldFire('untagged_closes', key, 'today')) { postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( 'High untagged close rate', `${untaggedCount} tickets closed today without a tag.`, @@ -297,7 +298,7 @@ async function checkTagPatterns(client) { } if (total >= 5 && maxGame && maxCount / total > 0.8) { const key = `tag_game_corr:${tag}:${maxGame}:week`; - if (shouldFire('tag_game_corr', key, 'week')) { + if (isEnabled('tag_game_corr') && shouldFire('tag_game_corr', key, 'week')) { postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( 'Auto-tagging opportunity', `**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`, @@ -314,7 +315,7 @@ async function checkEscalationPatterns(client) { for (const [user, count] of userEscalations) { if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) { const key = `user_esc:${user}:week`; - if (shouldFire('user_esc', key, 'week')) { + if (isEnabled('user_esc') && shouldFire('user_esc', key, 'week')) { postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( 'Frequent escalation user', `\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`, @@ -337,7 +338,7 @@ async function checkEscalationPatterns(client) { const gameTotal = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart }, game: tw._id }); if (gameTotal > 0 && tw.count / gameTotal > 0.5) { const key = `game_esc_rate:${tw._id}:week`; - if (shouldFire('game_esc_rate', key, 'week')) { + if (isEnabled('game_esc_rate') && shouldFire('game_esc_rate', key, 'week')) { postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( 'High escalation rate for game', `**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`, @@ -349,6 +350,7 @@ async function checkEscalationPatterns(client) { } catch (_) {} // Rapid tier 2→3 + if (!isEnabled('rapid_t2_t3')) return; try { const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const rapid = await Ticket.find({ @@ -391,7 +393,7 @@ async function checkStaffPatterns(client) { for (const [staffId, claims] of todayClaims) { if (claims >= 3 && get('staff_closes', staffId, 'today') === 0) { const key = `staff_no_close:${staffId}:today`; - if (shouldFire('staff_no_close', key, 'today')) { + if (isEnabled('staff_no_close') && shouldFire('staff_no_close', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Claims without closes', `Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`, @@ -410,7 +412,7 @@ async function checkStaffPatterns(client) { ]); for (const o of overloaded) { const key = `staff_overloaded:${o._id}:today`; - if (shouldFire('staff_overloaded', key, 'today')) { + if (isEnabled('staff_overloaded') && shouldFire('staff_overloaded', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff overloaded', `Staff \`${o._id}\` has ${o.count} open claimed tickets.`, @@ -425,7 +427,7 @@ async function checkStaffPatterns(client) { for (const [staffId, count] of stalePings) { if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) { const key = `staff_stale:${staffId}:today`; - if (shouldFire('staff_stale', key, 'today')) { + if (isEnabled('staff_stale') && shouldFire('staff_stale', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff stale ping threshold', `Staff \`${staffId}\` received ${count} stale pings today.`, @@ -441,7 +443,7 @@ async function checkStaffPatterns(client) { const claims = get('staff_claims', staffId, 'today'); if (claims > 0 && transfers >= claims) { const key = `staff_transfer_rate:${staffId}:today`; - if (shouldFire('staff_transfer_rate', key, 'today')) { + if (isEnabled('staff_transfer_rate') && shouldFire('staff_transfer_rate', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'High transfer rate', `Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`, @@ -456,7 +458,7 @@ async function checkStaffPatterns(client) { for (const [staffId, count] of weekEscalations) { if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) { const key = `staff_esc:${staffId}:week`; - if (shouldFire('staff_esc', key, 'week')) { + if (isEnabled('staff_esc') && shouldFire('staff_esc', key, 'week')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff frequent escalator', `Staff \`${staffId}\` escalated ${count} tickets this week.`, @@ -475,7 +477,7 @@ async function checkCombinedPatterns(client) { for (const [game, count] of gameEsc) { if (count >= 3) { const key = `staff_game_esc:${staffId}:${game}:week`; - if (shouldFire('staff_game_esc', key, 'week')) { + if (isEnabled('staff_game_esc') && shouldFire('staff_game_esc', key, 'week')) { postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff may need training for this game', `Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`, @@ -494,7 +496,7 @@ async function checkCombinedPatterns(client) { const tagGameCount = get(`tag_game:${tag}`, game, 'week'); if (tagGameCount >= 5) { const key = `game_tag_spike:${game}:${tag}:today`; - if (shouldFire('game_tag_spike', key, 'today')) { + if (isEnabled('game_tag_spike') && shouldFire('game_tag_spike', key, 'today')) { postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( 'Specific feature of specific game spiking', `**${game}** + **${tag}**: ${tagGameCount} tickets this week.`, @@ -531,7 +533,7 @@ async function checkCombinedPatterns(client) { const daytimeRate = daytime / daytimeTotal; if (overnightRate > daytimeRate * 2 && overnight >= 3) { const key = 'overnight_gap:week'; - if (shouldFire('overnight_gap', key, 'week')) { + if (isEnabled('overnight_gap') && shouldFire('overnight_gap', key, 'week')) { postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( 'Overnight coverage gap', `Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`, @@ -559,7 +561,7 @@ async function checkCombinedPatterns(client) { for (const s of staffGameStats) { if (s.escalated / s.total >= 0.9) { const key = `staff_always_esc:${s._id.staff}:${s._id.game}:month`; - if (shouldFire('staff_always_esc', key, 'month')) { + if (isEnabled('staff_always_esc') && shouldFire('staff_always_esc', key, 'month')) { postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff always escalates this game', `Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`, diff --git a/services/staffNotifications.js b/services/staffNotifications.js index d59fa17..3617993 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -13,6 +13,7 @@ const { CONFIG, parseThresholdString } = require('../config'); const { increment } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); const { assertKeysRegistered } = require('./notificationRegistry'); +const { isEnabled } = require('./notificationEnabled'); // Alert key this module drives. Registered to fail fast on drift. const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder']; @@ -60,6 +61,7 @@ async function notifyStaffOfReply(guild, ticket, message) { * threshold) into every staff notification channel. */ async function notifyAllStaffUnclaimed(client) { + if (!isEnabled('unclaimed_reminder')) return; const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || []; const thresholds = rawThresholds .map(parseThresholdString) diff --git a/services/surgeChecker.js b/services/surgeChecker.js index 7ada53d..e73d201 100644 --- a/services/surgeChecker.js +++ b/services/surgeChecker.js @@ -9,6 +9,7 @@ const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); const { enqueueSend } = require('./channelQueue'); const { assertKeysRegistered } = require('./notificationRegistry'); +const { isEnabled } = require('./notificationEnabled'); // Alert keys this module drives. Asserted against the registry at load so any // future drift (rename, typo, unregistered key) fails fast rather than @@ -58,6 +59,7 @@ async function pingStaff(client, message, embedFields) { } async function checkTicketSurge(client) { + if (!isEnabled('surge_tickets')) return; const key = 'surge:tickets'; const since = new Date(Date.now() - CONFIG.SURGE_TICKET_WINDOW_MINUTES * 60000); const count = await Ticket.countDocuments({ createdAt: { $gte: since } }); @@ -75,6 +77,7 @@ async function checkTicketSurge(client) { } async function checkGameSurge(client) { + if (!isEnabled('surge_game')) return; const key = 'surge:game'; const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000); const gameCounts = await Ticket.aggregate([ @@ -99,6 +102,7 @@ async function checkGameSurge(client) { } async function checkStaleSurge(client) { + if (!isEnabled('surge_stale')) return; const key = 'surge:stale'; const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000); const count = await Ticket.countDocuments({ @@ -119,6 +123,7 @@ async function checkStaleSurge(client) { } async function checkNeedsResponseSurge(client) { + if (!isEnabled('surge_needs_response')) return; const key = 'surge:needs_response'; const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000); const count = await Ticket.countDocuments({ @@ -140,6 +145,7 @@ async function checkNeedsResponseSurge(client) { } async function checkUnclaimedSurge(client) { + if (!isEnabled('surge_unclaimed')) return; const key = 'surge:unclaimed'; const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000); const count = await Ticket.countDocuments({ @@ -161,6 +167,7 @@ async function checkUnclaimedSurge(client) { } async function checkTier3UnclaimedSurge(client) { + if (!isEnabled('surge_tier3_unclaimed')) return; const key = 'surge:tier3_unclaimed'; const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000); const tickets = await Ticket.find({ @@ -183,6 +190,7 @@ async function checkTier3UnclaimedSurge(client) { } async function checkZeroStaffSurge(client) { + if (!isEnabled('surge_no_staff')) return; const key = 'surge:no_staff'; if (!CONFIG.STAFF_IDS.length) { clearEscalating(key); diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 2d6dd29..a01551b 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -771,6 +771,41 @@ body::before { #s-notifications .notif-trigger[open] summary::before { content: '−'; } #s-notifications .notif-trigger[open] summary { color: var(--primary); } +/* Phase 9 — notification enable toggles */ +#s-notifications .notif-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border); +} +#s-notifications .notif-toggle-group { + display: flex; + align-items: center; + gap: 10px; +} +#s-notifications .notif-toggle-label { + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + color: var(--text); + letter-spacing: 0; +} +#s-notifications .notif-per-alert-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.notif-disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; +} + /* Logging hint link */ .logging-hint { color: var(--text-muted); font-size: 13px; } .logging-hint a { diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 7e69980..78ffa71 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -173,10 +173,33 @@
+
+
+ + Master (all categories) +
+
+ + All in category +
+

Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.

+
+ + Alert disabled +
@@ -208,10 +231,33 @@