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:
@@ -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(() => {}));
|
||||
|
||||
Reference in New Issue
Block a user