From e77be9a3e47b5e855dd021db12084e315122e5f1 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 5 Jun 2026 02:02:48 +0000 Subject: [PATCH] Add per-staff metrics: StaffAction event log + /stats command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 1 + commands/register.js | 33 +- config.js | 1 + gmail-poll.js | 82 +++- handlers/buttons.js | 40 +- handlers/commands/close.js | 33 +- handlers/commands/escalation.js | 36 +- handlers/commands/index.js | 106 +++-- handlers/commands/response.js | 22 +- handlers/commands/stats.js | 139 ++++++ handlers/messages.js | 17 +- models.js | 30 +- services/configSchema.js | 2 +- services/staffStats.js | 54 +++ services/statsShaping.js | 154 +++++++ services/tickets.js | 100 +++- tests/autocompleteDispatch.test.js | 66 +++ tests/claimEvents.test.js | 171 +++++++ tests/closeEvents.test.js | 310 +++++++++++++ tests/closeTransition.test.js | 110 +++++ tests/configSchema.test.js | 43 +- tests/escalateEvents.test.js | 188 ++++++++ tests/gmailPollEvents.test.js | 169 +++++++ tests/responseEvents.test.js | 245 ++++++++++ tests/staffStats.test.js | 187 ++++++++ tests/statsHandler.test.js | 350 ++++++++++++++ tests/statsShaping.test.js | 716 +++++++++++++++++++++++++++++ tests/transferEvents.test.js | 166 +++++++ 28 files changed, 3447 insertions(+), 124 deletions(-) create mode 100644 handlers/commands/stats.js create mode 100644 services/staffStats.js create mode 100644 services/statsShaping.js create mode 100644 tests/autocompleteDispatch.test.js create mode 100644 tests/claimEvents.test.js create mode 100644 tests/closeEvents.test.js create mode 100644 tests/closeTransition.test.js create mode 100644 tests/escalateEvents.test.js create mode 100644 tests/gmailPollEvents.test.js create mode 100644 tests/responseEvents.test.js create mode 100644 tests/staffStats.test.js create mode 100644 tests/statsHandler.test.js create mode 100644 tests/statsShaping.test.js create mode 100644 tests/transferEvents.test.js diff --git a/.env.example b/.env.example index 3f2b048..012cf6c 100644 --- a/.env.example +++ b/.env.example @@ -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 BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets 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_ENABLED=false diff --git a/commands/register.js b/commands/register.js index a23beba..4825d60 100644 --- a/commands/register.js +++ b/commands/register.js @@ -435,7 +435,38 @@ async function registerCommands() { .setDescription('Set your personal email signature (valediction, display name, tagline)') .setContexts([InteractionContextType.Guild]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .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) + .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 = [ diff --git a/config.js b/config.js index ac1abee..75f13b9 100644 --- a/config.js +++ b/config.js @@ -53,6 +53,7 @@ const CONFIG = { 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), 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_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} ā˜€ļø', diff --git a/gmail-poll.js b/gmail-poll.js index 80e4b65..30cadf8 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -26,6 +26,7 @@ const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDis const { logError } = require('./services/debugLog'); const { enqueueSend } = require('./services/channelQueue'); const { getTicketActionRow } = require('./utils/ticketComponents'); +const { recordAction } = require('./services/staffStats'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); @@ -250,6 +251,54 @@ function oauthSuspendIfPermanent(err, client) { 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 // ============================================================ @@ -287,6 +336,7 @@ async function poll(client) { .select('gmailThreadId discordThreadId status') .lean(); + const wasClosedTicket = !!existing && existing.status === 'closed'; let ticketChan = null; let parentCategoryIdForTicket = null; let isReopened = false; @@ -380,23 +430,23 @@ async function poll(client) { const now = new Date(); const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; - await withRetry(() => Ticket.findOneAndUpdate( - { gmailThreadId: parsed.threadId }, + await persistEmailTicket( { - $set: { - discordThreadId: ticketChan.id, - senderEmail: parsed.senderEmail, - subject: parsed.subject, - createdAt: now, - status: 'open', - ticketNumber: number, - priority: defaultPriority, - lastActivity: now, - parentCategoryId: parentCategoryIdForTicket - } + threadId: parsed.threadId, + discordThreadId: ticketChan.id, + senderEmail: parsed.senderEmail, + subject: parsed.subject, + createdAt: now, + ticketNumber: number, + priority: defaultPriority, + 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 // the inbox, marked read, awaiting staff action. The threads.modify also @@ -416,4 +466,4 @@ async function poll(client) { } } -module.exports = { poll, setPollSuspended }; +module.exports = { poll, setPollSuspended, persistEmailTicket }; diff --git a/handlers/buttons.js b/handlers/buttons.js index 2f560d4..37e850f 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -22,12 +22,13 @@ const { } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); -const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { moveThreadToFolder } = require('../services/gmailLabels'); const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript'); -const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); +const { sanitizeEmbedText, truncateEmbedDescription, isStaff } = require('../utils'); +const { recordAction } = require('../services/staffStats'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); @@ -158,14 +159,24 @@ async function handleClaimButton(interaction, ticket) { } } -async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) { - await Ticket.updateOne( - { gmailThreadId: freshTicket.gmailThreadId }, +async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild, _TicketModel, _recordAction) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; + + const result = await T.updateOne( + { gmailThreadId: freshTicket.gmailThreadId, claimerId: { $ne: interaction.user.id } }, { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } ); freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; + if (result.modifiedCount === 1) { + record(interaction.user.id, 'claim', { + ticket: freshTicket, + guildId: interaction.guild?.id + }); + } + const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; @@ -442,12 +453,17 @@ async function runFinalClose(interaction, ticket, sendEmail = true) { await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id); } - // $unset welcomeMessageId so a future reopen on this thread doesn't carry - // a stale message ID pointing into the now-deleted channel. - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } } - ); + const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, { welcomeMessageId: '' }); + if (transitioned) { + const closerType = isStaff(interaction.member) ? 'staff' : 'user'; + recordAction(interaction.user.id, 'close', { + ticket: closedTicket, + guildId: interaction.guild?.id, + closerType, + resolverId: closedTicket.claimerId ?? null, + wasClaimed: Boolean(closedTicket.claimerId) + }); + } // File the email thread into the Resolved folder — non-fatal, email tickets only. if (!ticket.gmailThreadId?.startsWith('discord-')) { @@ -754,4 +770,4 @@ async function handleButton(interaction) { return ticketHandler(interaction, ticket); } -module.exports = { handleButton, handleTicketModal }; +module.exports = { handleButton, handleTicketModal, runFinalClose, applyClaim }; diff --git a/handlers/commands/close.js b/handlers/commands/close.js index 43f3a43..3296059 100644 --- a/handlers/commands/close.js +++ b/handlers/commands/close.js @@ -15,7 +15,9 @@ const { logTicketEvent, logError } = require('../../services/debugLog'); const { moveThreadToFolder } = require('../../services/gmailLabels'); const { pendingCloses } = require('../pendingCloses'); const { findTicketForChannel } = require('../sharedHelpers'); +const { attemptCloseTransition } = require('../../services/tickets'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript'); +const { recordAction } = require('../../services/staffStats'); const Ticket = mongoose.model('Ticket'); @@ -58,22 +60,31 @@ async function handleForceClose(interaction) { const channelRef = interaction.channel; const clientRef = interaction.client; const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); - pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag }); + pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag, closerId: interaction.user.id }); } /** Performs the actual force-close work after the countdown elapses. */ -async function finalizeForceClose(channelRef, clientRef) { - pendingCloses.delete(channelRef.id); - const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean(); +async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; + const pc = _pendingCloses || pendingCloses; + const pending = pc.get(channelRef.id); + pc.delete(channelRef.id); + const closerId = pending?.closerId ?? null; + const freshTicket = await T.findOne({ discordThreadId: channelRef.id }).lean(); if (!freshTicket || freshTicket.status === 'closed') return; try { - // $unset welcomeMessageId so a future reopen on this thread doesn't carry - // a stale message ID pointing into the now-deleted channel. - await Ticket.updateOne( - { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } } - ); + const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T); + if (transitioned) { + record(closerId ?? 'system', 'close', { + ticket: closedTicket, + guildId: channelRef.guild?.id, + closerType: closerId ? 'staff' : 'system', + resolverId: closedTicket.claimerId ?? null, + wasClaimed: Boolean(closedTicket.claimerId) + }); + } // File the email thread into the Resolved folder — non-fatal, email tickets only. if (!freshTicket.gmailThreadId.startsWith('discord-')) { @@ -116,4 +127,4 @@ async function postTranscript(channelRef, clientRef, freshTicket) { await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } }); } -module.exports = { handleCloseTimer, handleCancelClose, handleForceClose }; +module.exports = { handleCloseTimer, handleCancelClose, handleForceClose, finalizeForceClose }; diff --git a/handlers/commands/escalation.js b/handlers/commands/escalation.js index 19d12ff..8dac1e6 100644 --- a/handlers/commands/escalation.js +++ b/handlers/commands/escalation.js @@ -17,6 +17,7 @@ const { pinMessage } = require('../../services/pinMessage'); const { logError } = require('../../services/debugLog'); const { findTicketForChannel, runDeferred } = require('../sharedHelpers'); const { fetchLoggingChannel } = require('./helpers'); +const { recordAction } = require('../../services/staffStats'); const Ticket = mongoose.model('Ticket'); @@ -37,19 +38,29 @@ function resolveEscalationCategoryId(ticket, nextTier) { * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must * validate ticket and currentTier < nextTier, and have already deferred. */ -async function runEscalation(interaction, ticket, nextTier) { +async function runEscalation(interaction, ticket, nextTier, _TicketModel, _recordAction) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const categoryId = resolveEscalationCategoryId(ticket, nextTier); - // Clear claim on escalation - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, + // Conditional write: only update if the tier hasn't already been set to nextTier. + // modifiedCount === 0 means a concurrent request already escalated — no event. + const result = await T.updateOne( + { gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: nextTier } }, { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } ); ticket.escalated = true; ticket.escalationTier = nextTier; ticket.claimedBy = null; + if (result.modifiedCount === 1) { + record(interaction.user.id, 'escalate', { + ticket, + guildId: interaction.guild?.id + }); + } + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const newName = makeTicketName('escalated', ticket, creatorNickname); enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); @@ -136,19 +147,30 @@ async function runEscalation(interaction, ticket, nextTier) { } /** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */ -async function runDeescalation(interaction, ticket) { +async function runDeescalation(interaction, ticket, _TicketModel, _recordAction) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const newTier = currentTier - 1; - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, + // Conditional write: only update if the tier hasn't already been set to newTier. + // modifiedCount === 0 means a concurrent request already deescalated — no event. + const result = await T.updateOne( + { gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: newTier } }, { $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } } ); ticket.escalated = newTier > 0; ticket.escalationTier = newTier; ticket.claimedBy = null; + if (result.modifiedCount === 1) { + record(interaction.user.id, 'deescalate', { + ticket, + guildId: interaction.guild?.id + }); + } + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const state = newTier === 0 ? 'unclaimed' : 'escalated'; enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {})); diff --git a/handlers/commands/index.js b/handlers/commands/index.js index 4beb7ef..9c5ea71 100644 --- a/handlers/commands/index.js +++ b/handlers/commands/index.js @@ -19,6 +19,7 @@ const { mongoose } = require('../../db-connection'); const { CONFIG } = require('../../config'); const { isStaff } = require('../../utils'); const { setNotifyDm } = require('../../services/staffSettings'); +const { recordAction } = require('../../services/staffStats'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); const { logError, logTicketEvent } = require('../../services/debugLog'); @@ -29,10 +30,11 @@ const { findTicketForChannel } = require('../sharedHelpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); -const { handleResponse, handleAutocomplete } = require('./response'); +const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response'); const { handlePanel, handleSignature } = require('./panel'); const { handleForward } = require('./forward'); const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu'); +const { handleStats, handleStatsAutocomplete } = require('./stats'); const Ticket = mongoose.model('Ticket'); @@ -93,6 +95,57 @@ async function handleRemove(interaction) { } } +async function applyTransfer(interaction, ticket, guildMember, reason, _TicketModel, _recordAction) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; + + const fromId = ticket.claimerId; // capture BEFORE the write + const toId = guildMember.id; + const claimerLabel = guildMember.displayName || guildMember.user.username; + + await T.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { claimedBy: claimerLabel, claimerId: toId } } + ); + ticket.claimedBy = claimerLabel; + ticket.claimerId = toId; + + // Gate: transferring to the member who already holds the claim is a no-op. + if (fromId !== toId) { + record(interaction.user.id, 'transfer', { + ticket, + guildId: interaction.guild?.id, + fromId, + toId + }); + } + + // Rename the channel to reflect the new claimer — mirrors the /claim + // button flow (applyClaim in handlers/buttons.js). Picks the new + // claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed + // variant when tier >= 1. + const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); + const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + const state = tier >= 1 ? 'escalated-claimed' : 'claimed'; + enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji)) + .catch(err => logError('rename', err).catch(() => {})); + + // `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping. + await interaction.editReply({ + content: `Ticket transferred to ${guildMember.user} by ${interaction.user}.\nReason: ${reason}`, + allowedMentions: { parse: ['users'] } + }); + + const logChan = await fetchLoggingChannel(interaction.client); + if (logChan) { + await enqueueSend(logChan, { + content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${guildMember.user.tag}.\nReason: ${reason}`, + allowedMentions: { parse: [] } + }); + } +} + async function handleTransfer(interaction) { const member = interaction.options.getUser('member'); const reason = interaction.options.getString('reason') || 'No reason provided'; @@ -125,39 +178,7 @@ async function handleTransfer(interaction) { await interaction.deferReply(); try { - const claimerLabel = guildMember.displayName || guildMember.user.username; - - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { claimedBy: claimerLabel, claimerId: guildMember.id } } - ); - ticket.claimedBy = claimerLabel; - ticket.claimerId = guildMember.id; - - // Rename the channel to reflect the new claimer — mirrors the /claim - // button flow (applyClaim in handlers/buttons.js). Picks the new - // claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed - // variant when tier >= 1. - const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; - const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); - const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); - const state = tier >= 1 ? 'escalated-claimed' : 'claimed'; - enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji)) - .catch(err => logError('rename', err).catch(() => {})); - - // `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping. - await interaction.editReply({ - content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`, - allowedMentions: { parse: ['users'] } - }); - - const logChan = await fetchLoggingChannel(interaction.client); - if (logChan) { - await enqueueSend(logChan, { - content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`, - allowedMentions: { parse: [] } - }); - } + await applyTransfer(interaction, ticket, guildMember, reason); } catch (err) { console.error('Transfer error:', err); await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {}); @@ -413,7 +434,8 @@ const COMMAND_HANDLERS = { response: handleResponse, signature: handleSignature, help: handleHelp, - panel: handlePanel + panel: handlePanel, + stats: handleStats }; const CONTEXT_MENU_HANDLERS = { @@ -421,6 +443,17 @@ const CONTEXT_MENU_HANDLERS = { 'View User Tickets': handleViewUserTickets }; +const AUTOCOMPLETE_HANDLERS = { + response: handleResponseAutocomplete, + stats: handleStatsAutocomplete +}; + +async function handleAutocomplete(interaction, _handlers) { + const handlers = _handlers || AUTOCOMPLETE_HANDLERS; + const handler = handlers[interaction.commandName]; + if (handler) await handler(interaction); +} + /** * Slash-command dispatcher. Every command is staff-only — including /help, * which previously bypassed the role check. @@ -444,5 +477,6 @@ module.exports = { handleAutocomplete, runEscalation, runDeescalation, - resolveEscalationCategoryId + resolveEscalationCategoryId, + applyTransfer }; diff --git a/handlers/commands/response.js b/handlers/commands/response.js index 56ca407..7a1f8c5 100644 --- a/handlers/commands/response.js +++ b/handlers/commands/response.js @@ -17,6 +17,7 @@ const { mongoose } = require('../../db-connection'); const { CONFIG } = require('../../config'); const { replaceVariables } = require('../../utils'); const { logError } = require('../../services/debugLog'); +const { recordAction } = require('../../services/staffStats'); const Tag = mongoose.model('Tag'); const Ticket = mongoose.model('Ticket'); @@ -38,14 +39,18 @@ async function handleResponse(interaction) { } } -async function handleResponseSend(interaction) { +async function handleResponseSend(interaction, _TagModel, _TicketModel, _recordAction) { + const TTag = _TagModel || Tag; + const TTicket = _TicketModel || Ticket; + const record = _recordAction || recordAction; + const name = interaction.options.getString('name'); - const tag = await Tag.findOne({ name }).lean(); + const tag = await TTag.findOne({ name }).lean(); if (!tag) { return interaction.reply({ content: `āŒ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral }); } - const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + const ticket = await TTicket.findOne({ discordThreadId: interaction.channel.id }).lean(); const context = { ticket: ticket || {}, staff: { @@ -57,10 +62,14 @@ async function handleResponseSend(interaction) { }; const content = replaceVariables(tag.content, context); - await Tag.updateOne({ name }, { $inc: { useCount: 1 } }); + await TTag.updateOne({ name }, { $inc: { useCount: 1 } }); // Tag bodies are staff-authored but may include variable substitutions from user/ticket data. // Disable mention parsing so a `@everyone` in a tag body never pings. await interaction.reply({ content, allowedMentions: { parse: [] } }); + + if (ticket) { + record(interaction.user.id, 'response', { ticket, guildId: interaction.guild?.id }); + } } async function handleResponseCreate(interaction) { @@ -146,9 +155,8 @@ const RESPONSE_SUBCOMMANDS = { list: handleResponseList }; -/** Autocomplete handler. Currently only /response uses it. */ +/** Autocomplete handler for /response. Routed here by the dispatcher in index.js. */ async function handleAutocomplete(interaction) { - if (interaction.commandName !== 'response') return; const subcommand = interaction.options.getSubcommand(); if (!['send', 'edit', 'delete'].includes(subcommand)) return; @@ -162,4 +170,4 @@ async function handleAutocomplete(interaction) { await interaction.respond(filtered); } -module.exports = { handleResponse, handleAutocomplete }; +module.exports = { handleResponse, handleAutocomplete, handleResponseSend }; diff --git a/handlers/commands/stats.js b/handlers/commands/stats.js new file mode 100644 index 0000000..cc4add5 --- /dev/null +++ b/handlers/commands/stats.js @@ -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 }; diff --git a/handlers/messages.js b/handlers/messages.js index 22d606d..dac80d9 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -8,21 +8,26 @@ const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { autoAdvanceFolder } = require('../services/gmailLabels'); const { getNotifyDm } = require('../services/staffSettings'); const { logError } = require('../services/debugLog'); +const { recordAction } = require('../services/staffStats'); const Ticket = mongoose.model('Ticket'); /** * Handle a Discord message in a ticket channel → relay to Gmail (email tickets only). */ -async function handleDiscordReply(m) { +async function handleDiscordReply(m, _TicketModel, _recordAction, _isStaff) { + const T = _TicketModel || Ticket; + const record = _recordAction || recordAction; + const checkIsStaff = _isStaff || isStaff; + if (m.author.bot || m.interaction) return; - const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); + const ticket = await T.findOne({ discordThreadId: m.channel.id }).lean(); if (!ticket) return; const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); - const isStaffMember = isStaff(memberForCheck); - Ticket.updateOne( + const isStaffMember = checkIsStaff(memberForCheck); + T.updateOne( { discordThreadId: m.channel.id }, { $set: { lastActivity: new Date() } } ).catch(err => logError('updateActivity', err).catch(() => {})); @@ -47,6 +52,10 @@ async function handleDiscordReply(m) { } } + if (isStaffMember) { + record(m.author.id, 'response', { ticket, guildId: m.guild?.id }); + } + if (ticket.gmailThreadId.startsWith('discord-')) { return; } diff --git a/models.js b/models.js index 2328a22..1b386f9 100644 --- a/models.js +++ b/models.js @@ -20,7 +20,9 @@ const ticketSchema = new mongoose.Schema({ claimerId: String, creatorId: 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({ senderEmail: 1, status: 1 }); @@ -61,3 +63,29 @@ mongoose.model('StaffSignature', new mongoose.Schema({ tagline: { type: String, default: '' }, 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); diff --git a/services/configSchema.js b/services/configSchema.js index 942217c..3199c55 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -25,7 +25,7 @@ const ALLOWED_CONFIG_KEYS = new Set([ 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', // Roles and staff 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', - 'ADMIN_ID', + 'ADMIN_ID', 'STATS_ADMIN_IDS', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', // Messages and labels diff --git a/services/staffStats.js b/services/staffStats.js new file mode 100644 index 0000000..8325095 --- /dev/null +++ b/services/staffStats.js @@ -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 }; diff --git a/services/statsShaping.js b/services/statsShaping.js new file mode 100644 index 0000000..79ec2dd --- /dev/null +++ b/services/statsShaping.js @@ -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: d / day(s), w / week(s), m / mo / month(s), + * 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 }; diff --git a/services/tickets.js b/services/tickets.js index b8639a2..cd60e06 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -6,6 +6,7 @@ const { ChannelType } = require('discord.js'); const { mongoose, withRetry } = require('../db-connection'); const { CONFIG } = require('../config'); const { enqueueSend, enqueueDelete } = require('./channelQueue'); +const { recordAction } = require('./staffStats'); const Ticket = mongoose.model('Ticket'); const TicketCounter = mongoose.model('TicketCounter'); @@ -269,15 +270,43 @@ async function checkTicketLimits(senderEmail) { 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 --- // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. -async function checkAutoClose(client, sendTicketClosedEmail) { - if (!CONFIG.AUTO_CLOSE_ENABLED) return; +async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _recordAction, _deps) { + 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. - const staleTickets = await withRetry(() => Ticket.find({ + const staleTickets = await _withRetry(() => T.find({ status: 'open', lastActivity: { $lt: cutoffTime, $ne: null } }).sort({ createdAt: 1 }).limit(500).lean()); @@ -289,28 +318,39 @@ async function checkAutoClose(client, sendTicketClosedEmail) { try { const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); 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 // resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete // resolves; if the doc is gone the unset is a no-op. - await withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed', pendingDelete: true } } - )); + const { transitioned: autoTransitioned, ticket: autoClosedTicket } = + await _withRetry(() => attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, {}, T)); + 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); - // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe. - const { trackTimeout } = require('../broccolini-discord'); - trackTimeout(setTimeout(() => { - enqueueDelete(channel).then(() => { - withRetry(() => Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $unset: { pendingDelete: '' } } - )).catch(() => {}); - }).catch(() => {}); - }, 5000)); + if (_deps && _deps.scheduleDelete) { + _deps.scheduleDelete(channel, ticket); + } else { + // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe. + const { trackTimeout } = require('../broccolini-discord'); + trackTimeout(setTimeout(() => { + enqueueDelete(channel).then(() => { + withRetry(() => Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $unset: { pendingDelete: '' } } + )).catch(() => {}); + }).catch(() => {}); + }, 5000)); + } } } catch (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(); if (!guild) return; // Bounded per-tick; a larger backlog drains in subsequent hourly runs. - const openTickets = await Ticket.find({ + const openTickets = await T.find({ status: 'open', discordThreadId: { $ne: null } }).sort({ createdAt: 1 }).limit(500).lean(); @@ -369,10 +411,17 @@ async function reconcileDeletedTicketChannels(client) { channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); } if (!channel) { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed', discordThreadId: null } } - ); + const { transitioned: reconTransitioned, ticket: reconClosedTicket } = + await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, {}, T); + if (reconTransitioned) { + record('system', 'close', { + ticket: reconClosedTicket, + guildId: guild.id, + closerType: 'system', + resolverId: reconClosedTicket.claimerId ?? null, + wasClaimed: Boolean(reconClosedTicket.claimerId) + }); + } } } catch (err) { console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); @@ -417,6 +466,7 @@ module.exports = { makeTicketName, checkTicketCreationRateLimit, checkTicketLimits, + attemptCloseTransition, checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, diff --git a/tests/autocompleteDispatch.test.js b/tests/autocompleteDispatch.test.js new file mode 100644 index 0000000..78fb865 --- /dev/null +++ b/tests/autocompleteDispatch.test.js @@ -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); + }); +}); diff --git a/tests/claimEvents.test.js b/tests/claimEvents.test.js new file mode 100644 index 0000000..dfe2970 --- /dev/null +++ b/tests/claimEvents.test.js @@ -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); + }); +}); diff --git a/tests/closeEvents.test.js b/tests/closeEvents.test.js new file mode 100644 index 0000000..8278d40 --- /dev/null +++ b/tests/closeEvents.test.js @@ -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(); + }); +}); diff --git a/tests/closeTransition.test.js b/tests/closeTransition.test.js new file mode 100644 index 0000000..a5f6fd3 --- /dev/null +++ b/tests/closeTransition.test.js @@ -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(); + }); +}); diff --git a/tests/configSchema.test.js b/tests/configSchema.test.js index 4616437..767057a 100644 --- a/tests/configSchema.test.js +++ b/tests/configSchema.test.js @@ -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', () => { for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) { expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false); @@ -192,9 +196,8 @@ describe('discord_id validator', () => { describe('discord_id_list validator', () => { // 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 - // exercise it with a hypothetical name. - const v = getValidator('STAFF_USER_IDS'); + // hits the string fallback. discord_id_list only fires for `*_IDS` keys. + const v = getValidator('STATS_ADMIN_IDS'); it('infers type discord_id_list for *_IDS keys', () => { 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)', () => { const v = getValidator('TICKET_CATEGORY_NAME'); diff --git a/tests/escalateEvents.test.js b/tests/escalateEvents.test.js new file mode 100644 index 0000000..f1aee11 --- /dev/null +++ b/tests/escalateEvents.test.js @@ -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(); + }); +}); diff --git a/tests/gmailPollEvents.test.js b/tests/gmailPollEvents.test.js new file mode 100644 index 0000000..e389ac8 --- /dev/null +++ b/tests/gmailPollEvents.test.js @@ -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(); + }); +}); diff --git a/tests/responseEvents.test.js b/tests/responseEvents.test.js new file mode 100644 index 0000000..20297fd --- /dev/null +++ b/tests/responseEvents.test.js @@ -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(); + }); +}); diff --git a/tests/staffStats.test.js b/tests/staffStats.test.js new file mode 100644 index 0000000..fddcdee --- /dev/null +++ b/tests/staffStats.test.js @@ -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(); + }); +}); diff --git a/tests/statsHandler.test.js b/tests/statsHandler.test.js new file mode 100644 index 0000000..a59aa9b --- /dev/null +++ b/tests/statsHandler.test.js @@ -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'); + }); +}); diff --git a/tests/statsShaping.test.js b/tests/statsShaping.test.js new file mode 100644 index 0000000..52d8ad4 --- /dev/null +++ b/tests/statsShaping.test.js @@ -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('d', () => { + const r = parsePeriod('14d'); + expect(r.value).toBe(14); + expect(r.unit).toBe('days'); + expect(r.durationMs).toBe(14 * MS_PER_DAY); + }); + + it('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(' day (singular, with space)', () => { + const r = parsePeriod('1 day'); + expect(r.value).toBe(1); + expect(r.label).toBe('1 day'); + }); + + it(' days', () => { + const r = parsePeriod('10 days'); + expect(r.durationMs).toBe(10 * MS_PER_DAY); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — week unit variants +// --------------------------------------------------------------------------- + +describe('parsePeriod — week variants', () => { + it('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(' week (singular)', () => { + const r = parsePeriod('1 week'); + expect(r.value).toBe(1); + expect(r.label).toBe('1 week'); + }); + + it('weeks (no space)', () => { + const r = parsePeriod('4weeks'); + expect(r.value).toBe(4); + expect(r.unit).toBe('weeks'); + }); + + it(' weeks', () => { + const r = parsePeriod('4 weeks'); + expect(r.durationMs).toBe(4 * 7 * MS_PER_DAY); + }); +}); + +// --------------------------------------------------------------------------- +// parsePeriod — month unit variants +// --------------------------------------------------------------------------- + +describe('parsePeriod — month variants', () => { + it('m', () => { + const r = parsePeriod('3m'); + expect(r.unit).toBe('months'); + expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY); + }); + + it('mo', () => { + const r = parsePeriod('6mo'); + expect(r.unit).toBe('months'); + expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY); + }); + + it(' month (singular)', () => { + const r = parsePeriod('1 month'); + expect(r.value).toBe(1); + expect(r.label).toBe('1 month'); + }); + + it(' 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('y', () => { + const r = parsePeriod('1y'); + expect(r.unit).toBe('years'); + expect(r.durationMs).toBe(365 * MS_PER_DAY); + }); + + it(' year (singular)', () => { + const r = parsePeriod('1 year'); + expect(r.unit).toBe('years'); + expect(r.label).toBe('1 year'); + }); + + it(' 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); + }); +}); diff --git a/tests/transferEvents.test.js b/tests/transferEvents.test.js new file mode 100644 index 0000000..a86cb3a --- /dev/null +++ b/tests/transferEvents.test.js @@ -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'); + }); +});