/** * 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 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 channel.send({ 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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 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 (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 (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 (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 (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 (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 (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 (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 (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 (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 };