Files
broccolini-bot/services/patternChecker.js

588 lines
22 KiB
JavaScript

/**
* Pattern detection — scheduled checks that analyze ticket trends and post
* alerts to dedicated Discord channels.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert keys this module fires via shouldFire()/standard threshold path.
// rapid_t2_t3 is intentionally excluded — it uses count-milestone firing below
// via firedCountMilestones, not the shouldFire() pipeline, so it is not part
// of the notification registry.
const PATTERN_ALERT_KEYS = [
'user_tickets', 'user_reopen', 'user_crossgame',
'game_surge', 'game_backlog', 'game_resolution', 'game_spike',
'tag_top', 'tag_escalation', 'untagged_closes', 'tag_game_corr',
'user_esc', 'game_esc_rate',
'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate',
'staff_esc', 'staff_game_esc',
'game_tag_spike', 'overnight_gap', 'staff_always_esc'
];
assertKeysRegistered('patternChecker', PATTERN_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
// rapid_t2_t3 count milestone state (cleared weekly)
const firedCountMilestones = new Map();
onWeeklyReset(() => firedCountMilestones.clear());
// --- Helpers ---
function buildEmbed(title, description, color = 0xFFAA00) {
return new EmbedBuilder()
.setTitle(title)
.setDescription(String(description).slice(0, 4000))
.setColor(color)
.setTimestamp();
}
async function postPattern(client, channelConfigKey, embed) {
const channelId = CONFIG[channelConfigKey];
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (channel) await enqueueSend(channel, { embeds: [embed] });
} catch (_) {}
}
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() {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? 6 : day - 1;
const monday = new Date(now);
monday.setDate(now.getDate() - diff);
monday.setHours(0, 0, 0, 0);
return monday;
}
// --- Check functions ---
async function checkUserPatterns(client) {
// Surge: users with tickets >= threshold today
const todayCounts = getAll('user_tickets', 'today');
for (const [userId, count] of todayCounts) {
if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) {
const key = `user_tickets:${userId}:today`;
if (isEnabled('user_tickets') && shouldFire('user_tickets', key, 'today')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Repeat ticket user',
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Reopens this week
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const reopens = await Ticket.aggregate([
{ $match: { reopenedAt: { $gte: since } } },
{ $group: { _id: '$senderEmail', count: { $sum: 1 } } },
{ $match: { count: { $gte: 2 } } }
]);
for (const r of reopens) {
const key = `user_reopen:${r._id}:week`;
if (isEnabled('user_reopen') && shouldFire('user_reopen', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'High reopen rate',
`${r._id} reopened tickets ${r.count}x this week`,
0xFFAA00
));
}
}
} catch (_) {}
// Cross-game: users with tickets across 3+ games this week
try {
const crossGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, status: { $ne: 'closed' } } },
{ $group: { _id: '$senderEmail', games: { $addToSet: '$game' } } },
{ $match: { 'games.2': { $exists: true } } }
]);
for (const c of crossGame) {
const key = `user_crossgame:${c._id}:week`;
if (isEnabled('user_crossgame') && shouldFire('user_crossgame', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Cross-game user',
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
0x00AAFF
));
}
}
} catch (_) {}
}
async function checkGamePatterns(client) {
// Surge: games with tickets >= threshold today
const todayCounts = getAll('game_tickets', 'today');
for (const [game, count] of todayCounts) {
if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) {
const key = `game_surge:${game}:today`;
if (isEnabled('game_surge') && shouldFire('game_surge', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game ticket surge',
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
0xFF6600
));
}
}
}
// Backlog: unclaimed tickets older than threshold
try {
const cutoff = new Date(Date.now() - CONFIG.PATTERN_UNCLAIMED_HOURS * 3600000);
const backlog = await Ticket.aggregate([
{ $match: { status: 'open', claimedBy: null, createdAt: { $lte: cutoff } } },
{ $group: { _id: '$game', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const b of backlog) {
const gameName = b._id || 'Unknown';
const key = `game_backlog:${gameName}:today`;
if (isEnabled('game_backlog') && shouldFire('game_backlog', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game backlog alert',
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
0xFF0000
));
}
}
} catch (_) {}
// Resolution time trending: this week vs last week
try {
const thisWeekStart = getThisWeekStart();
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: lastWeekStart, $lt: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeekMap = new Map(lastWeek.map(l => [l._id, l.avg]));
for (const tw of thisWeek) {
const lw = lastWeekMap.get(tw._id);
if (lw && tw.avg > lw * 1.2) {
const key = `game_resolution:${tw._id}:week`;
if (isEnabled('game_resolution') && shouldFire('game_resolution', key, 'week')) {
const twHrs = (tw.avg / 3600000).toFixed(1);
const lwHrs = (lw / 3600000).toFixed(1);
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Resolution time increasing',
`**${tw._id}**: ${twHrs}h avg this week vs ${lwHrs}h last week (+${((tw.avg / lw - 1) * 100).toFixed(0)}%).`,
0xFFAA00
));
}
}
}
} catch (_) {}
// Spike after silence: games with 0 tickets in last 3 days but 3+ today
try {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
const recentByGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: threeDaysAgo, $lt: todayStart }, game: { $ne: null } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const recentGames = new Set(recentByGame.map(r => r._id));
for (const [game, count] of todayCounts) {
if (count >= 3 && !recentGames.has(game)) {
const key = `game_spike:${game}:today`;
if (isEnabled('game_spike') && shouldFire('game_spike', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Possible outage',
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
0xFF0000
));
}
}
}
} catch (_) {}
}
async function checkTagPatterns(client) {
// Most common tag today
const todayTags = getAll('tag_usage', 'today');
let topTag = null, topCount = 0;
for (const [tag, count] of todayTags) {
if (count > topCount) { topTag = tag; topCount = count; }
}
if (topTag && topCount >= 5) {
const key = `tag_top:${topTag}:today`;
if (isEnabled('tag_top') && shouldFire('tag_top', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Top issue tag today',
`**${topTag}** used ${topCount} times today.`,
0x00AAFF
));
}
}
// Tag→escalation correlation
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const tagEscalations = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, escalationTier: { $gte: 1 }, ticketTag: { $ne: null } } },
{ $group: { _id: '$ticketTag', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const te of tagEscalations) {
const key = `tag_escalation:${te._id}:week`;
if (isEnabled('tag_escalation') && shouldFire('tag_escalation', key, 'week')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Tag frequently leads to escalation',
`**${te._id}**: ${te.count} escalated tickets this week.`,
0xFFAA00
));
}
}
} catch (_) {}
// Untagged closes
const untaggedCount = get('untagged_closes', 'total', 'today');
if (untaggedCount >= 5) {
const key = 'untagged_closes:today';
if (isEnabled('untagged_closes') && shouldFire('untagged_closes', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'High untagged close rate',
`${untaggedCount} tickets closed today without a tag.`,
0xFFAA00
));
}
}
// Tag↔game correlation: for each tag this week, check if one game dominates
const weekTags = getAll('tag_usage', 'week');
for (const [tag] of weekTags) {
const tagGameCounts = getAll(`tag_game:${tag}`, 'week');
let total = 0, maxGame = null, maxCount = 0;
for (const [game, count] of tagGameCounts) {
total += count;
if (count > maxCount) { maxGame = game; maxCount = count; }
}
if (total >= 5 && maxGame && maxCount / total > 0.8) {
const key = `tag_game_corr:${tag}:${maxGame}:week`;
if (isEnabled('tag_game_corr') && shouldFire('tag_game_corr', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Auto-tagging opportunity',
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
0x00AAFF
));
}
}
}
}
async function checkEscalationPatterns(client) {
// User escalation rate
const userEscalations = getAll('user_escalations', 'week');
for (const [user, count] of userEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `user_esc:${user}:week`;
if (isEnabled('user_esc') && shouldFire('user_esc', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Frequent escalation user',
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Game escalation rate vs baseline
try {
const thisWeekStart = getThisWeekStart();
const thisWeek = await Ticket.aggregate([
{ $match: { escalationTier: { $gte: 1 }, createdAt: { $gte: thisWeekStart } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const totalThisWeek = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart } });
for (const tw of thisWeek) {
if (!tw._id) continue;
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 (isEnabled('game_esc_rate') && shouldFire('game_esc_rate', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'High escalation rate for game',
`**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`,
0xFF6600
));
}
}
}
} catch (_) {}
// Rapid tier 2→3
if (!isEnabled('rapid_t2_t3')) return;
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const rapid = await Ticket.find({
escalationTier: 2,
escalatedAt: { $gte: since }
}).lean();
// Count tickets where escalation happened very quickly (approximate: check if tier was changed recently)
const rapidCount = rapid.length;
if (rapidCount >= 3) {
const key = 'rapid_t2_t3:week';
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(
'Rapid tier 3 escalations',
`${rapidCount} tickets reached tier 3 this week.`,
0xFF0000
));
}
}
} catch (_) {}
}
async function checkStaffPatterns(client) {
// Claims without closes
const todayClaims = getAll('staff_claims', 'today');
for (const [staffId, claims] of todayClaims) {
if (claims >= 3 && get('staff_closes', staffId, 'today') === 0) {
const key = `staff_no_close:${staffId}:today`;
if (isEnabled('staff_no_close') && shouldFire('staff_no_close', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Claims without closes',
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
0xFFAA00
));
}
}
}
// Overloaded: open tickets per claimer
try {
const overloaded = await Ticket.aggregate([
{ $match: { status: 'open', claimerId: { $ne: null } } },
{ $group: { _id: '$claimerId', count: { $sum: 1 } } },
{ $match: { count: { $gte: 5 } } }
]);
for (const o of overloaded) {
const key = `staff_overloaded:${o._id}:today`;
if (isEnabled('staff_overloaded') && shouldFire('staff_overloaded', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff overloaded',
`Staff \`${o._id}\` has ${o.count} open claimed tickets.`,
0xFF6600
));
}
}
} catch (_) {}
// Stale ping threshold
const stalePings = getAll('staff_stale_pings', 'today');
for (const [staffId, count] of stalePings) {
if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) {
const key = `staff_stale:${staffId}:today`;
if (isEnabled('staff_stale') && shouldFire('staff_stale', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff stale ping threshold',
`Staff \`${staffId}\` received ${count} stale pings today.`,
0xFFAA00
));
}
}
}
// Transfer rate
const todayTransfers = getAll('staff_transfers', 'today');
for (const [staffId, transfers] of todayTransfers) {
const claims = get('staff_claims', staffId, 'today');
if (claims > 0 && transfers >= claims) {
const key = `staff_transfer_rate:${staffId}:today`;
if (isEnabled('staff_transfer_rate') && shouldFire('staff_transfer_rate', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'High transfer rate',
`Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`,
0xFFAA00
));
}
}
}
// Escalations per staff
const weekEscalations = getAll('staff_escalations', 'week');
for (const [staffId, count] of weekEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `staff_esc:${staffId}:week`;
if (isEnabled('staff_esc') && shouldFire('staff_esc', key, 'week')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff frequent escalator',
`Staff \`${staffId}\` escalated ${count} tickets this week.`,
0xFFAA00
));
}
}
}
}
async function checkCombinedPatterns(client) {
// Staff+game escalation correlation
const weekEscStaff = getAll('staff_escalations', 'week');
for (const [staffId] of weekEscStaff) {
const gameEsc = getAll(`staff_game_escalations:${staffId}`, 'week');
for (const [game, count] of gameEsc) {
if (count >= 3) {
const key = `staff_game_esc:${staffId}:${game}:week`;
if (isEnabled('staff_game_esc') && shouldFire('staff_game_esc', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff may need training for this game',
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
0xFFAA00
));
}
}
}
}
// Game+tag spike: specific game+tag combo >= 5 today
const todayGames = getAll('game_tickets', 'today');
const todayTags = getAll('tag_usage', 'today');
for (const [game] of todayGames) {
for (const [tag] of todayTags) {
const tagGameCount = get(`tag_game:${tag}`, game, 'week');
if (tagGameCount >= 5) {
const key = `game_tag_spike:${game}:${tag}:today`;
if (isEnabled('game_tag_spike') && shouldFire('game_tag_spike', key, 'today')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Specific feature of specific game spiking',
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
0xFF6600
));
}
}
}
}
// Overnight escalation gap: compare 00:00-06:00 vs daytime escalation rates
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const overnight = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytime = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
const overnightTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytimeTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
if (overnightTotal > 0 && daytimeTotal > 0) {
const overnightRate = overnight / overnightTotal;
const daytimeRate = daytime / daytimeTotal;
if (overnightRate > daytimeRate * 2 && overnight >= 3) {
const key = 'overnight_gap:week';
if (isEnabled('overnight_gap') && shouldFire('overnight_gap', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Overnight coverage gap',
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
0xFF0000
));
}
}
}
} catch (_) {}
// Staff never resolves game X without escalating
try {
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const staffGameStats = await Ticket.aggregate([
{ $match: { claimerId: { $ne: null }, game: { $ne: null }, createdAt: { $gte: monthStart } } },
{ $group: {
_id: { staff: '$claimerId', game: '$game' },
total: { $sum: 1 },
escalated: { $sum: { $cond: [{ $gte: ['$escalationTier', 1] }, 1, 0] } }
}},
{ $match: { total: { $gte: 3 } } }
]);
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 (isEnabled('staff_always_esc') && shouldFire('staff_always_esc', key, 'month')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff always escalates this game',
`Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`,
0xFF6600
));
}
}
}
} catch (_) {}
}
// --- Main entry point ---
async function runPatternChecks(client) {
try { await checkUserPatterns(client); } catch (e) { console.error('checkUserPatterns:', e); }
try { await checkGamePatterns(client); } catch (e) { console.error('checkGamePatterns:', e); }
try { await checkTagPatterns(client); } catch (e) { console.error('checkTagPatterns:', e); }
try { await checkEscalationPatterns(client); } catch (e) { console.error('checkEscalationPatterns:', e); }
try { await checkStaffPatterns(client); } catch (e) { console.error('checkStaffPatterns:', e); }
try { await checkCombinedPatterns(client); } catch (e) { console.error('checkCombinedPatterns:', e); }
}
module.exports = { runPatternChecks };