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

@@ -7,21 +7,26 @@ const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog');
const { recordAction } = require('../services/staffStats');
const Ticket = mongoose.model('Ticket');
/**
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
*/
async function handleDiscordReply(m) {
async function handleDiscordReply(m, _TicketModel, _recordAction, _isStaff) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const checkIsStaff = _isStaff || isStaff;
if (m.author.bot || m.interaction) return;
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
const ticket = await T.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return;
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = isStaff(memberForCheck);
Ticket.updateOne(
const isStaffMember = checkIsStaff(memberForCheck);
T.updateOne(
{ discordThreadId: m.channel.id },
{ $set: { lastActivity: new Date() } }
).catch(err => logError('updateActivity', err).catch(() => {}));
@@ -46,6 +51,10 @@ async function handleDiscordReply(m) {
}
}
if (isStaffMember) {
record(m.author.id, 'response', { ticket, guildId: m.guild?.id });
}
if (ticket.gmailThreadId.startsWith('discord-')) {
return;
}