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:
@@ -15,7 +15,9 @@ const { logTicketEvent, logError } = require('../../services/debugLog');
|
||||
const { moveThreadToFolder } = require('../../services/gmailLabels');
|
||||
const { pendingCloses } = require('../pendingCloses');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
const { attemptCloseTransition } = require('../../services/tickets');
|
||||
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
|
||||
const { recordAction } = require('../../services/staffStats');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -58,22 +60,31 @@ async function handleForceClose(interaction) {
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag });
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag, closerId: interaction.user.id });
|
||||
}
|
||||
|
||||
/** Performs the actual force-close work after the countdown elapses. */
|
||||
async function finalizeForceClose(channelRef, clientRef) {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) {
|
||||
const T = _TicketModel || Ticket;
|
||||
const record = _recordAction || recordAction;
|
||||
const pc = _pendingCloses || pendingCloses;
|
||||
const pending = pc.get(channelRef.id);
|
||||
pc.delete(channelRef.id);
|
||||
const closerId = pending?.closerId ?? null;
|
||||
const freshTicket = await T.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
|
||||
// a stale message ID pointing into the now-deleted channel.
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||
);
|
||||
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T);
|
||||
if (transitioned) {
|
||||
record(closerId ?? 'system', 'close', {
|
||||
ticket: closedTicket,
|
||||
guildId: channelRef.guild?.id,
|
||||
closerType: closerId ? 'staff' : 'system',
|
||||
resolverId: closedTicket.claimerId ?? null,
|
||||
wasClaimed: Boolean(closedTicket.claimerId)
|
||||
});
|
||||
}
|
||||
|
||||
// File the email thread into the Resolved folder — non-fatal, email tickets only.
|
||||
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
|
||||
@@ -116,4 +127,4 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
|
||||
}
|
||||
|
||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose, finalizeForceClose };
|
||||
|
||||
Reference in New Issue
Block a user