phase 9 notification toggles (per-alert, per-category, master; default-disabled)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
102
services/notificationEnabled.js
Normal file
102
services/notificationEnabled.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user