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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => {}));
|
||||
|
||||
@@ -19,6 +19,7 @@ const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { recordAction } = require('../../services/staffStats');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||
@@ -29,10 +30,11 @@ const { findTicketForChannel } = require('../sharedHelpers');
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
|
||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||
const { handleResponse, handleAutocomplete } = require('./response');
|
||||
const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response');
|
||||
const { handlePanel, handleSignature } = require('./panel');
|
||||
const { handleForward } = require('./forward');
|
||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||
const { handleStats, handleStatsAutocomplete } = require('./stats');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -93,6 +95,57 @@ async function handleRemove(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTransfer(interaction, ticket, guildMember, reason, _TicketModel, _recordAction) {
|
||||
const T = _TicketModel || Ticket;
|
||||
const record = _recordAction || recordAction;
|
||||
|
||||
const fromId = ticket.claimerId; // capture BEFORE the write
|
||||
const toId = guildMember.id;
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await T.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: toId } }
|
||||
);
|
||||
ticket.claimedBy = claimerLabel;
|
||||
ticket.claimerId = toId;
|
||||
|
||||
// Gate: transferring to the member who already holds the claim is a no-op.
|
||||
if (fromId !== toId) {
|
||||
record(interaction.user.id, 'transfer', {
|
||||
ticket,
|
||||
guildId: interaction.guild?.id,
|
||||
fromId,
|
||||
toId
|
||||
});
|
||||
}
|
||||
|
||||
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||
// variant when tier >= 1.
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.editReply({
|
||||
content: `Ticket transferred to ${guildMember.user} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${guildMember.user.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(interaction) {
|
||||
const member = interaction.options.getUser('member');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
@@ -125,39 +178,7 @@ async function handleTransfer(interaction) {
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
|
||||
);
|
||||
ticket.claimedBy = claimerLabel;
|
||||
ticket.claimerId = guildMember.id;
|
||||
|
||||
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||
// variant when tier >= 1.
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.editReply({
|
||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
}
|
||||
await applyTransfer(interaction, ticket, guildMember, reason);
|
||||
} catch (err) {
|
||||
console.error('Transfer error:', err);
|
||||
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
||||
@@ -413,7 +434,8 @@ const COMMAND_HANDLERS = {
|
||||
response: handleResponse,
|
||||
signature: handleSignature,
|
||||
help: handleHelp,
|
||||
panel: handlePanel
|
||||
panel: handlePanel,
|
||||
stats: handleStats
|
||||
};
|
||||
|
||||
const CONTEXT_MENU_HANDLERS = {
|
||||
@@ -421,6 +443,17 @@ const CONTEXT_MENU_HANDLERS = {
|
||||
'View User Tickets': handleViewUserTickets
|
||||
};
|
||||
|
||||
const AUTOCOMPLETE_HANDLERS = {
|
||||
response: handleResponseAutocomplete,
|
||||
stats: handleStatsAutocomplete
|
||||
};
|
||||
|
||||
async function handleAutocomplete(interaction, _handlers) {
|
||||
const handlers = _handlers || AUTOCOMPLETE_HANDLERS;
|
||||
const handler = handlers[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash-command dispatcher. Every command is staff-only — including /help,
|
||||
* which previously bypassed the role check.
|
||||
@@ -444,5 +477,6 @@ module.exports = {
|
||||
handleAutocomplete,
|
||||
runEscalation,
|
||||
runDeescalation,
|
||||
resolveEscalationCategoryId
|
||||
resolveEscalationCategoryId,
|
||||
applyTransfer
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { replaceVariables } = require('../../utils');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
const { recordAction } = require('../../services/staffStats');
|
||||
|
||||
const Tag = mongoose.model('Tag');
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -38,14 +39,18 @@ async function handleResponse(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseSend(interaction) {
|
||||
async function handleResponseSend(interaction, _TagModel, _TicketModel, _recordAction) {
|
||||
const TTag = _TagModel || Tag;
|
||||
const TTicket = _TicketModel || Ticket;
|
||||
const record = _recordAction || recordAction;
|
||||
|
||||
const name = interaction.options.getString('name');
|
||||
const tag = await Tag.findOne({ name }).lean();
|
||||
const tag = await TTag.findOne({ name }).lean();
|
||||
if (!tag) {
|
||||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
const ticket = await TTicket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
const context = {
|
||||
ticket: ticket || {},
|
||||
staff: {
|
||||
@@ -57,10 +62,14 @@ async function handleResponseSend(interaction) {
|
||||
};
|
||||
|
||||
const content = replaceVariables(tag.content, context);
|
||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
await TTag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||
|
||||
if (ticket) {
|
||||
record(interaction.user.id, 'response', { ticket, guildId: interaction.guild?.id });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseCreate(interaction) {
|
||||
@@ -146,9 +155,8 @@ const RESPONSE_SUBCOMMANDS = {
|
||||
list: handleResponseList
|
||||
};
|
||||
|
||||
/** Autocomplete handler. Currently only /response uses it. */
|
||||
/** Autocomplete handler for /response. Routed here by the dispatcher in index.js. */
|
||||
async function handleAutocomplete(interaction) {
|
||||
if (interaction.commandName !== 'response') return;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||
|
||||
@@ -162,4 +170,4 @@ async function handleAutocomplete(interaction) {
|
||||
await interaction.respond(filtered);
|
||||
}
|
||||
|
||||
module.exports = { handleResponse, handleAutocomplete };
|
||||
module.exports = { handleResponse, handleAutocomplete, handleResponseSend };
|
||||
|
||||
139
handlers/commands/stats.js
Normal file
139
handlers/commands/stats.js
Normal file
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { parsePeriod, shapeStats } = require('../../services/statsShaping');
|
||||
|
||||
const PERIOD_PRESETS = ['7 days', '30 days', '3 months', '6 months', '1 year'];
|
||||
const TIER_LABELS = { 1: 'Tier 2', 2: 'Tier 3' };
|
||||
|
||||
function tierLabel(n) {
|
||||
return TIER_LABELS[n] || `Tier ${n + 1}`;
|
||||
}
|
||||
|
||||
function formatTierMap(obj) {
|
||||
const keys = Object.keys(obj).map(Number).sort((a, b) => a - b);
|
||||
if (!keys.length) return '0';
|
||||
return keys.map(k => `${tierLabel(k)}: ${obj[k]}`).join(', ');
|
||||
}
|
||||
|
||||
async function handleStatsAutocomplete(interaction) {
|
||||
const focused = (interaction.options.getFocused() || '').trim();
|
||||
const lower = focused.toLowerCase();
|
||||
|
||||
const suggestions = PERIOD_PRESETS
|
||||
.filter(p => !focused || p.toLowerCase().includes(lower))
|
||||
.map(p => ({ name: p, value: p }));
|
||||
|
||||
// Echo typed input as an extra suggestion when it differs from all presets.
|
||||
if (focused && !PERIOD_PRESETS.some(p => p.toLowerCase() === lower)) {
|
||||
suggestions.unshift({ name: focused, value: focused });
|
||||
}
|
||||
|
||||
await interaction.respond(suggestions.slice(0, 25));
|
||||
}
|
||||
|
||||
async function handleStats(interaction, _deps) {
|
||||
const StaffAction = (_deps && _deps.StaffAction) || mongoose.model('StaffAction');
|
||||
const nowMs = (_deps && typeof _deps.now === 'function') ? _deps.now() : Date.now();
|
||||
const adminIds = (_deps && _deps.adminIds != null) ? _deps.adminIds : CONFIG.STATS_ADMIN_IDS;
|
||||
|
||||
const memberUser = interaction.options.getUser('member');
|
||||
const periodStr = interaction.options.getString('period');
|
||||
const source = interaction.options.getString('source') || 'all';
|
||||
|
||||
if (memberUser && !adminIds.includes(interaction.user.id)) {
|
||||
return interaction.reply({
|
||||
content: 'You can only view your own stats.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
const target = memberUser ? memberUser.id : interaction.user.id;
|
||||
const period = parsePeriod(periodStr);
|
||||
const cutoff = new Date(nowMs - period.durationMs);
|
||||
|
||||
let events;
|
||||
try {
|
||||
events = await StaffAction.find({
|
||||
createdAt: { $gte: cutoff },
|
||||
$or: [
|
||||
{ staffId: target },
|
||||
{ resolverId: target },
|
||||
{ toId: target },
|
||||
{ fromId: target }
|
||||
]
|
||||
}).lean();
|
||||
} catch (err) {
|
||||
console.error('handleStats query error:', err);
|
||||
return interaction.reply({
|
||||
content: 'Failed to load stats. Please try again.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const stats = shapeStats(events, target, source);
|
||||
|
||||
const targetName = memberUser ? memberUser.username : interaction.user.username;
|
||||
const sourceLabel = source === 'all' ? 'all sources' : source;
|
||||
|
||||
const cweKeys = Object.keys(stats.claimsWhileEscalated).map(Number).sort((a, b) => a - b);
|
||||
const cweText = cweKeys.length
|
||||
? `\n↳ while escalated: ${cweKeys.map(k => `${tierLabel(k)}: ${stats.claimsWhileEscalated[k]}`).join(', ')}`
|
||||
: '';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Stats — ${targetName} — ${period.label}`)
|
||||
.setDescription(`Source: ${sourceLabel}`)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields([
|
||||
{
|
||||
name: 'Claims',
|
||||
value: `${stats.claims}${cweText}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Closes',
|
||||
value: `${stats.closes} (unclaimed: ${stats.unclaimedAtClose})`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Resolved (credit)',
|
||||
value: `${stats.resolved}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Escalations',
|
||||
value: formatTierMap(stats.escalations),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'De-escalations',
|
||||
value: formatTierMap(stats.deescalations),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Transfers',
|
||||
value: `In: ${stats.transfersIn} | Out: ${stats.transfersOut}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Reopens',
|
||||
value: `${stats.reopens}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: 'Email / Discord split',
|
||||
value: [
|
||||
`Email — claims: ${stats.bySource.email.claims}, closes: ${stats.bySource.email.closes}, resolved: ${stats.bySource.email.resolved}`,
|
||||
`Discord — claims: ${stats.bySource.discord.claims}, closes: ${stats.bySource.discord.closes}, resolved: ${stats.bySource.discord.resolved}`
|
||||
].join('\n'),
|
||||
inline: false
|
||||
}
|
||||
]);
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
module.exports = { handleStats, handleStatsAutocomplete };
|
||||
@@ -8,21 +8,26 @@ const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { autoAdvanceFolder } = require('../services/gmailLabels');
|
||||
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(() => {}));
|
||||
@@ -47,6 +52,10 @@ async function handleDiscordReply(m) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isStaffMember) {
|
||||
record(m.author.id, 'response', { ticket, guildId: m.guild?.id });
|
||||
}
|
||||
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user