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:
@@ -22,12 +22,13 @@ const {
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition } = require('../services/tickets');
|
||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||
const { moveThreadToFolder } = require('../services/gmailLabels');
|
||||
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
|
||||
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
|
||||
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
||||
const { sanitizeEmbedText, truncateEmbedDescription, isStaff } = require('../utils');
|
||||
const { recordAction } = require('../services/staffStats');
|
||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
@@ -158,14 +159,24 @@ async function handleClaimButton(interaction, ticket) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild, _TicketModel, _recordAction) {
|
||||
const T = _TicketModel || Ticket;
|
||||
const record = _recordAction || recordAction;
|
||||
|
||||
const result = await T.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId, claimerId: { $ne: interaction.user.id } },
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
||||
);
|
||||
freshTicket.claimedBy = claimerLabel;
|
||||
freshTicket.claimerId = interaction.user.id;
|
||||
|
||||
if (result.modifiedCount === 1) {
|
||||
record(interaction.user.id, 'claim', {
|
||||
ticket: freshTicket,
|
||||
guildId: interaction.guild?.id
|
||||
});
|
||||
}
|
||||
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||
@@ -442,12 +453,17 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
||||
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
||||
}
|
||||
|
||||
// $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: ticket.gmailThreadId },
|
||||
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||
);
|
||||
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, { welcomeMessageId: '' });
|
||||
if (transitioned) {
|
||||
const closerType = isStaff(interaction.member) ? 'staff' : 'user';
|
||||
recordAction(interaction.user.id, 'close', {
|
||||
ticket: closedTicket,
|
||||
guildId: interaction.guild?.id,
|
||||
closerType,
|
||||
resolverId: closedTicket.claimerId ?? null,
|
||||
wasClaimed: Boolean(closedTicket.claimerId)
|
||||
});
|
||||
}
|
||||
|
||||
// File the email thread into the Resolved folder — non-fatal, email tickets only.
|
||||
if (!ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
@@ -754,4 +770,4 @@ async function handleButton(interaction) {
|
||||
return ticketHandler(interaction, ticket);
|
||||
}
|
||||
|
||||
module.exports = { handleButton, handleTicketModal };
|
||||
module.exports = { handleButton, handleTicketModal, runFinalClose, applyClaim };
|
||||
|
||||
Reference in New Issue
Block a user