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 6bae3e79b1
commit e77be9a3e4
28 changed files with 3447 additions and 124 deletions

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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(() => {}));

View File

@@ -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
};

View File

@@ -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
View 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 };

View File

@@ -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;
}