Files
broccolini-bot/handlers/commands/stats.js
indifferentketchup cdb5db0082 Add per-staff metrics: StaffAction event log + /stats command
Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats
command. Foundation for a future tickets-website analytics dashboard.

Data:
- StaffAction model (event log) + Ticket.game / Ticket.closedAt
- STATS_ADMIN_IDS config (who may view others' stats)

Recording (fire-and-forget, idempotent on real state transitions):
- claim, response (channel reply + /response send), escalate, de-escalate,
  transfer, close (4 sites), reopen — each denormalizes ticketType, tier,
  priority, game, requester (senderEmail / creatorId), guildId
- close events carry closerType / resolverId (claimer credit) / wasClaimed;
  transfer carries fromId / toId; reopen stamps resolverId
- conditional close transition helper (atomic open->closed + closedAt) shared
  by all four close paths

Query + command:
- pure period parser (presets + free-text) and stats shaper (per-metric keys)
- command-aware autocomplete dispatch
- /stats: period (autocomplete) + member (admin-gated) + source (all/email/
  discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed

288+ unit tests; timing/busiest-times data is collected but displayed later.
2026-06-05 02:02:48 +00:00

140 lines
4.5 KiB
JavaScript

'use strict';
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { parsePeriod, shapeStats } = require('../../services/statsShaping');
const PERIOD_PRESETS = ['7 days', '30 days', '3 months', '6 months', '1 year'];
const TIER_LABELS = { 1: 'Tier 2', 2: 'Tier 3' };
function tierLabel(n) {
return TIER_LABELS[n] || `Tier ${n + 1}`;
}
function formatTierMap(obj) {
const keys = Object.keys(obj).map(Number).sort((a, b) => a - b);
if (!keys.length) return '0';
return keys.map(k => `${tierLabel(k)}: ${obj[k]}`).join(', ');
}
async function handleStatsAutocomplete(interaction) {
const focused = (interaction.options.getFocused() || '').trim();
const lower = focused.toLowerCase();
const suggestions = PERIOD_PRESETS
.filter(p => !focused || p.toLowerCase().includes(lower))
.map(p => ({ name: p, value: p }));
// Echo typed input as an extra suggestion when it differs from all presets.
if (focused && !PERIOD_PRESETS.some(p => p.toLowerCase() === lower)) {
suggestions.unshift({ name: focused, value: focused });
}
await interaction.respond(suggestions.slice(0, 25));
}
async function handleStats(interaction, _deps) {
const StaffAction = (_deps && _deps.StaffAction) || mongoose.model('StaffAction');
const nowMs = (_deps && typeof _deps.now === 'function') ? _deps.now() : Date.now();
const adminIds = (_deps && _deps.adminIds != null) ? _deps.adminIds : CONFIG.STATS_ADMIN_IDS;
const memberUser = interaction.options.getUser('member');
const periodStr = interaction.options.getString('period');
const source = interaction.options.getString('source') || 'all';
if (memberUser && !adminIds.includes(interaction.user.id)) {
return interaction.reply({
content: 'You can only view your own stats.',
flags: MessageFlags.Ephemeral
});
}
const target = memberUser ? memberUser.id : interaction.user.id;
const period = parsePeriod(periodStr);
const cutoff = new Date(nowMs - period.durationMs);
let events;
try {
events = await StaffAction.find({
createdAt: { $gte: cutoff },
$or: [
{ staffId: target },
{ resolverId: target },
{ toId: target },
{ fromId: target }
]
}).lean();
} catch (err) {
console.error('handleStats query error:', err);
return interaction.reply({
content: 'Failed to load stats. Please try again.',
flags: MessageFlags.Ephemeral
}).catch(() => {});
}
const stats = shapeStats(events, target, source);
const targetName = memberUser ? memberUser.username : interaction.user.username;
const sourceLabel = source === 'all' ? 'all sources' : source;
const cweKeys = Object.keys(stats.claimsWhileEscalated).map(Number).sort((a, b) => a - b);
const cweText = cweKeys.length
? `\n↳ while escalated: ${cweKeys.map(k => `${tierLabel(k)}: ${stats.claimsWhileEscalated[k]}`).join(', ')}`
: '';
const embed = new EmbedBuilder()
.setTitle(`Stats — ${targetName}${period.label}`)
.setDescription(`Source: ${sourceLabel}`)
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields([
{
name: 'Claims',
value: `${stats.claims}${cweText}`,
inline: true
},
{
name: 'Closes',
value: `${stats.closes} (unclaimed: ${stats.unclaimedAtClose})`,
inline: true
},
{
name: 'Resolved (credit)',
value: `${stats.resolved}`,
inline: true
},
{
name: 'Escalations',
value: formatTierMap(stats.escalations),
inline: true
},
{
name: 'De-escalations',
value: formatTierMap(stats.deescalations),
inline: true
},
{
name: 'Transfers',
value: `In: ${stats.transfersIn} | Out: ${stats.transfersOut}`,
inline: true
},
{
name: 'Reopens',
value: `${stats.reopens}`,
inline: true
},
{
name: 'Email / Discord split',
value: [
`Email — claims: ${stats.bySource.email.claims}, closes: ${stats.bySource.email.closes}, resolved: ${stats.bySource.email.resolved}`,
`Discord — claims: ${stats.bySource.discord.claims}, closes: ${stats.bySource.discord.closes}, resolved: ${stats.bySource.discord.resolved}`
].join('\n'),
inline: false
}
]);
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
module.exports = { handleStats, handleStatsAutocomplete };