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.
This commit is contained in:
2026-06-05 02:02:48 +00:00
parent 0fcffe8d33
commit cdb5db0082
28 changed files with 3447 additions and 124 deletions

View File

@@ -15,7 +15,9 @@ const { logTicketEvent, logError } = require('../../services/debugLog');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const { attemptCloseTransition } = require('../../services/tickets');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket');
@@ -58,22 +60,31 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag });
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag, closerId: interaction.user.id });
}
/** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const pc = _pendingCloses || pendingCloses;
const pending = pc.get(channelRef.id);
pc.delete(channelRef.id);
const closerId = pending?.closerId ?? null;
const freshTicket = await T.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
);
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T);
if (transitioned) {
record(closerId ?? 'system', 'close', {
ticket: closedTicket,
guildId: channelRef.guild?.id,
closerType: closerId ? 'staff' : 'system',
resolverId: closedTicket.claimerId ?? null,
wasClaimed: Boolean(closedTicket.claimerId)
});
}
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
@@ -116,4 +127,4 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
}
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose, finalizeForceClose };