1 Commits

Author SHA1 Message Date
cdb5db0082 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.
2026-06-05 02:02:48 +00:00
28 changed files with 3447 additions and 124 deletions

View File

@@ -76,6 +76,7 @@ RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disable
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissions ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissions
STATS_ADMIN_IDS= # Comma-separated Discord user IDs allowed to view other members' /stats (e.g. 321754640431710226,691678135527276614)
# --- Auto-close --- # --- Auto-close ---
AUTO_CLOSE_ENABLED=false AUTO_CLOSE_ENABLED=false

View File

@@ -415,7 +415,38 @@ async function registerCommands() {
.setDescription('Set your personal email signature (valediction, display name, tagline)') .setDescription('Set your personal email signature (valediction, display name, tagline)')
.setContexts([InteractionContextType.Guild]) .setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
new SlashCommandBuilder()
.setName('stats')
.setDescription('View staff metrics for a given time window')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('period')
.setDescription('Time window (e.g. "30 days", "3 months", "1 year")')
.setRequired(false)
.setAutocomplete(true)
)
.addUserOption(opt =>
opt
.setName('member')
.setDescription('Staff member to view stats for (admin only)')
.setRequired(false)
)
.addStringOption(opt =>
opt
.setName('source')
.setDescription('Filter by ticket source')
.setRequired(false)
.addChoices(
{ name: 'All', value: 'all' },
{ name: 'Email', value: 'email' },
{ name: 'Discord', value: 'discord' }
)
)
]; ];
const contextMenuCommands = [ const contextMenuCommands = [

View File

@@ -53,6 +53,7 @@ const CONFIG = {
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
STATS_ADMIN_IDS: (process.env.STATS_ADMIN_IDS || '').split(',').map(r => r.trim()).filter(Boolean),
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',

View File

@@ -26,6 +26,7 @@ const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDis
const { logError } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue'); const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents'); const { getTicketActionRow } = require('./utils/ticketComponents');
const { recordAction } = require('./services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript'); const Transcript = mongoose.model('Transcript');
@@ -250,6 +251,54 @@ function oauthSuspendIfPermanent(err, client) {
return true; return true;
} }
// ============================================================
// Email ticket persistence (Part A: game; Part B: reopen recording)
// ============================================================
/**
* Upsert the email ticket record and, when wasReopened is true, fire-and-forget
* a 'reopen' StaffAction with resolverId = the prior claimerId from the
* returned doc (claimerId is never cleared by any close path).
*
* Injectables: _Ticket (Ticket model), _recordAction (staffStats.recordAction).
* Exported for unit testing.
*/
async function persistEmailTicket(fields, guildId, wasReopened, _Ticket, _recordAction) {
const {
threadId, discordThreadId, senderEmail, subject, createdAt,
ticketNumber, priority, parentCategoryId, game
} = fields;
const doc = await withRetry(() => _Ticket.findOneAndUpdate(
{ gmailThreadId: threadId },
{
$set: {
discordThreadId,
senderEmail,
subject,
createdAt,
status: 'open',
ticketNumber,
priority,
lastActivity: createdAt,
parentCategoryId,
game
}
},
{ upsert: true, new: true }
));
if (wasReopened && doc) {
_recordAction('system', 'reopen', {
ticket: doc,
guildId,
resolverId: doc.claimerId ?? null
});
}
return doc;
}
// ============================================================ // ============================================================
// Orchestrator // Orchestrator
// ============================================================ // ============================================================
@@ -287,6 +336,7 @@ async function poll(client) {
.select('gmailThreadId discordThreadId status') .select('gmailThreadId discordThreadId status')
.lean(); .lean();
const wasClosedTicket = !!existing && existing.status === 'closed';
let ticketChan = null; let ticketChan = null;
let parentCategoryIdForTicket = null; let parentCategoryIdForTicket = null;
let isReopened = false; let isReopened = false;
@@ -368,23 +418,23 @@ async function poll(client) {
const now = new Date(); const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate( await persistEmailTicket(
{ gmailThreadId: parsed.threadId },
{ {
$set: { threadId: parsed.threadId,
discordThreadId: ticketChan.id, discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail, senderEmail: parsed.senderEmail,
subject: parsed.subject, subject: parsed.subject,
createdAt: now, createdAt: now,
status: 'open',
ticketNumber: number, ticketNumber: number,
priority: defaultPriority, priority: defaultPriority,
lastActivity: now, parentCategoryId: parentCategoryIdForTicket,
parentCategoryId: parentCategoryIdForTicket game: detectedGame
}
}, },
{ upsert: true, new: true } guild.id,
)); wasClosedTicket,
Ticket,
recordAction
);
// New (or reopened) ticket: file the email thread into Triage — out of // New (or reopened) ticket: file the email thread into Triage — out of
// the inbox, marked read, awaiting staff action. The threads.modify also // the inbox, marked read, awaiting staff action. The threads.modify also
@@ -404,4 +454,4 @@ async function poll(client) {
} }
} }
module.exports = { poll, setPollSuspended }; module.exports = { poll, setPollSuspended, persistEmailTicket };

View File

@@ -22,12 +22,13 @@ const {
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); 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 { sendTicketClosedEmail } = require('../services/gmail');
const { moveThreadToFolder } = require('../services/gmailLabels'); const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript'); 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 { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands'); const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
@@ -158,14 +159,24 @@ async function handleClaimButton(interaction, ticket) {
} }
} }
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) { async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild, _TicketModel, _recordAction) {
await Ticket.updateOne( const T = _TicketModel || Ticket;
{ gmailThreadId: freshTicket.gmailThreadId }, const record = _recordAction || recordAction;
const result = await T.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId, claimerId: { $ne: interaction.user.id } },
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
); );
freshTicket.claimedBy = claimerLabel; freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id; 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 claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; 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); await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
} }
// $unset welcomeMessageId so a future reopen on this thread doesn't carry const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, { welcomeMessageId: '' });
// a stale message ID pointing into the now-deleted channel. if (transitioned) {
await Ticket.updateOne( const closerType = isStaff(interaction.member) ? 'staff' : 'user';
{ gmailThreadId: ticket.gmailThreadId }, recordAction(interaction.user.id, 'close', {
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } } 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. // File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) { if (!ticket.gmailThreadId?.startsWith('discord-')) {
@@ -754,4 +770,4 @@ async function handleButton(interaction) {
return ticketHandler(interaction, ticket); 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 { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses'); const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { attemptCloseTransition } = require('../../services/tickets');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -58,22 +60,31 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel; const channelRef = interaction.channel;
const clientRef = interaction.client; const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); 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. */ /** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) { async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) {
pendingCloses.delete(channelRef.id); const T = _TicketModel || Ticket;
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean(); 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; if (!freshTicket || freshTicket.status === 'closed') return;
try { try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T);
// a stale message ID pointing into the now-deleted channel. if (transitioned) {
await Ticket.updateOne( record(closerId ?? 'system', 'close', {
{ gmailThreadId: freshTicket.gmailThreadId }, ticket: closedTicket,
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } } 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. // File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!freshTicket.gmailThreadId.startsWith('discord-')) { if (!freshTicket.gmailThreadId.startsWith('discord-')) {
@@ -116,4 +127,4 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } }); 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 { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers'); const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers'); const { fetchLoggingChannel } = require('./helpers');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket'); 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 * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred. * 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 isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = resolveEscalationCategoryId(ticket, nextTier); const categoryId = resolveEscalationCategoryId(ticket, nextTier);
// Clear claim on escalation // Conditional write: only update if the tier hasn't already been set to nextTier.
await Ticket.updateOne( // modifiedCount === 0 means a concurrent request already escalated — no event.
{ gmailThreadId: ticket.gmailThreadId }, const result = await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: nextTier } },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
); );
ticket.escalated = true; ticket.escalated = true;
ticket.escalationTier = nextTier; ticket.escalationTier = nextTier;
ticket.claimedBy = null; 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 creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname); const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); 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. */ /** 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 currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1; const newTier = currentTier - 1;
await Ticket.updateOne( // Conditional write: only update if the tier hasn't already been set to newTier.
{ gmailThreadId: ticket.gmailThreadId }, // 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 } } { $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
); );
ticket.escalated = newTier > 0; ticket.escalated = newTier > 0;
ticket.escalationTier = newTier; ticket.escalationTier = newTier;
ticket.claimedBy = null; 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 creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated'; const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {})); 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 { CONFIG } = require('../../config');
const { isStaff } = require('../../utils'); const { isStaff } = require('../../utils');
const { setNotifyDm } = require('../../services/staffSettings'); const { setNotifyDm } = require('../../services/staffSettings');
const { recordAction } = require('../../services/staffStats');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logError, logTicketEvent } = require('../../services/debugLog'); const { logError, logTicketEvent } = require('../../services/debugLog');
@@ -29,9 +30,10 @@ const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response'); const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel'); const { handlePanel, handleSignature } = require('./panel');
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu'); const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
const { handleStats, handleStatsAutocomplete } = require('./stats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -92,6 +94,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) { async function handleTransfer(interaction) {
const member = interaction.options.getUser('member'); const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided'; const reason = interaction.options.getString('reason') || 'No reason provided';
@@ -124,39 +177,7 @@ async function handleTransfer(interaction) {
await interaction.deferReply(); await interaction.deferReply();
try { try {
const claimerLabel = guildMember.displayName || guildMember.user.username; await applyTransfer(interaction, ticket, guildMember, reason);
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: [] }
});
}
} catch (err) { } catch (err) {
console.error('Transfer error:', err); console.error('Transfer error:', err);
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {}); await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
@@ -411,7 +432,8 @@ const COMMAND_HANDLERS = {
response: handleResponse, response: handleResponse,
signature: handleSignature, signature: handleSignature,
help: handleHelp, help: handleHelp,
panel: handlePanel panel: handlePanel,
stats: handleStats
}; };
const CONTEXT_MENU_HANDLERS = { const CONTEXT_MENU_HANDLERS = {
@@ -419,6 +441,17 @@ const CONTEXT_MENU_HANDLERS = {
'View User Tickets': handleViewUserTickets '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, * Slash-command dispatcher. Every command is staff-only — including /help,
* which previously bypassed the role check. * which previously bypassed the role check.
@@ -442,5 +475,6 @@ module.exports = {
handleAutocomplete, handleAutocomplete,
runEscalation, runEscalation,
runDeescalation, runDeescalation,
resolveEscalationCategoryId resolveEscalationCategoryId,
applyTransfer
}; };

View File

@@ -17,6 +17,7 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { replaceVariables } = require('../../utils'); const { replaceVariables } = require('../../utils');
const { logError } = require('../../services/debugLog'); const { logError } = require('../../services/debugLog');
const { recordAction } = require('../../services/staffStats');
const Tag = mongoose.model('Tag'); const Tag = mongoose.model('Tag');
const Ticket = mongoose.model('Ticket'); 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 name = interaction.options.getString('name');
const tag = await Tag.findOne({ name }).lean(); const tag = await TTag.findOne({ name }).lean();
if (!tag) { if (!tag) {
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral }); 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 = { const context = {
ticket: ticket || {}, ticket: ticket || {},
staff: { staff: {
@@ -57,10 +62,14 @@ async function handleResponseSend(interaction) {
}; };
const content = replaceVariables(tag.content, context); 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. // 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. // Disable mention parsing so a `@everyone` in a tag body never pings.
await interaction.reply({ content, allowedMentions: { parse: [] } }); await interaction.reply({ content, allowedMentions: { parse: [] } });
if (ticket) {
record(interaction.user.id, 'response', { ticket, guildId: interaction.guild?.id });
}
} }
async function handleResponseCreate(interaction) { async function handleResponseCreate(interaction) {
@@ -146,9 +155,8 @@ const RESPONSE_SUBCOMMANDS = {
list: handleResponseList 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) { async function handleAutocomplete(interaction) {
if (interaction.commandName !== 'response') return;
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
if (!['send', 'edit', 'delete'].includes(subcommand)) return; if (!['send', 'edit', 'delete'].includes(subcommand)) return;
@@ -162,4 +170,4 @@ async function handleAutocomplete(interaction) {
await interaction.respond(filtered); 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

@@ -7,21 +7,26 @@ const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
const { recordAction } = require('../services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
/** /**
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only). * 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; 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; if (!ticket) return;
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = isStaff(memberForCheck); const isStaffMember = checkIsStaff(memberForCheck);
Ticket.updateOne( T.updateOne(
{ discordThreadId: m.channel.id }, { discordThreadId: m.channel.id },
{ $set: { lastActivity: new Date() } } { $set: { lastActivity: new Date() } }
).catch(err => logError('updateActivity', err).catch(() => {})); ).catch(err => logError('updateActivity', err).catch(() => {}));
@@ -46,6 +51,10 @@ async function handleDiscordReply(m) {
} }
} }
if (isStaffMember) {
record(m.author.id, 'response', { ticket, guildId: m.guild?.id });
}
if (ticket.gmailThreadId.startsWith('discord-')) { if (ticket.gmailThreadId.startsWith('discord-')) {
return; return;
} }

View File

@@ -20,7 +20,9 @@ const ticketSchema = new mongoose.Schema({
claimerId: String, claimerId: String,
creatorId: String, creatorId: String,
parentCategoryId: String, parentCategoryId: String,
pendingDelete: { type: Boolean, default: false } pendingDelete: { type: Boolean, default: false },
game: String,
closedAt: Date
}); });
ticketSchema.index({ status: 1, lastActivity: 1 }); ticketSchema.index({ status: 1, lastActivity: 1 });
ticketSchema.index({ senderEmail: 1, status: 1 }); ticketSchema.index({ senderEmail: 1, status: 1 });
@@ -61,3 +63,29 @@ mongoose.model('StaffSignature', new mongoose.Schema({
tagline: { type: String, default: '' }, tagline: { type: String, default: '' },
updatedAt: { type: Date, default: Date.now } updatedAt: { type: Date, default: Date.now }
})); }));
const staffActionSchema = new mongoose.Schema({
staffId: { type: String, required: true },
type: { type: String, required: true },
tier: { type: Number, default: 0 },
ticketType: String,
priority: String,
game: String,
senderEmail: String,
creatorId: String,
gmailThreadId: String,
guildId: String,
createdAt: { type: Date, default: Date.now },
// close-only
closerType: String,
resolverId: String,
wasClaimed: Boolean,
// transfer-only
fromId: String,
toId: String
});
staffActionSchema.index({ staffId: 1, createdAt: -1 });
staffActionSchema.index({ gmailThreadId: 1, createdAt: 1 });
mongoose.model('StaffAction', staffActionSchema);

View File

@@ -25,7 +25,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff // Roles and staff
'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID', 'ADMIN_ID', 'STATS_ADMIN_IDS',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
// Messages and labels // Messages and labels

54
services/staffStats.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const mongoose = require('mongoose');
const { logError } = require('./debugLog');
// Derives ticketType from gmailThreadId prefix.
// discord-* and discord-msg-* → 'discord'; everything else → 'email'.
function deriveTicketType(gmailThreadId) {
if (!gmailThreadId) return 'email';
if (gmailThreadId.startsWith('discord-')) return 'discord';
return 'email';
}
// Extracts the standard event fields from a ticket document.
// Returns a plain object — does NOT include guildId (call-site only).
function denormalizeTicket(ticket) {
if (!ticket) return {};
return {
ticketType: deriveTicketType(ticket.gmailThreadId),
tier: ticket.escalationTier ?? 0,
priority: ticket.priority,
game: ticket.game,
senderEmail: ticket.senderEmail,
creatorId: ticket.creatorId,
gmailThreadId: ticket.gmailThreadId
};
}
// recordAction(staffId, type, payload)
//
// payload may carry:
// ticket — a Ticket doc to denormalize standard fields from
// guildId — must come from the call site (not on the Ticket schema)
// any other StaffAction field — these override denormalized values
//
// Fire-and-forget: never throws, never blocks the caller.
// The outer try/catch catches synchronous errors (e.g. model not registered
// during early boot); the inner .catch handles async DB rejections.
function recordAction(staffId, type, payload) {
try {
const { ticket, ...overrides } = payload || {};
const base = denormalizeTicket(ticket);
const doc = { staffId, type, ...base, ...overrides };
const StaffAction = mongoose.model('StaffAction');
StaffAction.create(doc).catch(err => {
logError('staffStats.recordAction', err);
});
} catch (err) {
logError('staffStats.recordAction', err);
}
}
module.exports = { recordAction, denormalizeTicket, deriveTicketType };

154
services/statsShaping.js Normal file
View File

@@ -0,0 +1,154 @@
'use strict';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Months = 30 days, years = 365 days — fixed-day approximation for windowing only.
const MS = {
days: MS_PER_DAY,
weeks: 7 * MS_PER_DAY,
months: 30 * MS_PER_DAY,
years: 365 * MS_PER_DAY
};
const DEFAULT_PERIOD = Object.freeze({ durationMs: 30 * MS_PER_DAY, value: 30, unit: 'days', label: '30 days' });
/**
* parsePeriod(input) → { durationMs, value, unit, label }
*
* Accepts the autocomplete presets ("7 days", "30 days", "3 months", "6 months",
* "1 year") and free text: <n>d / day(s), <n>w / week(s), <n>m / mo / month(s),
* <n>y / year(s), or a bare integer (= days). Case- and whitespace-tolerant.
* Unparseable or zero input returns the 30-day default.
*
* The caller computes the cutoff as: cutoff = Date.now() - durationMs
* Month = 30 days, year = 365 days (windowing approximation, not calendar-accurate).
*/
function parsePeriod(input) {
if (input == null) return Object.assign({}, DEFAULT_PERIOD);
const s = String(input).trim().toLowerCase();
if (!s) return Object.assign({}, DEFAULT_PERIOD);
const match = s.match(/^(\d+)\s*(d|day|days|w|week|weeks|m|mo|month|months|y|year|years)?$/);
if (!match) return Object.assign({}, DEFAULT_PERIOD);
const n = parseInt(match[1], 10);
if (!n) return Object.assign({}, DEFAULT_PERIOD);
const unitStr = match[2];
let unit, durationMs;
if (!unitStr || unitStr === 'd' || unitStr === 'day' || unitStr === 'days') {
unit = 'days';
durationMs = n * MS.days;
} else if (unitStr === 'w' || unitStr === 'week' || unitStr === 'weeks') {
unit = 'weeks';
durationMs = n * MS.weeks;
} else if (unitStr === 'm' || unitStr === 'mo' || unitStr === 'month' || unitStr === 'months') {
unit = 'months';
durationMs = n * MS.months;
} else if (unitStr === 'y' || unitStr === 'year' || unitStr === 'years') {
unit = 'years';
durationMs = n * MS.years;
} else {
return Object.assign({}, DEFAULT_PERIOD);
}
const singular = unit.slice(0, -1);
const label = n === 1 ? `1 ${singular}` : `${n} ${unit}`;
return { durationMs, value: n, unit, label };
}
/**
* shapeStats(events, memberId, source) → counts object
*
* Pure aggregator over an array of StaffAction-shaped objects.
* source: 'all' | 'email' | 'discord' (default: 'all')
*
* Field keying:
* claims type 'claim', staffId === member
* claimsWhileEscalated above, tier > 0, grouped by numeric tier key
* closes type 'close', staffId === member
* resolved type 'close', resolverId === member (claimer credit)
* unclaimedAtClose type 'close', staffId === member, wasClaimed === false
* escalations type 'escalate', staffId === member, grouped by tier
* deescalations type 'deescalate', staffId === member, grouped by tier
* transfersIn type 'transfer', toId === member
* transfersOut type 'transfer', staffId === member (initiator)
* reopens type 'reopen', resolverId === member
*
* Tier labels (tier 1 → "Tier 2", tier 2 → "Tier 3") are NOT applied here;
* Phase 10 maps numeric tier keys to display labels.
*
* bySource breaks headline counts (claims, closes, resolved) by ticketType.
*/
function shapeStats(events, memberId, source) {
const src = source || 'all';
const pool = src === 'all'
? (events || [])
: (events || []).filter(e => e.ticketType === src);
const result = {
claims: 0,
claimsWhileEscalated: {},
closes: 0,
resolved: 0,
unclaimedAtClose: 0,
escalations: {},
deescalations: {},
transfersIn: 0,
transfersOut: 0,
reopens: 0,
bySource: {
email: { claims: 0, closes: 0, resolved: 0 },
discord: { claims: 0, closes: 0, resolved: 0 }
}
};
for (const e of pool) {
const tt = e.ticketType === 'discord' ? 'discord' : 'email';
if (e.type === 'claim' && e.staffId === memberId) {
result.claims++;
result.bySource[tt].claims++;
if (e.tier > 0) {
result.claimsWhileEscalated[e.tier] = (result.claimsWhileEscalated[e.tier] || 0) + 1;
}
}
if (e.type === 'close' && e.staffId === memberId) {
result.closes++;
result.bySource[tt].closes++;
if (e.wasClaimed === false) {
result.unclaimedAtClose++;
}
}
if (e.type === 'close' && e.resolverId === memberId) {
result.resolved++;
result.bySource[tt].resolved++;
}
if (e.type === 'escalate' && e.staffId === memberId) {
result.escalations[e.tier] = (result.escalations[e.tier] || 0) + 1;
}
if (e.type === 'deescalate' && e.staffId === memberId) {
result.deescalations[e.tier] = (result.deescalations[e.tier] || 0) + 1;
}
if (e.type === 'transfer' && e.toId === memberId) {
result.transfersIn++;
}
if (e.type === 'transfer' && e.staffId === memberId) {
result.transfersOut++;
}
if (e.type === 'reopen' && e.resolverId === memberId) {
result.reopens++;
}
}
return result;
}
module.exports = { parsePeriod, shapeStats };

View File

@@ -6,6 +6,7 @@ const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { enqueueSend, enqueueDelete } = require('./channelQueue'); const { enqueueSend, enqueueDelete } = require('./channelQueue');
const { recordAction } = require('./staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter'); const TicketCounter = mongoose.model('TicketCounter');
@@ -269,15 +270,43 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; return { ok: true };
} }
// --- CLOSE TRANSITION ---
/**
* Atomic conditional close: updates the ticket only when status is 'open'.
* Sets status:'closed', closedAt, and any caller-supplied extra $set/$unset
* fields in ONE update so all side-writes land atomically.
* Returns { transitioned: true, ticket } when an open ticket was just closed,
* { transitioned: false, ticket: null } when the ticket was already closed
* (modifiedCount was 0 — the status filter did not match).
*/
async function attemptCloseTransition(gmailThreadId, extraSet = {}, extraUnset = {}, _TicketModel) {
const T = _TicketModel || Ticket;
const closedAt = new Date();
const update = { $set: { status: 'closed', closedAt, ...extraSet } };
if (Object.keys(extraUnset).length > 0) {
update.$unset = extraUnset;
}
const result = await T.updateOne({ gmailThreadId, status: 'open' }, update);
const transitioned = result.modifiedCount === 1;
const ticket = transitioned ? await T.findOne({ gmailThreadId }).lean() : null;
return { transitioned, ticket };
}
// --- SCHEDULED CHECKS --- // --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
async function checkAutoClose(client, sendTicketClosedEmail) { async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _recordAction, _deps) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return; const cfg = (_deps && _deps.config) || CONFIG;
if (!cfg.AUTO_CLOSE_ENABLED) return;
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const _withRetry = (_deps && _deps.withRetry) || withRetry;
const _enqueueSend = (_deps && _deps.enqueueSend) || enqueueSend;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); const cutoffTime = new Date(Date.now() - (cfg.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs. // Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({ const staleTickets = await _withRetry(() => T.find({
status: 'open', status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
@@ -289,18 +318,28 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
try { try {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await _enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be // Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete // resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op. // resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne( const { transitioned: autoTransitioned, ticket: autoClosedTicket } =
{ gmailThreadId: ticket.gmailThreadId }, await _withRetry(() => attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, {}, T));
{ $set: { status: 'closed', pendingDelete: true } } if (autoTransitioned) {
)); record('system', 'close', {
ticket: autoClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: autoClosedTicket.claimerId ?? null,
wasClaimed: Boolean(autoClosedTicket.claimerId)
});
}
await sendTicketClosedEmail(ticket, 'Auto-Close System', null); await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
if (_deps && _deps.scheduleDelete) {
_deps.scheduleDelete(channel, ticket);
} else {
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe. // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord'); const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => { trackTimeout(setTimeout(() => {
@@ -312,6 +351,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
}).catch(() => {}); }).catch(() => {});
}, 5000)); }, 5000));
} }
}
} catch (error) { } catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
} }
@@ -352,12 +392,14 @@ async function checkAutoUnclaim(client) {
} }
} }
async function reconcileDeletedTicketChannels(client) { async function reconcileDeletedTicketChannels(client, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return; if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs. // Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({ const openTickets = await T.find({
status: 'open', status: 'open',
discordThreadId: { $ne: null } discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
@@ -369,10 +411,17 @@ async function reconcileDeletedTicketChannels(client) {
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
} }
if (!channel) { if (!channel) {
await Ticket.updateOne( const { transitioned: reconTransitioned, ticket: reconClosedTicket } =
{ gmailThreadId: ticket.gmailThreadId }, await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, {}, T);
{ $set: { status: 'closed', discordThreadId: null } } if (reconTransitioned) {
); record('system', 'close', {
ticket: reconClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: reconClosedTicket.claimerId ?? null,
wasClaimed: Boolean(reconClosedTicket.claimerId)
});
}
} }
} catch (err) { } catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
@@ -417,6 +466,7 @@ module.exports = {
makeTicketName, makeTicketName,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
checkTicketLimits, checkTicketLimits,
attemptCloseTransition,
checkAutoClose, checkAutoClose,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,

View File

@@ -0,0 +1,66 @@
/**
* Phase 9 — command-aware autocomplete dispatcher tests.
*
* Uses the injectable _handlers parameter to test the dispatch seam without
* requiring real Tag/Ticket DB access.
*
* Covers:
* (a) commandName 'response' → response handler called
* (b) unknown commandName → no handler called (no-op)
* (c) handler receives the interaction object unchanged
*/
import { describe, it, expect, vi } from 'vitest';
import { handleAutocomplete } from '../handlers/commands/index.js';
function makeInteraction(commandName) {
return {
commandName,
options: {
getSubcommand: vi.fn().mockReturnValue('send'),
getFocused: vi.fn().mockReturnValue('')
},
respond: vi.fn().mockResolvedValue(undefined)
};
}
describe('autocomplete dispatcher', () => {
it('routes commandName "response" to the response handler', async () => {
const responseHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('response');
await handleAutocomplete(interaction, { response: responseHandler });
expect(responseHandler).toHaveBeenCalledTimes(1);
expect(responseHandler).toHaveBeenCalledWith(interaction);
});
it('routes commandName "stats" to the stats handler', async () => {
const statsHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('stats');
await handleAutocomplete(interaction, { stats: statsHandler });
expect(statsHandler).toHaveBeenCalledTimes(1);
expect(statsHandler).toHaveBeenCalledWith(interaction);
});
it('no-ops for an unknown commandName', async () => {
const responseHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('unknown-command');
await handleAutocomplete(interaction, { response: responseHandler });
expect(responseHandler).not.toHaveBeenCalled();
});
it('passes the interaction object through to the handler unchanged', async () => {
let received = null;
const handler = vi.fn().mockImplementation(async i => { received = i; });
const interaction = makeInteraction('response');
await handleAutocomplete(interaction, { response: handler });
expect(received).toBe(interaction);
});
});

171
tests/claimEvents.test.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* Phase 5a — claim event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) fresh claim — modifiedCount 1 → exactly one 'claim' event
* (b) no-op re-claim — modifiedCount 0 (same user) → no event
* (c) conditional filter — filter must exclude tickets already claimed by actor
* (d) tier captured — escalationTier from ticket at claim time
*/
import { describe, it, expect, vi } from 'vitest';
import { applyClaim } from '../handlers/buttons.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001' },
channel: { id: 'chan-001' },
update: vi.fn().mockResolvedValue(undefined),
followUp: vi.fn().mockResolvedValue(undefined)
};
}
function makeGuild() {
return {
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
claimedBy: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
...overrides
};
}
function makeBtn() {
const btn = {};
const chain = () => btn;
btn.setCustomId = chain;
btn.setLabel = chain;
btn.setEmoji = chain;
btn.setStyle = chain;
btn.setDisabled = chain;
return btn;
}
// ---------------------------------------------------------------------------
// (a) Fresh claim — real transition
// ---------------------------------------------------------------------------
describe('applyClaim — fresh claim emits one event', () => {
it('emits exactly one "claim" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('claim');
});
it('passes the ticket doc so the recorder can denormalize fields', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (b) No-op re-claim — same user double-click, modifiedCount 0
// ---------------------------------------------------------------------------
describe('applyClaim — no-op re-claim emits no event', () => {
it('emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: 'staff-001' });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (c) Conditional filter — DB write must exclude same-user claims
// ---------------------------------------------------------------------------
describe('applyClaim — conditional filter', () => {
it('includes claimerId $ne the acting user in the updateOne filter', async () => {
const ticket = makeTicket({ claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
claimerId: { $ne: 'staff-001' }
});
});
});
// ---------------------------------------------------------------------------
// (d) Tier captured at claim time
// ---------------------------------------------------------------------------
describe('applyClaim — tier captured at claim time', () => {
it('passes the ticket with escalationTier=1 when the ticket is escalated', async () => {
const ticket = makeTicket({ escalationTier: 1, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
});
it('passes the ticket with escalationTier=0 for a non-escalated ticket', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(0);
});
});

310
tests/closeEvents.test.js Normal file
View File

@@ -0,0 +1,310 @@
/**
* Phase 4 — close event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne / findOne / find)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) staff force-close — finalizeForceClose, closerId present in pendingCloses
* (b) system auto-close — reconcileDeletedTicketChannels, channel absent
* (c) no-op close — transitioned=false → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { finalizeForceClose } from '../handlers/commands/close.js';
import { reconcileDeletedTicketChannels, checkAutoClose } from '../services/tickets.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeOpenTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
discordThreadId: 'chan-test-001',
claimerId: 'claimer-001',
claimedBy: 'ClaimerName',
status: 'open',
createdAt: new Date('2026-01-01'),
escalationTier: 0,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeClosedTicket(openTicket) {
return { ...openTicket, status: 'closed', closedAt: new Date() };
}
/**
* Minimal mock model for reconcile tests (only needs find / updateOne / findOne).
*
* @param {object[]} openTickets — rows returned by find()
* @param {object} closedTicket — doc returned by findOne after transition
* @param {number} modifiedCount — 1 = transition succeeded, 0 = no-op
*/
function makeReconcileModel(openTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(openTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
/**
* Minimal mock model for finalizeForceClose.
* findOne is called twice:
* 1st — freshTicket lookup in finalizeForceClose itself
* 2nd — post-transition fetch inside attemptCloseTransition
*/
function makeForceCloseModel(freshTicket, closedTicket, modifiedCount = 1) {
return {
find: vi.fn(),
findOne: vi.fn()
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(freshTicket) })
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
function makeGuild(id = 'guild-001') {
return {
id,
channels: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockResolvedValue(null)
}
};
}
function makeClient(guild) {
return {
guilds: {
cache: {
get: vi.fn().mockReturnValue(null),
first: vi.fn().mockReturnValue(guild)
}
}
};
}
/** Channel mock with send() so enqueueSend doesn't reject immediately. */
function makeChannelRef(id = 'chan-test-001', guildId = 'guild-001') {
return {
id,
name: `ticket-${id}`,
guild: { id: guildId },
send: vi.fn().mockResolvedValue({ id: 'sent-msg' }),
delete: vi.fn().mockResolvedValue(undefined),
messages: undefined // triggers transcript error (caught internally)
};
}
function makeClientRef() {
return {
channels: { fetch: vi.fn().mockResolvedValue(null) }
};
}
/**
* Minimal mock model for checkAutoClose tests.
* Mirrors makeReconcileModel — same shape, renamed for clarity.
*/
function makeAutoCloseModel(staleTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(staleTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
// ===========================================================================
// (a) Staff force-close
// ===========================================================================
describe('finalizeForceClose — staff close', () => {
it('emits one "close" event with closerType "staff", correct staffId / resolverId / wasClaimed', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-001', discordThreadId: 'chan-staff-001' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map([['chan-staff-001', { closerId: 'staff-user-001', username: 'Staff#0001' }]]);
await finalizeForceClose(makeChannelRef('chan-staff-001', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-user-001');
expect(type).toBe('close');
expect(payload.closerType).toBe('staff');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('uses closerType "system" and staffId "system" when no closerId in pendingCloses', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-002', discordThreadId: 'chan-sys-002' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map(); // no entry for this channel
await finalizeForceClose(makeChannelRef('chan-sys-002', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, , payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(payload.closerType).toBe('system');
});
});
// ===========================================================================
// (b) System auto-close: reconcileDeletedTicketChannels
// ===========================================================================
describe('reconcileDeletedTicketChannels — system close', () => {
it('emits one "close" event with closerType "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('reflects wasClaimed=false and resolverId=null for an unclaimed ticket', async () => {
const open = makeOpenTicket({ claimerId: null, claimedBy: null });
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBeNull();
expect(payload.wasClaimed).toBe(false);
});
});
// ===========================================================================
// (c) No-op close — idempotency
// ===========================================================================
describe('no-op close — idempotency', () => {
it('emits no event when the ticket channel still exists (reconcile skips close path)', async () => {
const open = makeOpenTicket();
const model = {
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue([open]) }),
findOne: vi.fn(),
updateOne: vi.fn()
};
const mockRecord = vi.fn();
const guild = {
id: 'guild-001',
channels: {
cache: { get: vi.fn().mockReturnValue({ id: open.discordThreadId }) }, // channel IS present
fetch: vi.fn()
}
};
await reconcileDeletedTicketChannels(makeClient(guild), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
expect(model.updateOne).not.toHaveBeenCalled();
});
it('emits no event when attemptCloseTransition reports transitioned=false (ticket was already closed)', async () => {
const open = makeOpenTicket();
const model = makeReconcileModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ===========================================================================
// (d) System auto-close: checkAutoClose
// ===========================================================================
const TEST_CONFIG = { AUTO_CLOSE_ENABLED: true, AUTO_CLOSE_AFTER_HOURS: 72, DISCORD_AUTO_CLOSE_MESSAGE: 'closing' };
describe('checkAutoClose — system close', () => {
it('emits one "close" event with closerType "system", staffId "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeAutoCloseModel([open], closed, 1);
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('emits no event when attemptCloseTransition reports transitioned=false', async () => {
const open = makeOpenTicket();
const model = makeAutoCloseModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Import the real module — no module-level mocks needed.
// attemptCloseTransition accepts an optional 4th arg (_TicketModel) so tests
// can inject a mock without mocking the whole db-connection chain.
import { attemptCloseTransition } from '../services/tickets.js';
describe('attemptCloseTransition', () => {
let mockUpdateOne, mockFindOne, mockTicket;
beforeEach(() => {
mockUpdateOne = vi.fn();
mockFindOne = vi.fn();
mockTicket = { updateOne: mockUpdateOne, findOne: mockFindOne };
});
it('returns transitioned=true and the fetched ticket when an open ticket is closed', async () => {
const closedTicket = { gmailThreadId: 'thread-open', status: 'closed', closedAt: new Date() };
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) });
const result = await attemptCloseTransition('thread-open', {}, {}, mockTicket);
expect(result.transitioned).toBe(true);
expect(result.ticket).toBe(closedTicket);
});
it('gates the update on status:"open" so only open tickets are closed', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ gmailThreadId: 'thread-open', status: 'open' },
expect.anything()
);
});
it('includes a closedAt Date in the $set', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$set.closedAt).toBeInstanceOf(Date);
});
it('returns transitioned=false and null ticket when the ticket is already closed', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
const result = await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
expect(result.transitioned).toBe(false);
expect(result.ticket).toBeNull();
});
it('does not call findOne when no transition occurred', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
expect(mockFindOne).not.toHaveBeenCalled();
});
it('is a no-op on a second call — idempotency seam later phases rely on', async () => {
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 });
mockFindOne.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue({ gmailThreadId: 'thread-x' }) });
const first = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
expect(first.transitioned).toBe(true);
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 0 });
const second = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
expect(second.transitioned).toBe(false);
expect(second.ticket).toBeNull();
});
it('folds extraSet fields into the $set alongside status and closedAt', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', { discordThreadId: null, pendingDelete: true }, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$set.status).toBe('closed');
expect(update.$set.discordThreadId).toBeNull();
expect(update.$set.pendingDelete).toBe(true);
});
it('includes $unset in the update when extraUnset is non-empty', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', {}, { welcomeMessageId: '' }, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$unset).toEqual({ welcomeMessageId: '' });
});
it('omits $unset from the update when extraUnset is empty', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', {}, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$unset).toBeUndefined();
});
});

View File

@@ -19,6 +19,10 @@ describe('ALLOWED_CONFIG_KEYS', () => {
} }
}); });
it('includes STATS_ADMIN_IDS', () => {
expect(ALLOWED_CONFIG_KEYS.has('STATS_ADMIN_IDS')).toBe(true);
});
it('does not contain stale removed keys', () => { it('does not contain stale removed keys', () => {
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) { for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false); expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
@@ -192,9 +196,8 @@ describe('discord_id validator', () => {
describe('discord_id_list validator', () => { describe('discord_id_list validator', () => {
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it // ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so // hits the string fallback. discord_id_list only fires for `*_IDS` keys.
// exercise it with a hypothetical name. const v = getValidator('STATS_ADMIN_IDS');
const v = getValidator('STAFF_USER_IDS');
it('infers type discord_id_list for *_IDS keys', () => { it('infers type discord_id_list for *_IDS keys', () => {
expect(v.type).toBe('discord_id_list'); expect(v.type).toBe('discord_id_list');
@@ -221,6 +224,40 @@ describe('discord_id_list validator', () => {
}); });
}); });
describe('STATS_ADMIN_IDS parsing (config.js pattern)', () => {
// Tests the .split(',').map(r=>r.trim()).filter(Boolean) idiom used for all
// list env vars in config.js — exercised here as a pure expression.
function parseIdList(v) {
return (v || '').split(',').map(r => r.trim()).filter(Boolean);
}
it('returns [] for empty string', () => {
expect(parseIdList('')).toEqual([]);
});
it('returns [] for undefined', () => {
expect(parseIdList(undefined)).toEqual([]);
});
it('returns a single-element array for one ID', () => {
expect(parseIdList('321754640431710226')).toEqual(['321754640431710226']);
});
it('returns multiple IDs for comma-separated input', () => {
expect(parseIdList('321754640431710226,691678135527276614,224692549225283584'))
.toEqual(['321754640431710226', '691678135527276614', '224692549225283584']);
});
it('trims whitespace around each ID', () => {
expect(parseIdList(' 321754640431710226 , 691678135527276614 '))
.toEqual(['321754640431710226', '691678135527276614']);
});
it('drops empty segments from trailing commas', () => {
expect(parseIdList('321754640431710226,')).toEqual(['321754640431710226']);
});
});
describe('string validator (fallback)', () => { describe('string validator (fallback)', () => {
const v = getValidator('TICKET_CATEGORY_NAME'); const v = getValidator('TICKET_CATEGORY_NAME');

View File

@@ -0,0 +1,188 @@
/**
* Phase 5b — escalate / de-escalate event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) escalate real — modifiedCount 1 → one 'escalate' event with new tier
* (b) deescalate real — modifiedCount 1 → one 'deescalate' event with new tier
* (c) no-op write — modifiedCount 0 → no event for either direction
*/
import { describe, it, expect, vi } from 'vitest';
import { runEscalation, runDeescalation } from '../handlers/commands/escalation.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
escalated: false,
claimerId: 'claimer-001',
claimedBy: 'ClaimerName',
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
welcomeMessageId: null,
...overrides
};
}
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
tag: 'staffuser#0001',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: {
id: 'guild-001',
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
},
channel: {
id: 'chan-001',
name: 'ticket-chan-001',
isThread: vi.fn().mockReturnValue(true),
send: vi.fn().mockResolvedValue({ id: 'sent-msg-001' })
},
editReply: vi.fn().mockResolvedValue(undefined),
client: {
channels: { fetch: vi.fn().mockResolvedValue(null) }
}
};
}
// ---------------------------------------------------------------------------
// (a) Real escalate — modifiedCount 1
// ---------------------------------------------------------------------------
describe('runEscalation — real escalate emits one event', () => {
it('emits exactly one "escalate" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('escalate');
});
it('passes the ticket with the new tier', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
expect(payload.guildId).toBe('guild-001');
});
it('uses conditional filter escalationTier $ne nextTier', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
escalationTier: { $ne: 1 }
});
});
});
// ---------------------------------------------------------------------------
// (b) Real deescalate — modifiedCount 1
// ---------------------------------------------------------------------------
describe('runDeescalation — real deescalate emits one event', () => {
it('emits exactly one "deescalate" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('deescalate');
});
it('passes the ticket with the new (lower) tier', async () => {
const ticket = makeTicket({ escalationTier: 2, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
expect(payload.guildId).toBe('guild-001');
});
it('uses conditional filter escalationTier $ne newTier', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
escalationTier: { $ne: 0 }
});
});
});
// ---------------------------------------------------------------------------
// (c) No-op write — modifiedCount 0 → no event for either direction
// ---------------------------------------------------------------------------
describe('no-op tier write emits no event', () => {
it('escalate: emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 1 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
it('deescalate: emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,169 @@
/**
* Phase 7 — gmail-poll email ticket persistence + reopen event recording.
*
* Uses the injectable-parameter pattern: persistEmailTicket accepts
* _Ticket (model) and _recordAction as the 4th and 5th parameters.
* No vi.mock needed; dependencies are injected directly.
*
* Covers:
* (a) game persisted: findOneAndUpdate $set includes game from detectGame
* (b) reopen event: staffId='system', resolverId = prior claimerId, guildId correct
* (c) payload.ticket: the returned doc is passed verbatim for denormalization
* (d) no reopen: wasReopened=false → _recordAction not called
* (e) null claimerId: resolverId=null for unclaimed-ticket reopen
*/
import { describe, it, expect, vi } from 'vitest';
import { persistEmailTicket } from '../gmail-poll.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeFields(overrides = {}) {
return {
threadId: 'gmail-thread-001',
discordThreadId: 'chan-001',
senderEmail: 'user@example.com',
subject: 'Help needed',
createdAt: new Date('2026-01-01'),
ticketNumber: 1,
priority: 'normal',
parentCategoryId: 'cat-001',
game: 'TestGame',
...overrides
};
}
function makeReturnedDoc(overrides = {}) {
return {
gmailThreadId: 'gmail-thread-001',
senderEmail: 'user@example.com',
claimerId: 'claimer-001',
escalationTier: 0,
priority: 'normal',
game: 'TestGame',
...overrides
};
}
function makeTicketModel(returnedDoc) {
return {
findOneAndUpdate: vi.fn().mockResolvedValue(returnedDoc)
};
}
// ---------------------------------------------------------------------------
// (a) game included in the $set
// ---------------------------------------------------------------------------
describe('persistEmailTicket — game persisted in $set', () => {
it('includes game in the findOneAndUpdate $set', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields({ game: 'Minecraft' }), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.game).toBe('Minecraft');
});
it('passes null game when no game is detected', async () => {
const model = makeTicketModel(makeReturnedDoc({ game: null }));
await persistEmailTicket(makeFields({ game: null }), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.game).toBeNull();
});
it('uses gmailThreadId as the findOneAndUpdate filter', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields({ threadId: 'thread-xyz' }), 'guild-001', false, model, vi.fn());
const [filter] = model.findOneAndUpdate.mock.calls[0];
expect(filter.gmailThreadId).toBe('thread-xyz');
});
it('sets status:"open" in the $set', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields(), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.status).toBe('open');
});
});
// ---------------------------------------------------------------------------
// (b) Reopen event: staffId='system', resolverId=prior claimerId, guildId correct
// ---------------------------------------------------------------------------
describe('persistEmailTicket — reopen event recording', () => {
it('calls _recordAction once with staffId=system and type=reopen', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: 'prev-claimer' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('reopen');
});
it('sets resolverId = the claimerId from the returned doc', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: 'prev-claimer-123' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBe('prev-claimer-123');
});
it('includes guildId in the payload', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc();
await persistEmailTicket(makeFields(), 'guild-999', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.guildId).toBe('guild-999');
});
});
// ---------------------------------------------------------------------------
// (c) payload.ticket is the returned doc (so denormalization gets all fields)
// ---------------------------------------------------------------------------
describe('persistEmailTicket — returned doc passed as payload.ticket', () => {
it('sets payload.ticket to the doc returned by findOneAndUpdate', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ game: 'SomeGame', senderEmail: 'a@b.com' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket).toBe(doc);
});
});
// ---------------------------------------------------------------------------
// (d) No reopen event when wasReopened=false
// ---------------------------------------------------------------------------
describe('persistEmailTicket — no reopen on brand-new ticket', () => {
it('does not call _recordAction when wasReopened=false', async () => {
const mockRecord = vi.fn();
await persistEmailTicket(makeFields(), 'guild-001', false, makeTicketModel(makeReturnedDoc()), mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (e) resolverId=null when prior claimerId is null (unclaimed ticket reopened)
// ---------------------------------------------------------------------------
describe('persistEmailTicket — reopen of unclaimed ticket', () => {
it('sets resolverId=null when the prior claimerId is null', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: null });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBeNull();
});
});

View File

@@ -0,0 +1,245 @@
/**
* Phase 6 — staff response event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls Ticket DB layer (findOne, updateOne)
* _TagModel — controls Tag DB layer (findOne, updateOne) for /response send
* _recordAction — captures recording calls without any module mocking
* _isStaff — controls staff check result (messages.js path only)
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) handleDiscordReply — staff message in a discord ticket → one 'response' event
* (b) handleDiscordReply — staff message in an email ticket → one 'response' event
* (c) handleDiscordReply — bot message → no event
* (d) handleDiscordReply — non-staff message → no event
* (e) handleResponseSend — /response send in a ticket → one 'response' event
* (f) handleResponseSend — no ticket found → no event
* (g) handleResponseSend — tag not found → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { handleDiscordReply } from '../handlers/messages.js';
import { handleResponseSend } from '../handlers/commands/response.js';
// ---------------------------------------------------------------------------
// Shared factories — handleDiscordReply
// ---------------------------------------------------------------------------
function makeMessage(overrides = {}) {
return {
author: { bot: false, id: 'staff-001' },
interaction: null,
channel: { id: 'chan-001', name: 'ticket-chan-001' },
guild: {
id: 'guild-001',
members: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockRejectedValue(new Error('no members in test env'))
}
},
content: 'Hello customer',
id: 'msg-001',
...overrides
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeMessageTicketModel(ticket) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 0 })
};
}
// ---------------------------------------------------------------------------
// (a) + (b) Staff message records one 'response' — discord + email tickets
// ---------------------------------------------------------------------------
describe('handleDiscordReply — staff message records response', () => {
it('records one "response" event for a discord ticket', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
it('records one "response" event for an email ticket (before the discord early-return)', async () => {
const ticket = makeTicket({ gmailThreadId: '18f3a2b1c0d4e5f6' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
// Gmail relay will fail but that's caught internally — record already fired.
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (c) Bot message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — bot message records nothing', () => {
it('records nothing when author.bot is true', async () => {
const mockModel = makeMessageTicketModel(makeTicket());
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
const m = makeMessage({ author: { bot: true, id: 'bot-001' } });
await handleDiscordReply(m, mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (d) Non-staff message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — non-staff message records nothing', () => {
it('records nothing when isStaff returns false', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(false);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
it('records nothing when the message is not in a ticket channel', async () => {
const mockModel = makeMessageTicketModel(null); // no ticket
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Shared factories — handleResponseSend
// ---------------------------------------------------------------------------
function makeInteraction(overrides = {}) {
return {
user: {
id: 'staff-001',
username: 'staffuser',
toString: () => '<@staff-001>'
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001', name: 'Test Guild', memberCount: 10 },
channel: { id: 'chan-001' },
options: { getString: vi.fn().mockReturnValue('my-tag') },
reply: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
function makeTagModel(tag) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(tag) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 })
};
}
function makeResponseTicketModel(ticket) {
return { findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }) };
}
// ---------------------------------------------------------------------------
// (e) /response send in a ticket channel → one event
// ---------------------------------------------------------------------------
describe('handleResponseSend — records one "response" event', () => {
it('records staffId, guildId, and ticket when ticket is found', async () => {
const ticket = makeTicket();
const tag = { name: 'my-tag', content: 'Hello {ticket.user}', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(ticket),
mockRecord
);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (f) No ticket found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — no ticket records nothing', () => {
it('records nothing when no ticket exists for the channel', async () => {
const tag = { name: 'my-tag', content: 'Hello', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(null),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (g) Tag not found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — tag not found records nothing', () => {
it('records nothing when the tag does not exist', async () => {
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(null),
makeResponseTicketModel(makeTicket()),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});

187
tests/staffStats.test.js Normal file
View File

@@ -0,0 +1,187 @@
import { describe, it, expect, vi } from 'vitest';
// Stub debugLog so the import chain doesn't pull in discord.js / config.
vi.mock('../services/debugLog.js', () => ({
logError: vi.fn()
}));
import { recordAction, denormalizeTicket, deriveTicketType } from '../services/staffStats.js';
// ---------------------------------------------------------------------------
// deriveTicketType
// ---------------------------------------------------------------------------
describe('deriveTicketType', () => {
it('returns "discord" for discord- prefix', () => {
expect(deriveTicketType('discord-abc123')).toBe('discord');
});
it('returns "discord" for discord-msg- prefix', () => {
expect(deriveTicketType('discord-msg-abc123')).toBe('discord');
});
it('returns "email" for a Gmail thread ID', () => {
expect(deriveTicketType('18f3a2b1c0d4e5f6')).toBe('email');
});
it('returns "email" for null / undefined / empty gmailThreadId', () => {
expect(deriveTicketType(null)).toBe('email');
expect(deriveTicketType(undefined)).toBe('email');
expect(deriveTicketType('')).toBe('email');
});
});
// ---------------------------------------------------------------------------
// denormalizeTicket — field extraction
// ---------------------------------------------------------------------------
describe('denormalizeTicket', () => {
const emailTicket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 1,
priority: 'high',
game: 'Minecraft',
senderEmail: 'user@example.com',
creatorId: '111222333444555666',
claimerId: '999888777666555444'
};
const discordTicket = {
gmailThreadId: 'discord-msg-xyz789',
escalationTier: 0,
priority: 'normal',
game: null,
senderEmail: 'noreply@discord',
creatorId: '777666555444333222'
};
it('derives ticketType "email" for a Gmail thread', () => {
expect(denormalizeTicket(emailTicket).ticketType).toBe('email');
});
it('derives ticketType "discord" for a discord-msg- thread', () => {
expect(denormalizeTicket(discordTicket).ticketType).toBe('discord');
});
it('copies all standard event fields from the ticket', () => {
const f = denormalizeTicket(emailTicket);
expect(f.tier).toBe(1);
expect(f.priority).toBe('high');
expect(f.game).toBe('Minecraft');
expect(f.senderEmail).toBe('user@example.com');
expect(f.creatorId).toBe('111222333444555666');
expect(f.gmailThreadId).toBe('18f3a2b1c0d4e5f6');
});
it('defaults tier to 0 when escalationTier is absent', () => {
expect(denormalizeTicket({ gmailThreadId: 'abc' }).tier).toBe(0);
});
it('does NOT include guildId (must come from call site)', () => {
const f = denormalizeTicket(emailTicket);
expect(Object.prototype.hasOwnProperty.call(f, 'guildId')).toBe(false);
});
it('returns {} for a null ticket', () => {
expect(denormalizeTicket(null)).toEqual({});
});
});
// ---------------------------------------------------------------------------
// recordAction — payload merging / override precedence
// ---------------------------------------------------------------------------
describe('recordAction payload merging', () => {
const ticket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 2,
priority: 'medium',
game: 'Rust',
senderEmail: 'player@example.com',
creatorId: '100200300400500600'
};
it('payload fields override denormalized ticket fields', () => {
const { ticket: t, ...overrides } = {
ticket,
guildId: '555666777888999000',
game: 'OverriddenGame',
priority: 'low'
};
const merged = { ...denormalizeTicket(t), ...overrides };
expect(merged.game).toBe('OverriddenGame');
expect(merged.priority).toBe('low');
expect(merged.guildId).toBe('555666777888999000');
});
it('guildId (call-site only) passes through from payload', () => {
const { ticket: t, ...rest } = { ticket, guildId: '123456789012345678' };
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.guildId).toBe('123456789012345678');
});
it('close-only fields pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.closerType).toBe('staff');
expect(merged.resolverId).toBe('123456789012345678');
expect(merged.wasClaimed).toBe(true);
});
it('transfer-only fields (fromId/toId) pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
fromId: '111111111111111111',
toId: '222222222222222222'
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.fromId).toBe('111111111111111111');
expect(merged.toId).toBe('222222222222222222');
});
});
// ---------------------------------------------------------------------------
// recordAction — fire-and-forget discipline
//
// In the test environment there is no real MongoDB connection and the
// StaffAction model schema is not registered, so mongoose.model('StaffAction')
// throws MissingSchemaError synchronously. This is exactly the kind of error
// recordAction must swallow — it proves the outer try/catch works. The async
// .catch() path is exercised transitively: any callers in later phases that
// succeed with a real DB connection will hit that path.
// ---------------------------------------------------------------------------
describe('recordAction fire-and-forget', () => {
it('returns undefined (callers do not await)', () => {
expect(recordAction('staff1', 'claim', {})).toBeUndefined();
});
it('does not throw when called with null / undefined / empty payload', () => {
expect(() => recordAction('staff1', 'reopen', undefined)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', null)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', {})).not.toThrow();
});
it('does not throw even when the model layer errors (model not registered)', () => {
// mongoose.model('StaffAction') throws MissingSchemaError synchronously in
// this environment — recordAction must absorb it and never rethrow.
expect(() => recordAction('staff1', 'close', {
ticket: {
gmailThreadId: 'discord-abc',
escalationTier: 0,
priority: 'normal',
senderEmail: 'a@b.com',
creatorId: '123'
},
guildId: '999',
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
})).not.toThrow();
});
});

350
tests/statsHandler.test.js Normal file
View File

@@ -0,0 +1,350 @@
/**
* Phase 10 — /stats command handler tests.
* Injectable deps — no vi.mock.
*/
import { describe, it, expect, vi } from 'vitest';
import { MessageFlags } from 'discord.js';
import { handleStats, handleStatsAutocomplete } from '../handlers/commands/stats.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const NOW_MS = 1_700_000_000_000; // fixed epoch for deterministic cutoff assertions
// ---------------------------------------------------------------------------
// Factories
// ---------------------------------------------------------------------------
function makeInteraction({ userId = 'caller-001', memberUserId, memberUsername, periodStr, source } = {}) {
return {
user: { id: userId, username: 'testuser' },
guildId: 'guild-001',
options: {
getUser: (name) => {
if (name === 'member' && memberUserId) {
return { id: memberUserId, username: memberUsername || 'member-user' };
}
return null;
},
getString: (name) => {
if (name === 'period') return periodStr || null;
if (name === 'source') return source || null;
return null;
}
},
reply: vi.fn().mockResolvedValue(undefined)
};
}
function makeStaffAction(events = []) {
return {
find: vi.fn().mockReturnValue({ lean: () => Promise.resolve(events) })
};
}
function captureStaffAction() {
let capturedFilter;
return {
sa: {
find: (filter) => {
capturedFilter = filter;
return { lean: () => Promise.resolve([]) };
}
},
getFilter: () => capturedFilter
};
}
function deps(overrides = {}) {
return {
StaffAction: makeStaffAction(),
now: () => NOW_MS,
adminIds: [],
...overrides
};
}
// ---------------------------------------------------------------------------
// handleStatsAutocomplete
// ---------------------------------------------------------------------------
describe('handleStatsAutocomplete', () => {
function makeAutoInteraction(focusedValue = '') {
return {
options: { getFocused: () => focusedValue },
respond: vi.fn().mockResolvedValue(undefined)
};
}
it('returns all 5 presets when focused input is empty', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions).toHaveLength(5);
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).toContain('3 months');
expect(values).toContain('6 months');
expect(values).toContain('1 year');
});
it('filters to presets matching the typed substring', async () => {
const i = makeAutoInteraction('days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).not.toContain('3 months');
expect(values).not.toContain('1 year');
});
it('echoes typed input as first suggestion when it does not exactly match a preset', async () => {
const i = makeAutoInteraction('14d');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions[0].value).toBe('14d');
expect(suggestions[0].name).toBe('14d');
});
it('does not duplicate a preset when typed input exactly matches one', async () => {
const i = makeAutoInteraction('30 days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions.filter(s => s.value === '30 days')).toHaveLength(1);
});
it('calls interaction.respond exactly once', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
expect(i.respond).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// handleStats — gating (STATS_ADMIN_IDS)
// ---------------------------------------------------------------------------
describe('handleStats — gating', () => {
it('caller views own stats when no member option is provided', async () => {
const interaction = makeInteraction({ userId: 'caller-001' });
await handleStats(interaction, deps());
expect(interaction.reply).toHaveBeenCalledTimes(1);
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('admin can view another member\'s stats', async () => {
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: ['admin-001'] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toBeDefined();
expect(replyArg.content).toBeUndefined();
});
it('non-admin is blocked with the exact error message when member option is set', async () => {
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBe('You can only view your own stats.');
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
expect(replyArg.embeds).toBeUndefined();
});
it('non-admin can view their own stats (no member option)', async () => {
const interaction = makeInteraction({ userId: 'plain-001' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('StaffAction is never queried when the non-admin gate fires', async () => {
const sa = makeStaffAction();
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ StaffAction: sa, adminIds: [] }));
expect(sa.find).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// handleStats — period default (no option → 30 days)
// ---------------------------------------------------------------------------
describe('handleStats — period default', () => {
it('uses a 30-day cutoff when no period option is given', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const expectedCutoff = new Date(NOW_MS - 30 * MS_PER_DAY);
expect(getFilter().createdAt.$gte.getTime()).toBe(expectedCutoff.getTime());
});
it('embed title includes "30 days" when no period option', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('30 days');
});
it('embed title includes the user-supplied period label', async () => {
const interaction = makeInteraction({ periodStr: '7 days' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('7 days');
});
});
// ---------------------------------------------------------------------------
// handleStats — source filter passthrough
// ---------------------------------------------------------------------------
describe('handleStats — source filter', () => {
it('source="email" counts only email events', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction({ source: 'email' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
// Only 1 email claim should be counted
expect(claimsField.value).toMatch(/^1/);
});
it('source label appears in embed description', async () => {
const interaction = makeInteraction({ source: 'discord' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('discord');
});
it('omitted source defaults to "all" and shows "all sources" in description', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('all sources');
});
});
// ---------------------------------------------------------------------------
// handleStats — StaffAction query filter shape
// ---------------------------------------------------------------------------
describe('handleStats — query filter shape', () => {
it('$or includes all 4 target fields', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'user-001' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().$or).toHaveLength(4);
expect(getFilter().$or).toContainEqual({ staffId: 'user-001' });
expect(getFilter().$or).toContainEqual({ resolverId: 'user-001' });
expect(getFilter().$or).toContainEqual({ toId: 'user-001' });
expect(getFilter().$or).toContainEqual({ fromId: 'user-001' });
});
it('createdAt.$gte is a Date instance', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().createdAt.$gte).toBeInstanceOf(Date);
});
it('admin querying another member uses member id (not admin id) in $or', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: ['admin-001'] });
expect(getFilter().$or).toContainEqual({ staffId: 'other-002' });
expect(getFilter().$or).not.toContainEqual({ staffId: 'admin-001' });
});
});
// ---------------------------------------------------------------------------
// handleStats — embed output reflects shapeStats with tier labels
// ---------------------------------------------------------------------------
describe('handleStats — embed content', () => {
it('reply is ephemeral', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
});
it('embed title contains the target username', async () => {
const interaction = makeInteraction({ userId: 'caller-001' }); // username = 'testuser'
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('testuser');
});
it('escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'escalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'escalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('de-escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'deescalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'deescalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'De-escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('claims-while-escalated sub-breakdown uses tier labels in Claims field', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('empty stats returns a valid zero-filled embed without throwing', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toHaveLength(1);
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(claimsField.value).toMatch(/^0/);
const escalField = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(escalField.value).toBe('0');
});
it('Email / Discord split field reflects bySource from shapeStats', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const splitField = replyArg.embeds[0].data.fields.find(f => f.name === 'Email / Discord split');
expect(splitField.value).toContain('Email');
expect(splitField.value).toContain('Discord');
});
});

716
tests/statsShaping.test.js Normal file
View File

@@ -0,0 +1,716 @@
import { describe, it, expect } from 'vitest';
import { parsePeriod, shapeStats } from '../services/statsShaping.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// parsePeriod — presets (autocomplete suggestions)
// ---------------------------------------------------------------------------
describe('parsePeriod — presets', () => {
it('"7 days" → 7 days', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
expect(r.label).toBe('7 days');
});
it('"30 days" → 30 days', () => {
const r = parsePeriod('30 days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
});
it('"3 months" → 3 × 30 days', () => {
const r = parsePeriod('3 months');
expect(r.value).toBe(3);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
expect(r.label).toBe('3 months');
});
it('"6 months" → 6 × 30 days', () => {
const r = parsePeriod('6 months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('"1 year" → 365 days', () => {
const r = parsePeriod('1 year');
expect(r.value).toBe(1);
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
expect(r.label).toBe('1 year');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — day unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — day variants', () => {
it('<n>d', () => {
const r = parsePeriod('14d');
expect(r.value).toBe(14);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(14 * MS_PER_DAY);
});
it('<n>day (singular, no space)', () => {
const r = parsePeriod('1day');
expect(r.value).toBe(1);
expect(r.unit).toBe('days');
expect(r.label).toBe('1 day');
});
it('<n> day (singular, with space)', () => {
const r = parsePeriod('1 day');
expect(r.value).toBe(1);
expect(r.label).toBe('1 day');
});
it('<n> days', () => {
const r = parsePeriod('10 days');
expect(r.durationMs).toBe(10 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — week unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — week variants', () => {
it('<n>w', () => {
const r = parsePeriod('2w');
expect(r.value).toBe(2);
expect(r.unit).toBe('weeks');
expect(r.durationMs).toBe(2 * 7 * MS_PER_DAY);
expect(r.label).toBe('2 weeks');
});
it('<n> week (singular)', () => {
const r = parsePeriod('1 week');
expect(r.value).toBe(1);
expect(r.label).toBe('1 week');
});
it('<n>weeks (no space)', () => {
const r = parsePeriod('4weeks');
expect(r.value).toBe(4);
expect(r.unit).toBe('weeks');
});
it('<n> weeks', () => {
const r = parsePeriod('4 weeks');
expect(r.durationMs).toBe(4 * 7 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — month unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — month variants', () => {
it('<n>m', () => {
const r = parsePeriod('3m');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
});
it('<n>mo', () => {
const r = parsePeriod('6mo');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('<n> month (singular)', () => {
const r = parsePeriod('1 month');
expect(r.value).toBe(1);
expect(r.label).toBe('1 month');
});
it('<n> months', () => {
const r = parsePeriod('12 months');
expect(r.value).toBe(12);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(12 * 30 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — year unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — year variants', () => {
it('<n>y', () => {
const r = parsePeriod('1y');
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
});
it('<n> year (singular)', () => {
const r = parsePeriod('1 year');
expect(r.unit).toBe('years');
expect(r.label).toBe('1 year');
});
it('<n> years', () => {
const r = parsePeriod('2 years');
expect(r.value).toBe(2);
expect(r.label).toBe('2 years');
expect(r.durationMs).toBe(2 * 365 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — bare number = days
// ---------------------------------------------------------------------------
describe('parsePeriod — bare number defaults to days', () => {
it('"30" → 30 days', () => {
const r = parsePeriod('30');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
});
it('"7" → 7 days', () => {
const r = parsePeriod('7');
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
});
it('"365" → 365 days (not 1 year)', () => {
const r = parsePeriod('365');
expect(r.unit).toBe('days');
expect(r.value).toBe(365);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — case & whitespace tolerance
// ---------------------------------------------------------------------------
describe('parsePeriod — case and whitespace tolerance', () => {
it('uppercase "7 DAYS"', () => {
const r = parsePeriod('7 DAYS');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('mixed case "3 Months"', () => {
const r = parsePeriod('3 Months');
expect(r.unit).toBe('months');
});
it('leading and trailing whitespace " 30 days "', () => {
const r = parsePeriod(' 30 days ');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('multiple internal spaces "7 days"', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('no space between number and unit "30days"', () => {
const r = parsePeriod('30days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('"1YEAR" (uppercase, no space)', () => {
const r = parsePeriod('1YEAR');
expect(r.unit).toBe('years');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — unparseable → 30-day default
// ---------------------------------------------------------------------------
describe('parsePeriod — unparseable inputs → 30-day default', () => {
const expectDefault = r => {
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
};
it('null → default', () => expectDefault(parsePeriod(null)));
it('undefined → default', () => expectDefault(parsePeriod(undefined)));
it('empty string → default', () => expectDefault(parsePeriod('')));
it('whitespace only → default', () => expectDefault(parsePeriod(' ')));
it('letters only → default', () => expectDefault(parsePeriod('abc')));
it('natural language → default', () => expectDefault(parsePeriod('last month')));
it('unknown unit "5x" → default', () => expectDefault(parsePeriod('5x')));
it('"0" → default (zero is nonsensical)', () => expectDefault(parsePeriod('0')));
it('"0d" → default', () => expectDefault(parsePeriod('0d')));
it('negative-like "-5d" → default (not a digit-start)', () => expectDefault(parsePeriod('-5d')));
});
// ---------------------------------------------------------------------------
// parsePeriod — return shape invariant
// ---------------------------------------------------------------------------
describe('parsePeriod — return shape', () => {
it('always returns { durationMs, value, unit, label }', () => {
for (const input of ['7d', '2w', '3m', '6mo', '1y', '30', null, 'junk']) {
const r = parsePeriod(input);
expect(typeof r.durationMs).toBe('number');
expect(typeof r.value).toBe('number');
expect(typeof r.unit).toBe('string');
expect(typeof r.label).toBe('string');
}
});
it('returns a fresh object each call (not the same frozen reference)', () => {
const a = parsePeriod(null);
const b = parsePeriod(undefined);
expect(a).not.toBe(b);
});
});
// ===========================================================================
// shapeStats — fixtures
// ===========================================================================
const MEMBER = 'member-001';
const OTHER = 'other-002';
function event(overrides) {
return {
staffId: OTHER,
type: 'claim',
tier: 0,
ticketType: 'email',
wasClaimed: null,
resolverId: null,
fromId: null,
toId: null,
...overrides
};
}
// ---------------------------------------------------------------------------
// shapeStats — claims
// ---------------------------------------------------------------------------
describe('shapeStats — claims', () => {
it('counts claim events where staffId===member', () => {
const events = [
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('does not count claims by other staff', () => {
expect(shapeStats([event({ type: 'claim', staffId: OTHER })], MEMBER, 'all').claims).toBe(0);
});
it('non-claim event types do not increment claims', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(0);
});
it('claimsWhileEscalated groups by numeric tier for tier > 0', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 2 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(4);
expect(r.claimsWhileEscalated).toEqual({ 1: 2, 2: 1 });
});
it('tier=0 claims are NOT included in claimsWhileEscalated', () => {
const events = [event({ type: 'claim', staffId: MEMBER, tier: 0 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
it('claimsWhileEscalated only includes the member\'s own claims', () => {
const events = [event({ type: 'claim', staffId: OTHER, tier: 1 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
});
// ---------------------------------------------------------------------------
// shapeStats — closes
// ---------------------------------------------------------------------------
describe('shapeStats — closes', () => {
it('counts close events where staffId===member', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: OTHER }),
event({ type: 'close', staffId: 'system' }),
];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(2);
});
it('system closes do not count toward member closes', () => {
const events = [event({ type: 'close', staffId: 'system', resolverId: MEMBER })];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(0);
});
it('unclaimedAtClose counts member closes where wasClaimed===false', () => {
const events = [
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: MEMBER, wasClaimed: true }),
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: OTHER, wasClaimed: false }),
];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(2);
});
it('wasClaimed===true does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: true })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
it('wasClaimed===null does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: null })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — resolved (credit to claimer via resolverId)
// ---------------------------------------------------------------------------
describe('shapeStats — resolved', () => {
it('counts close events where resolverId===member', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(2);
expect(r.closes).toBe(1);
});
it('resolved is distinct from closes — different field keys', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(1);
expect(r.closes).toBe(0);
});
it('a self-close-and-resolve increments both closes and resolved', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true })
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — escalations / de-escalations
// ---------------------------------------------------------------------------
describe('shapeStats — escalations', () => {
it('groups escalate events by numeric tier', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').escalations).toEqual({ 1: 2, 2: 1 });
});
it('ignores escalations by other staff', () => {
expect(shapeStats([event({ type: 'escalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').escalations).toEqual({});
});
it('escalations is empty when no escalate events', () => {
expect(shapeStats([event({ type: 'claim', staffId: MEMBER })], MEMBER, 'all').escalations).toEqual({});
});
});
describe('shapeStats — de-escalations', () => {
it('groups deescalate events by numeric tier', () => {
const events = [
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').deescalations).toEqual({ 1: 1, 2: 2 });
});
it('ignores deescalations by other staff', () => {
expect(shapeStats([event({ type: 'deescalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').deescalations).toEqual({});
});
it('escalations and deescalations are counted independently', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.escalations).toEqual({ 1: 1 });
expect(r.deescalations).toEqual({ 1: 1 });
});
});
// ---------------------------------------------------------------------------
// shapeStats — transfers in vs out
// ---------------------------------------------------------------------------
describe('shapeStats — transfers', () => {
it('transfersIn counts transfer events where toId===member', () => {
const events = [
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersIn).toBe(1);
});
it('transfersOut counts transfer events where staffId===member (initiator)', () => {
const events = [
event({ type: 'transfer', staffId: MEMBER, toId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersOut).toBe(1);
});
it('single transfer counts out for sender, in for receiver', () => {
const events = [event({ type: 'transfer', staffId: MEMBER, toId: OTHER })];
const rMember = shapeStats(events, MEMBER, 'all');
const rOther = shapeStats(events, OTHER, 'all');
expect(rMember.transfersOut).toBe(1);
expect(rMember.transfersIn).toBe(0);
expect(rOther.transfersIn).toBe(1);
expect(rOther.transfersOut).toBe(0);
});
it('transfersIn and transfersOut are counted on a single event if member is both', () => {
// Degenerate: staffId===toId===member. Phase 5b prevents this in practice,
// but the shaper is pure and should still count both dimensions.
const events = [event({ type: 'transfer', staffId: MEMBER, toId: MEMBER })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.transfersOut).toBe(1);
expect(r.transfersIn).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — reopens (via resolverId, not staffId)
// ---------------------------------------------------------------------------
describe('shapeStats — reopens', () => {
it('counts reopen events where resolverId===member', () => {
const events = [
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(1);
});
it('staffId on reopen is typically "system" — does not drive the reopen count', () => {
const events = [event({ type: 'reopen', staffId: MEMBER, resolverId: OTHER })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
it('null resolverId does not count', () => {
const events = [event({ type: 'reopen', staffId: 'system', resolverId: null })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — source filter
// ---------------------------------------------------------------------------
describe('shapeStats — source filter', () => {
it('"all" includes both email and discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('"email" includes only email events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'email').claims).toBe(1);
});
it('"discord" includes only discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'discord').claims).toBe(1);
});
it('source filter applies before all metric calculations', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'email');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
});
it('undefined source defaults to "all"', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER).claims).toBe(2);
});
});
// ---------------------------------------------------------------------------
// shapeStats — bySource breakdown
// ---------------------------------------------------------------------------
describe('shapeStats — bySource breakdown', () => {
it('splits claims by email/discord', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(2);
});
it('splits closes by email/discord', () => {
const events = [
event({ type: 'close', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.closes).toBe(1);
expect(r.bySource.discord.closes).toBe(1);
});
it('splits resolved by email/discord (using resolverId key)', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.resolved).toBe(1);
expect(r.bySource.discord.resolved).toBe(1);
});
it('events with unknown ticketType are bucketed as email', () => {
const events = [event({ type: 'claim', staffId: MEMBER, ticketType: undefined })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(0);
});
it('bySource totals match headline counts', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims + r.bySource.discord.claims).toBe(r.claims);
expect(r.bySource.email.closes + r.bySource.discord.closes).toBe(r.closes);
expect(r.bySource.email.resolved + r.bySource.discord.resolved).toBe(r.resolved);
});
});
// ---------------------------------------------------------------------------
// shapeStats — edge cases
// ---------------------------------------------------------------------------
describe('shapeStats — edge cases', () => {
it('empty events array returns zero counts', () => {
const r = shapeStats([], MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.unclaimedAtClose).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.claimsWhileEscalated).toEqual({});
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
expect(r.bySource.email.claims).toBe(0);
expect(r.bySource.discord.claims).toBe(0);
});
it('null events array is treated as empty', () => {
const r = shapeStats(null, MEMBER, 'all');
expect(r.claims).toBe(0);
});
it('events from other members are ignored for the requested member', () => {
const events = [
event({ type: 'claim', staffId: OTHER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
event({ type: 'escalate', staffId: OTHER, tier: 1 }),
event({ type: 'deescalate',staffId: OTHER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
});
it('handles member appearing in multiple roles across events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: OTHER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(1);
expect(r.closes).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
expect(r.resolved).toBe(1);
expect(r.transfersIn).toBe(1);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(1);
expect(r.escalations).toEqual({ 1: 1 });
});
it('events matching no member fields contribute nothing', () => {
const events = [
event({ type: 'response', staffId: MEMBER }), // 'response' type has no shaper rule
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims + r.closes + r.resolved + r.transfersIn + r.transfersOut + r.reopens).toBe(0);
});
});

View File

@@ -0,0 +1,166 @@
/**
* Phase 5b — transfer event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Tests applyTransfer directly (same pattern as testing applyClaim directly).
*
* Covers:
* (a) transfer to different member — one 'transfer' event with fromId/toId
* (b) transfer to current claimer — fromId === toId → no event
* (c) fromId/toId/staffId/guildId correctness
*/
import { describe, it, expect, vi } from 'vitest';
import { applyTransfer } from '../handlers/commands/index.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
escalated: false,
claimerId: 'prev-claimer-001',
claimedBy: 'PrevClaimerName',
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
...overrides
};
}
function makeGuildMember(userId = 'new-claimer-002') {
return {
id: userId,
displayName: 'New Claimer',
user: {
id: userId,
username: 'newclaimer',
tag: 'newclaimer#0002',
toString: () => `<@${userId}>`
}
};
}
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
tag: 'staffuser#0001',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: {
id: 'guild-001',
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
},
channel: {
id: 'chan-001',
name: 'ticket-chan-001',
isThread: vi.fn().mockReturnValue(true),
send: vi.fn().mockResolvedValue({ id: 'sent-msg-001' })
},
editReply: vi.fn().mockResolvedValue(undefined),
client: {
channels: { fetch: vi.fn().mockResolvedValue(null) }
}
};
}
// ---------------------------------------------------------------------------
// (a) Transfer to a different member — one event
// ---------------------------------------------------------------------------
describe('applyTransfer — different member emits one event', () => {
it('emits exactly one "transfer" event', async () => {
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('transfer');
});
it('passes correct fromId, toId, guildId, and ticket', async () => {
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBe('prev-claimer-001');
expect(payload.toId).toBe('new-claimer-002');
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(ticket);
});
it('fromId is the pre-write claimerId (captured before the DB write)', async () => {
const ticket = makeTicket({ claimerId: 'original-claimer' });
const guildMember = makeGuildMember('new-staff-003');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBe('original-claimer');
expect(payload.toId).toBe('new-staff-003');
});
});
// ---------------------------------------------------------------------------
// (b) Transfer to the current claimer — no event
// ---------------------------------------------------------------------------
describe('applyTransfer — transfer to current claimer emits no event', () => {
it('emits no event when fromId === toId (transferring to existing claimer)', async () => {
const ticket = makeTicket({ claimerId: 'same-claimer-001' });
const guildMember = makeGuildMember('same-claimer-001');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
it('emits one event when the ticket is unclaimed (fromId null) and toId is non-null — null !== toId satisfies the fromId !== toId gate', async () => {
// null !== 'new-claimer' so this IS a real transfer — event IS emitted
const ticket = makeTicket({ claimerId: null });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
// null !== 'new-claimer-002' → event IS emitted
expect(mockRecord).toHaveBeenCalledTimes(1);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBeNull();
expect(payload.toId).toBe('new-claimer-002');
});
});