diff --git a/config.js b/config.js index abbc2bf..a66b63b 100644 --- a/config.js +++ b/config.js @@ -30,6 +30,83 @@ if (!envPath) { } } +const DEFAULT_NOTIFICATION_THRESHOLDS = { + // patternChecker - age-based (time since condition first became true) + user_tickets: ['15m', '30m', '1h', '3h'], + user_reopen: ['1h', '4h', '1d'], + user_crossgame: ['1h', '1d'], + game_surge: ['15m', '30m', '1h'], + game_backlog: ['30m', '1h', '3h', '6h'], + game_resolution: ['1d'], + game_spike: ['15m', '30m'], + tag_top: ['1h', '6h', '1d'], + tag_escalation: ['1h', '6h', '1d'], + untagged_closes: ['1h', '1d'], + tag_game_corr: ['1d'], + user_esc: ['1h', '6h', '1d'], + game_esc_rate: ['1d'], + rapid_t2_t3: ['3', '5', '10', '15', '20', '30', '50'], // count-based milestones, not time + staff_no_close: ['1h', '3h'], + staff_overloaded: ['1h', '3h', '6h'], + staff_stale: ['1h', '3h'], + staff_transfer_rate: ['1h', '1d'], + staff_esc: ['1h', '6h', '1d'], + staff_game_esc: ['1d'], + game_tag_spike: ['1h', '6h'], + overnight_gap: ['1d'], + staff_always_esc: ['1d'], + // surgeChecker - cooldown-escalating (repeat alerts spaced further apart) + surge_tickets: ['10m', '30m', '1h', '2h', '3h'], + surge_game: ['10m', '30m', '1h', '2h'], + surge_stale: ['30m', '1h', '2h', '4h'], + surge_needs_response: ['15m', '30m', '1h', '3h'], + surge_unclaimed: ['15m', '30m', '1h', '2h', '4h'], + surge_tier3_unclaimed: ['10m', '15m', '30m', '1h', '2h'], + surge_no_staff: ['10m', '20m', '30m', '1h'], + // staffNotifications - age-based per ticket (hours) + unclaimed_reminder: ['1h', '2h', '4h', '8h', '1d'], + // chatAlertChecker - cooldown-escalating + chat_messages: ['15m', '30m', '1h', '3h'], + chat_time: ['30m', '1h', '2h', '4h'] +}; + +function parseThresholdString(str) { + const value = String(str || '').trim(); + if (!value) return NaN; + + // Integers without a unit are raw count milestones. + if (/^\d+$/.test(value)) return parseInt(value, 10); + + let totalMs = 0; + const re = /(\d+)([mhd])/g; + let match; + let consumed = ''; + while ((match = re.exec(value)) !== null) { + const amount = parseInt(match[1], 10); + const unit = match[2]; + consumed += match[0]; + if (unit === 'm') totalMs += amount * 60 * 1000; + else if (unit === 'h') totalMs += amount * 60 * 60 * 1000; + else if (unit === 'd') totalMs += amount * 24 * 60 * 60 * 1000; + } + + if (!consumed || consumed !== value) return NaN; + return totalMs; +} + +function parseNotificationThresholdsJson(raw) { + if (!raw || !String(raw).trim()) return DEFAULT_NOTIFICATION_THRESHOLDS; + try { + const parsedJson = JSON.parse(raw); + if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) { + return parsedJson; + } + } catch (err) { + console.warn('[config] Failed to parse NOTIFICATION_THRESHOLDS_JSON, using default:', err.message); + } + return DEFAULT_NOTIFICATION_THRESHOLDS; +} + const CONFIG = { DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(), DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null, @@ -191,6 +268,7 @@ const CONFIG = { SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com', INTERNAL_API_PORT: parseInt(process.env.INTERNAL_API_PORT) || 12753, INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null, + NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON), UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') .split(',') .map(s => parseInt(s.trim(), 10)) @@ -262,6 +340,7 @@ const GAME_NAME_TO_KEY = { module.exports = { CONFIG, + parseThresholdString, TICKET_TAGS, GAME_NAMES, GAME_ALIASES, diff --git a/handlers/commands.js b/handlers/commands.js index 94f2edb..c0da1af 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -74,7 +74,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { // Clear claim on escalation await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } + { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedReminderssent: [] } } ); ticket.escalated = true; ticket.escalationTier = nextTier; diff --git a/services/chatAlertChecker.js b/services/chatAlertChecker.js index b43e046..d08907f 100644 --- a/services/chatAlertChecker.js +++ b/services/chatAlertChecker.js @@ -3,11 +3,17 @@ * and alerts staff when thresholds are crossed. */ const { EmbedBuilder } = require('discord.js'); -const { CONFIG } = require('../config'); -const { setCooldown, isOnCooldown } = require('./patternStore'); +const { CONFIG, parseThresholdString } = require('../config'); +const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore'); // channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt } const chatState = new Map(); +const chatMessageThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_messages || []) + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n > 0); +const chatTimeThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_time || []) + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n > 0); function initChatMonitoring(client) { for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) { @@ -34,6 +40,8 @@ async function handleChatMessage(msg, client) { if (isStaff(msg.member)) { state.lastStaffMessageAt = new Date(); state.unrespondedCount = 0; + clearEscalating(`chat:messages:${msg.channel.id}`); + clearEscalating(`chat:time:${msg.channel.id}`); } else { state.unrespondedCount++; } @@ -47,8 +55,7 @@ async function runChatAlertChecks(client) { // Message count threshold if (state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) { const cooldownKey = `chat:messages:${channelId}`; - if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) { - setCooldown(cooldownKey); + if (shouldFireCooldownEscalating(cooldownKey, chatMessageThresholdsMs) !== null) { const embed = new EmbedBuilder() .setTitle('Chat needs attention') .setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`) @@ -66,8 +73,7 @@ async function runChatAlertChecks(client) { const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000; if (hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) { const cooldownKey = `chat:time:${channelId}`; - if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) { - setCooldown(cooldownKey); + if (shouldFireCooldownEscalating(cooldownKey, chatTimeThresholdsMs) !== null) { const embed = new EmbedBuilder() .setTitle('Chat without staff response') .setDescription(`<#${channelId}> has had no staff response for ${Math.floor(hoursSinceStaff)} hour(s) with ${state.unrespondedCount} pending message(s).`) diff --git a/services/patternChecker.js b/services/patternChecker.js index a50ec30..24b61df 100644 --- a/services/patternChecker.js +++ b/services/patternChecker.js @@ -3,18 +3,15 @@ * alerts to dedicated Discord channels. */ const { EmbedBuilder } = require('discord.js'); -const { CONFIG } = require('../config'); +const { CONFIG, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); -const { getAll, get } = require('./patternStore'); +const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore'); const Ticket = mongoose.model('Ticket'); -// Deduplication: keys that have already fired today -const firedToday = new Set(); - -// Register daily reset -const { onDailyReset } = require('./patternStore'); -onDailyReset(() => firedToday.clear()); +// rapid_t2_t3 count milestone state (cleared weekly) +const firedCountMilestones = new Map(); +onWeeklyReset(() => firedCountMilestones.clear()); // --- Helpers --- @@ -35,10 +32,31 @@ async function postPattern(client, channelConfigKey, embed) { } catch (_) {} } -function shouldFire(key) { - if (firedToday.has(key)) return false; - firedToday.add(key); - return true; +function getWindowStartMs(windowType) { + if (windowType === 'today') { + const start = new Date(); + start.setHours(0, 0, 0, 0); + return start.getTime(); + } + if (windowType === 'week') return getThisWeekStart().getTime(); + if (windowType === 'month') { + const start = new Date(); + start.setDate(1); + start.setHours(0, 0, 0, 0); + return start.getTime(); + } + return Date.now(); +} + +function shouldFire(alertKey, key, windowType) { + const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || []; + const thresholds = rawThresholds + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n >= 0); + if (thresholds.length === 0) return false; + + const ageMs = Date.now() - getWindowStartMs(windowType); + return shouldFireThreshold(key, ageMs, thresholds, windowType) !== null; } function getThisWeekStart() { @@ -59,7 +77,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(key)) { + if (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}).`, @@ -79,7 +97,7 @@ async function checkUserPatterns(client) { ]); for (const r of reopens) { const key = `user_reopen:${r._id}:week`; - if (shouldFire(key)) { + if (shouldFire('user_reopen', key, 'week')) { postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( 'High reopen rate', `${r._id} reopened tickets ${r.count}x this week`, @@ -98,7 +116,7 @@ async function checkUserPatterns(client) { ]); for (const c of crossGame) { const key = `user_crossgame:${c._id}:week`; - if (shouldFire(key)) { + if (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(', ')}`, @@ -115,7 +133,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(key)) { + if (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}).`, @@ -136,7 +154,7 @@ async function checkGamePatterns(client) { for (const b of backlog) { const gameName = b._id || 'Unknown'; const key = `game_backlog:${gameName}:today`; - if (shouldFire(key)) { + if (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.`, @@ -163,7 +181,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(key)) { + if (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( @@ -188,7 +206,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(key)) { + if (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.`, @@ -209,7 +227,7 @@ async function checkTagPatterns(client) { } if (topTag && topCount >= 5) { const key = `tag_top:${topTag}:today`; - if (shouldFire(key)) { + if (shouldFire('tag_top', key, 'today')) { postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( 'Top issue tag today', `**${topTag}** used ${topCount} times today.`, @@ -228,7 +246,7 @@ async function checkTagPatterns(client) { ]); for (const te of tagEscalations) { const key = `tag_escalation:${te._id}:week`; - if (shouldFire(key)) { + if (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.`, @@ -242,7 +260,7 @@ async function checkTagPatterns(client) { const untaggedCount = get('untagged_closes', 'total', 'today'); if (untaggedCount >= 5) { const key = 'untagged_closes:today'; - if (shouldFire(key)) { + if (shouldFire('untagged_closes', key, 'today')) { postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( 'High untagged close rate', `${untaggedCount} tickets closed today without a tag.`, @@ -262,7 +280,7 @@ async function checkTagPatterns(client) { } if (total >= 5 && maxGame && maxCount / total > 0.8) { const key = `tag_game_corr:${tag}:${maxGame}:week`; - if (shouldFire(key)) { + if (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).`, @@ -279,7 +297,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(key)) { + if (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}).`, @@ -302,7 +320,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(key)) { + if (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.`, @@ -324,9 +342,24 @@ async function checkEscalationPatterns(client) { const rapidCount = rapid.length; if (rapidCount >= 3) { const key = 'rapid_t2_t3:week'; - if (shouldFire(key)) { + const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.rapid_t2_t3) || []; + const thresholds = rawThresholds + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n >= 0) + .sort((a, b) => a - b); + const firedSet = firedCountMilestones.get(key) || new Set(); + let shouldNotify = false; + for (const threshold of thresholds) { + if (rapidCount >= threshold && !firedSet.has(threshold)) { + firedSet.add(threshold); + shouldNotify = true; + break; + } + } + if (shouldNotify) { + firedCountMilestones.set(key, firedSet); postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( - 'Tier 2 unable to handle issue type', + 'Rapid tier 3 escalations', `${rapidCount} tickets reached tier 3 this week.`, 0xFF0000 )); @@ -341,7 +374,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(key)) { + if (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.`, @@ -360,7 +393,7 @@ async function checkStaffPatterns(client) { ]); for (const o of overloaded) { const key = `staff_overloaded:${o._id}:today`; - if (shouldFire(key)) { + if (shouldFire('staff_overloaded', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff overloaded', `Staff \`${o._id}\` has ${o.count} open claimed tickets.`, @@ -375,7 +408,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(key)) { + if (shouldFire('staff_stale', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff stale ping threshold', `Staff \`${staffId}\` received ${count} stale pings today.`, @@ -391,7 +424,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(key)) { + if (shouldFire('staff_transfer_rate', key, 'today')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'High transfer rate', `Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`, @@ -406,7 +439,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(key)) { + if (shouldFire('staff_esc', key, 'week')) { postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( 'Staff frequent escalator', `Staff \`${staffId}\` escalated ${count} tickets this week.`, @@ -425,7 +458,7 @@ async function checkCombinedPatterns(client) { for (const [game, count] of gameEsc) { if (count >= 3) { const key = `staff_game_esc:${staffId}:${game}:week`; - if (shouldFire(key)) { + if (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.`, @@ -444,7 +477,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(key)) { + if (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.`, @@ -481,7 +514,7 @@ async function checkCombinedPatterns(client) { const daytimeRate = daytime / daytimeTotal; if (overnightRate > daytimeRate * 2 && overnight >= 3) { const key = 'overnight_gap:week'; - if (shouldFire(key)) { + if (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)}%.`, @@ -509,7 +542,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(key)) { + if (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/patternStore.js b/services/patternStore.js index 3e3d625..6b9d880 100644 --- a/services/patternStore.js +++ b/services/patternStore.js @@ -68,14 +68,89 @@ function msUntilNextMonth() { // Callbacks to run on daily reset (e.g. clear firedToday in patternChecker) const dailyResetCallbacks = []; +const weeklyResetCallbacks = []; function onDailyReset(fn) { dailyResetCallbacks.push(fn); } +function onWeeklyReset(fn) { + weeklyResetCallbacks.push(fn); +} + +// --- Threshold firing state --- +// key -> Set that have fired within the key's window. +const firedThresholds = new Map(); +// key -> window type used for threshold clearing ("today" | "week" | "month") +const firedThresholdWindows = new Map(); + +function clearFiredThresholdsForWindow(windowType) { + for (const [key, mappedWindowType] of firedThresholdWindows.entries()) { + if (mappedWindowType === windowType) { + firedThresholds.delete(key); + firedThresholdWindows.delete(key); + } + } +} + +function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) { + if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null; + if (!['today', 'week', 'month'].includes(windowType)) return null; + + firedThresholdWindows.set(key, windowType); + + const firedForKey = firedThresholds.get(key) || new Set(); + const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); + + let highestUnfiredCrossed = null; + for (const thresholdMs of sortedThresholds) { + if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) { + highestUnfiredCrossed = thresholdMs; + } + } + + if (highestUnfiredCrossed === null) return null; + + firedForKey.add(highestUnfiredCrossed); + firedThresholds.set(key, firedForKey); + return highestUnfiredCrossed; +} + +// --- Escalating cooldown state --- +// key -> { startedAtMs, lastFireAtMs, fireCount } +const escalatingCooldowns = new Map(); + +function shouldFireCooldownEscalating(key, thresholdsMs) { + if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null; + + const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); + const now = Date.now(); + let state = escalatingCooldowns.get(key); + + if (!state) { + state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0 }; + escalatingCooldowns.set(key, state); + } + + const nextThreshold = sortedThresholds[state.fireCount]; + if (typeof nextThreshold !== 'number') return null; + + const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs; + if ((now - referenceMs) < nextThreshold) return null; + + state.fireCount += 1; + state.lastFireAtMs = now; + return nextThreshold; +} + +function clearEscalating(key) { + escalatingCooldowns.delete(key); +} + function scheduleDailyReset() { setTimeout(() => { store.today = new Map(); + clearFiredThresholdsForWindow('today'); for (const fn of dailyResetCallbacks) { try { fn(); } catch (_) {} } @@ -86,6 +161,10 @@ function scheduleDailyReset() { function scheduleWeeklyReset() { setTimeout(() => { store.week = new Map(); + clearFiredThresholdsForWindow('week'); + for (const fn of weeklyResetCallbacks) { + try { fn(); } catch (_) {} + } scheduleWeeklyReset(); }, msUntilNextMonday()); } @@ -93,6 +172,7 @@ function scheduleWeeklyReset() { function scheduleMonthlyReset() { setTimeout(() => { store.month = new Map(); + clearFiredThresholdsForWindow('month'); scheduleMonthlyReset(); }, msUntilNextMonth()); } @@ -140,6 +220,11 @@ module.exports = { getAll, scheduleResets, onDailyReset, + onWeeklyReset, + firedThresholds, + shouldFireThreshold, + shouldFireCooldownEscalating, + clearEscalating, setCooldown, isOnCooldown, updateStaffLastSeen, diff --git a/services/staffNotifications.js b/services/staffNotifications.js index ebd7d4f..7da4221 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -9,7 +9,7 @@ * per ticket (highest newly-crossed threshold only). */ const { mongoose } = require('../db-connection'); -const { CONFIG } = require('../config'); +const { CONFIG, parseThresholdString } = require('../config'); const { increment } = require('./patternStore'); const Ticket = mongoose.model('Ticket'); @@ -53,8 +53,12 @@ async function notifyStaffOfReply(guild, ticket, message) { * threshold) into every staff notification channel. */ async function notifyAllStaffUnclaimed(client) { - const thresholds = CONFIG.UNCLAIMED_REMINDER_THRESHOLDS; - if (!thresholds || thresholds.length === 0) return; + const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || []; + const thresholds = rawThresholds + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n >= 0) + .map(ms => ms / (60 * 60 * 1000)); + if (thresholds.length === 0) return; const sorted = [...thresholds].sort((a, b) => a - b); const now = Date.now(); diff --git a/services/surgeChecker.js b/services/surgeChecker.js index 37fecd4..5642ae7 100644 --- a/services/surgeChecker.js +++ b/services/surgeChecker.js @@ -3,13 +3,21 @@ * and pings ALL_STAFF_CHANNEL_ID with role mention. */ const { EmbedBuilder } = require('discord.js'); -const { CONFIG } = require('../config'); +const { CONFIG, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); -const { setCooldown, isOnCooldown, isStaffRecentlyActive } = require('./patternStore'); +const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore'); const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); const Ticket = mongoose.model('Ticket'); +function getThresholdsMs(alertKey) { + const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || []; + return rawThresholds + .map(parseThresholdString) + .filter(n => Number.isFinite(n) && n >= 0) + .sort((a, b) => a - b); +} + async function pingStaff(client, message, embedFields) { const channelId = CONFIG.ALL_STAFF_CHANNEL_ID; if (!channelId || !client) return; @@ -34,20 +42,24 @@ async function pingStaff(client, message, embedFields) { } async function checkTicketSurge(client) { - if (isOnCooldown('surge:tickets', CONFIG.SURGE_COOLDOWN_MINUTES)) 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 } }); if (count >= CONFIG.SURGE_TICKET_COUNT) { - setCooldown('surge:tickets'); - await pingStaff(client, - `${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`, - [{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }] - ); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tickets')); + if (thresholdMs !== null) { + await pingStaff(client, + `${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`, + [{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }] + ); + } + } else { + clearEscalating(key); } } async function checkGameSurge(client) { - if (isOnCooldown('surge:game', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const key = 'surge:game'; const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000); const gameCounts = await Ticket.aggregate([ { $match: { createdAt: { $gte: since }, game: { $ne: null } } }, @@ -56,34 +68,42 @@ async function checkGameSurge(client) { { $sort: { count: -1 } } ]); if (gameCounts.length > 0) { - setCooldown('surge:game'); - const fields = gameCounts.map(g => ({ - name: g._id, - value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`, - inline: true - })); - await pingStaff(client, 'Game ticket surge detected.', fields); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_game')); + if (thresholdMs !== null) { + const fields = gameCounts.map(g => ({ + name: g._id, + value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`, + inline: true + })); + await pingStaff(client, 'Game ticket surge detected.', fields); + } + } else { + clearEscalating(key); } } async function checkStaleSurge(client) { - if (isOnCooldown('surge:stale', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const key = 'surge:stale'; const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000); const count = await Ticket.countDocuments({ status: 'open', lastActivity: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_STALE_COUNT) { - setCooldown('surge:stale'); - await pingStaff(client, - `${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`, - [{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }] - ); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_stale')); + if (thresholdMs !== null) { + await pingStaff(client, + `${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`, + [{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }] + ); + } + } else { + clearEscalating(key); } } async function checkNeedsResponseSurge(client) { - if (isOnCooldown('surge:needs_response', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const key = 'surge:needs_response'; const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000); const count = await Ticket.countDocuments({ status: 'open', @@ -91,16 +111,20 @@ async function checkNeedsResponseSurge(client) { lastActivity: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) { - setCooldown('surge:needs_response'); - await pingStaff(client, - `${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`, - [] - ); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_needs_response')); + if (thresholdMs !== null) { + await pingStaff(client, + `${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`, + [] + ); + } + } else { + clearEscalating(key); } } async function checkUnclaimedSurge(client) { - if (isOnCooldown('surge:unclaimed', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const key = 'surge:unclaimed'; const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000); const count = await Ticket.countDocuments({ status: 'open', @@ -108,16 +132,20 @@ async function checkUnclaimedSurge(client) { createdAt: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) { - setCooldown('surge:unclaimed'); - await pingStaff(client, - `${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`, - [] - ); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_unclaimed')); + if (thresholdMs !== null) { + await pingStaff(client, + `${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`, + [] + ); + } + } else { + clearEscalating(key); } } async function checkTier3UnclaimedSurge(client) { - if (isOnCooldown('surge:tier3_unclaimed', 30)) return; + const key = 'surge:tier3_unclaimed'; const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000); const tickets = await Ticket.find({ status: 'open', @@ -126,23 +154,36 @@ async function checkTier3UnclaimedSurge(client) { createdAt: { $lte: cutoff, $ne: null } }).lean(); if (tickets.length > 0) { - setCooldown('surge:tier3_unclaimed'); - await pingStaff(client, - `${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`, - tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true })) - ); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tier3_unclaimed')); + if (thresholdMs !== null) { + await pingStaff(client, + `${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`, + tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true })) + ); + } + } else { + clearEscalating(key); } } async function checkZeroStaffSurge(client) { - if (isOnCooldown('surge:no_staff', CONFIG.SURGE_NO_STAFF_COOLDOWN_MINUTES)) return; - if (!CONFIG.STAFF_IDS.length) return; + const key = 'surge:no_staff'; + if (!CONFIG.STAFF_IDS.length) { + clearEscalating(key); + return; + } const openCount = await Ticket.countDocuments({ status: 'open' }); - if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) return; + if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) { + clearEscalating(key); + return; + } const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); - if (!guild) return; + if (!guild) { + clearEscalating(key); + return; + } const { available, source } = isAnyStaffAvailable(guild); @@ -162,9 +203,13 @@ async function checkZeroStaffSurge(client) { detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`; } - if (!noStaff) return; + if (!noStaff) { + clearEscalating(key); + return; + } - setCooldown('surge:no_staff'); + const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_no_staff')); + if (thresholdMs === null) return; const fields = [ { name: 'Open tickets', value: String(openCount), inline: true }, diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 7fac01f..86c9b4f 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -20,8 +20,7 @@ Ticket Behavior Staff Threads Pin Messages - Surge Alerts - Pattern Detection + Notifications Logging Automation Appearance @@ -156,44 +155,139 @@ - -
-

Surge Alerts

Ticket volume and staffing alerts

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Enabled
-
-
-
-
-
-
-
+ +
+

Notifications

Threshold milestones and trigger conditions by alert category

+
+ - -
-

Pattern Detection

Thresholds for automated pattern alerts

-
-
-
-
-
-
-
-
-
+ + +
+ + + + +
+ +
+

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.

+
+
+
+
+
+ + +
+
+
+
+ Trigger conditions +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enabled
+
+
+
+
+
+ + + + + + +
diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js index ccf5d37..e9a73ff 100644 --- a/settings-site/public/js/app.js +++ b/settings-site/public/js/app.js @@ -1,5 +1,81 @@ let savedConfig = {}; let pendingChanges = {}; +let notificationThresholdsState = {}; + +const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d']; +const NOTIFICATION_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 NOTIFICATION_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.' +}; async function init() { document.getElementById('loading').classList.remove('hidden'); @@ -12,6 +88,7 @@ async function init() { document.getElementById('bot-status-dot').className = 'dot online'; document.getElementById('bot-status-text').textContent = 'Connected'; populateFields(config); + initNotificationsEditor(config); initSmartSelects(config); } catch (e) { document.getElementById('bot-status-dot').className = 'dot offline'; @@ -172,4 +249,190 @@ function showToast(message, type = 'success') { setTimeout(() => toast.remove(), 3500); } +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(NOTIFICATION_TAB_KEYS).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); + }); + 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'); +} + +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)) { + 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.innerHTML = ''; + 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 = NOTIFICATION_ALERT_DESCRIPTIONS[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; + markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized); + hiddenField.classList.toggle('changed', 'NOTIFICATION_THRESHOLDS_JSON' in pendingChanges); +} + +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(' '); +} + document.addEventListener('DOMContentLoaded', init);