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 6bae3e79b1
commit e77be9a3e4
28 changed files with 3447 additions and 124 deletions

View File

@@ -17,6 +17,7 @@ const { pinMessage } = require('../../services/pinMessage');
const { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket');
@@ -37,19 +38,29 @@ function resolveEscalationCategoryId(ticket, nextTier) {
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred.
*/
async function runEscalation(interaction, ticket, nextTier) {
async function runEscalation(interaction, ticket, nextTier, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
// Clear claim on escalation
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
// Conditional write: only update if the tier hasn't already been set to nextTier.
// modifiedCount === 0 means a concurrent request already escalated — no event.
const result = await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: nextTier } },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'escalate', {
ticket,
guildId: interaction.guild?.id
});
}
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
@@ -136,19 +147,30 @@ async function runEscalation(interaction, ticket, nextTier) {
}
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
async function runDeescalation(interaction, ticket) {
async function runDeescalation(interaction, ticket, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
// Conditional write: only update if the tier hasn't already been set to newTier.
// modifiedCount === 0 means a concurrent request already deescalated — no event.
const result = await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: newTier } },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'deescalate', {
ticket,
guildId: interaction.guild?.id
});
}
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));