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

54
services/staffStats.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const mongoose = require('mongoose');
const { logError } = require('./debugLog');
// Derives ticketType from gmailThreadId prefix.
// discord-* and discord-msg-* → 'discord'; everything else → 'email'.
function deriveTicketType(gmailThreadId) {
if (!gmailThreadId) return 'email';
if (gmailThreadId.startsWith('discord-')) return 'discord';
return 'email';
}
// Extracts the standard event fields from a ticket document.
// Returns a plain object — does NOT include guildId (call-site only).
function denormalizeTicket(ticket) {
if (!ticket) return {};
return {
ticketType: deriveTicketType(ticket.gmailThreadId),
tier: ticket.escalationTier ?? 0,
priority: ticket.priority,
game: ticket.game,
senderEmail: ticket.senderEmail,
creatorId: ticket.creatorId,
gmailThreadId: ticket.gmailThreadId
};
}
// recordAction(staffId, type, payload)
//
// payload may carry:
// ticket — a Ticket doc to denormalize standard fields from
// guildId — must come from the call site (not on the Ticket schema)
// any other StaffAction field — these override denormalized values
//
// Fire-and-forget: never throws, never blocks the caller.
// The outer try/catch catches synchronous errors (e.g. model not registered
// during early boot); the inner .catch handles async DB rejections.
function recordAction(staffId, type, payload) {
try {
const { ticket, ...overrides } = payload || {};
const base = denormalizeTicket(ticket);
const doc = { staffId, type, ...base, ...overrides };
const StaffAction = mongoose.model('StaffAction');
StaffAction.create(doc).catch(err => {
logError('staffStats.recordAction', err);
});
} catch (err) {
logError('staffStats.recordAction', err);
}
}
module.exports = { recordAction, denormalizeTicket, deriveTicketType };