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.
140 lines
4.5 KiB
JavaScript
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 };
|