notification changes
This commit is contained in:
79
config.js
79
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).`)
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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<thresholdMs> 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
<a href="#s-behavior">Ticket Behavior</a>
|
||||
<a href="#s-threads">Staff Threads</a>
|
||||
<a href="#s-pins">Pin Messages</a>
|
||||
<a href="#s-surge">Surge Alerts</a>
|
||||
<a href="#s-patterns">Pattern Detection</a>
|
||||
<a href="#s-notifications">Notifications</a>
|
||||
<a href="#s-logging">Logging</a>
|
||||
<a href="#s-automation">Automation</a>
|
||||
<a href="#s-appearance">Appearance</a>
|
||||
@@ -156,44 +155,139 @@
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Surge Alerts -->
|
||||
<div class="section" id="s-surge">
|
||||
<div class="section-header"><h2>Surge Alerts</h2><p>Ticket volume and staffing alerts</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<!-- 8. Notifications -->
|
||||
<div class="section" id="s-notifications">
|
||||
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body">
|
||||
<style>
|
||||
#s-notifications .notif-tabs { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px; }
|
||||
#s-notifications .notif-tab-btn { border:1px solid var(--border); background:var(--surface-2); color:var(--text); border-radius:8px; padding:8px 12px; cursor:pointer; }
|
||||
#s-notifications .notif-tab-btn.active { border-color:var(--accent); color:var(--accent); }
|
||||
#s-notifications .notif-panel.hidden { display:none; }
|
||||
#s-notifications .notif-editor { border:1px solid var(--border); border-radius:10px; padding:14px; margin-bottom:14px; background:var(--surface-2); }
|
||||
#s-notifications .notif-chips { display:flex; gap:8px; flex-wrap:wrap; margin:10px 0; min-height:28px; }
|
||||
#s-notifications .notif-chip { display:inline-flex; align-items:center; gap:8px; border:1px solid var(--border); background:var(--surface); border-radius:999px; padding:4px 10px; font-size:12px; }
|
||||
#s-notifications .notif-chip button { border:none; background:transparent; color:var(--text-muted); cursor:pointer; padding:0; line-height:1; font-size:14px; }
|
||||
#s-notifications .notif-input-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
#s-notifications .notif-input-row input { width:220px; }
|
||||
#s-notifications .notif-presets { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
|
||||
#s-notifications .notif-presets button { padding:6px 10px; border-radius:8px; border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; }
|
||||
#s-notifications .notif-trigger { margin-top:10px; }
|
||||
#s-notifications .notif-trigger summary { cursor:pointer; color:var(--text-muted); font-weight:600; margin-bottom:10px; }
|
||||
</style>
|
||||
|
||||
<!-- 9. Pattern Detection -->
|
||||
<div class="section" id="s-patterns">
|
||||
<div class="section-header"><h2>Pattern Detection</h2><p>Thresholds for automated pattern alerts</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||
</div></div>
|
||||
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
||||
|
||||
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
||||
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel" data-notif-panel="surge">
|
||||
<p class="hint">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.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="surge"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="patterns"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="chat"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Logging -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user