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

@@ -19,6 +19,7 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils');
const { setNotifyDm } = require('../../services/staffSettings');
const { recordAction } = require('../../services/staffStats');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logError, logTicketEvent } = require('../../services/debugLog');
@@ -29,9 +30,10 @@ const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response');
const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel');
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
const { handleStats, handleStatsAutocomplete } = require('./stats');
const Ticket = mongoose.model('Ticket');
@@ -92,6 +94,57 @@ async function handleRemove(interaction) {
}
}
async function applyTransfer(interaction, ticket, guildMember, reason, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const fromId = ticket.claimerId; // capture BEFORE the write
const toId = guildMember.id;
const claimerLabel = guildMember.displayName || guildMember.user.username;
await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: toId } }
);
ticket.claimedBy = claimerLabel;
ticket.claimerId = toId;
// Gate: transferring to the member who already holds the claim is a no-op.
if (fromId !== toId) {
record(interaction.user.id, 'transfer', {
ticket,
guildId: interaction.guild?.id,
fromId,
toId
});
}
// Rename the channel to reflect the new claimer — mirrors the /claim
// button flow (applyClaim in handlers/buttons.js). Picks the new
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
// variant when tier >= 1.
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
.catch(err => logError('rename', err).catch(() => {}));
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.editReply({
content: `Ticket transferred to ${guildMember.user} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${guildMember.user.tag}.\nReason: ${reason}`,
allowedMentions: { parse: [] }
});
}
}
async function handleTransfer(interaction) {
const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided';
@@ -124,39 +177,7 @@ async function handleTransfer(interaction) {
await interaction.deferReply();
try {
const claimerLabel = guildMember.displayName || guildMember.user.username;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
);
ticket.claimedBy = claimerLabel;
ticket.claimerId = guildMember.id;
// Rename the channel to reflect the new claimer — mirrors the /claim
// button flow (applyClaim in handlers/buttons.js). Picks the new
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
// variant when tier >= 1.
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
.catch(err => logError('rename', err).catch(() => {}));
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.editReply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: [] }
});
}
await applyTransfer(interaction, ticket, guildMember, reason);
} catch (err) {
console.error('Transfer error:', err);
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
@@ -411,7 +432,8 @@ const COMMAND_HANDLERS = {
response: handleResponse,
signature: handleSignature,
help: handleHelp,
panel: handlePanel
panel: handlePanel,
stats: handleStats
};
const CONTEXT_MENU_HANDLERS = {
@@ -419,6 +441,17 @@ const CONTEXT_MENU_HANDLERS = {
'View User Tickets': handleViewUserTickets
};
const AUTOCOMPLETE_HANDLERS = {
response: handleResponseAutocomplete,
stats: handleStatsAutocomplete
};
async function handleAutocomplete(interaction, _handlers) {
const handlers = _handlers || AUTOCOMPLETE_HANDLERS;
const handler = handlers[interaction.commandName];
if (handler) await handler(interaction);
}
/**
* Slash-command dispatcher. Every command is staff-only — including /help,
* which previously bypassed the role check.
@@ -442,5 +475,6 @@ module.exports = {
handleAutocomplete,
runEscalation,
runDeescalation,
resolveEscalationCategoryId
resolveEscalationCategoryId,
applyTransfer
};