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

@@ -26,6 +26,7 @@ const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDis
const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents');
const { recordAction } = require('./services/staffStats');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -250,6 +251,54 @@ function oauthSuspendIfPermanent(err, client) {
return true;
}
// ============================================================
// Email ticket persistence (Part A: game; Part B: reopen recording)
// ============================================================
/**
* Upsert the email ticket record and, when wasReopened is true, fire-and-forget
* a 'reopen' StaffAction with resolverId = the prior claimerId from the
* returned doc (claimerId is never cleared by any close path).
*
* Injectables: _Ticket (Ticket model), _recordAction (staffStats.recordAction).
* Exported for unit testing.
*/
async function persistEmailTicket(fields, guildId, wasReopened, _Ticket, _recordAction) {
const {
threadId, discordThreadId, senderEmail, subject, createdAt,
ticketNumber, priority, parentCategoryId, game
} = fields;
const doc = await withRetry(() => _Ticket.findOneAndUpdate(
{ gmailThreadId: threadId },
{
$set: {
discordThreadId,
senderEmail,
subject,
createdAt,
status: 'open',
ticketNumber,
priority,
lastActivity: createdAt,
parentCategoryId,
game
}
},
{ upsert: true, new: true }
));
if (wasReopened && doc) {
_recordAction('system', 'reopen', {
ticket: doc,
guildId,
resolverId: doc.claimerId ?? null
});
}
return doc;
}
// ============================================================
// Orchestrator
// ============================================================
@@ -287,6 +336,7 @@ async function poll(client) {
.select('gmailThreadId discordThreadId status')
.lean();
const wasClosedTicket = !!existing && existing.status === 'closed';
let ticketChan = null;
let parentCategoryIdForTicket = null;
let isReopened = false;
@@ -368,23 +418,23 @@ async function poll(client) {
const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate(
{ gmailThreadId: parsed.threadId },
await persistEmailTicket(
{
$set: {
discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail,
subject: parsed.subject,
createdAt: now,
status: 'open',
ticketNumber: number,
priority: defaultPriority,
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
}
threadId: parsed.threadId,
discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail,
subject: parsed.subject,
createdAt: now,
ticketNumber: number,
priority: defaultPriority,
parentCategoryId: parentCategoryIdForTicket,
game: detectedGame
},
{ upsert: true, new: true }
));
guild.id,
wasClosedTicket,
Ticket,
recordAction
);
// New (or reopened) ticket: file the email thread into Triage — out of
// the inbox, marked read, awaiting staff action. The threads.modify also
@@ -404,4 +454,4 @@ async function poll(client) {
}
}
module.exports = { poll, setPollSuspended };
module.exports = { poll, setPollSuspended, persistEmailTicket };