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.
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
/**
|
||
* Escalation flows.
|
||
*
|
||
* runEscalation / runDeescalation are exported for handlers/buttons.js
|
||
* (the tier-pick buttons share this code path). handleEscalate /
|
||
* handleDeescalate are the slash-command entry points.
|
||
*/
|
||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||
const { mongoose } = require('../../db-connection');
|
||
const { CONFIG } = require('../../config');
|
||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
||
const { moveThreadToFolder } = require('../../services/gmailLabels');
|
||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
||
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');
|
||
|
||
/**
|
||
* Resolve the destination category for an escalation target tier
|
||
* (nextTier 1 = tier 2, 2 = tier 3), picking the Discord vs email category set
|
||
* by ticket origin. Returns null/undefined when the relevant category is unset.
|
||
*/
|
||
function resolveEscalationCategoryId(ticket, nextTier) {
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
if (nextTier === 1) {
|
||
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||
}
|
||
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
|
||
}
|
||
|
||
/**
|
||
* 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, _TicketModel, _recordAction) {
|
||
const T = _TicketModel || Ticket;
|
||
const record = _recordAction || recordAction;
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
|
||
|
||
// 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(() => {}));
|
||
|
||
if (!interaction.channel.isThread() && categoryId) {
|
||
await enqueueMove(interaction.channel, categoryId);
|
||
}
|
||
|
||
const pendingEmbed = new EmbedBuilder()
|
||
.setDescription('Ticket will be escalated in a few seconds.')
|
||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||
await interaction.editReply({ embeds: [pendingEmbed] });
|
||
|
||
const creatorId = isDiscordTicket
|
||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||
: null;
|
||
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
||
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
|
||
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
||
await enqueueSend(interaction.channel, {
|
||
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
||
allowedMentions: { parse: ['users', 'roles'] }
|
||
});
|
||
|
||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||
.replace(/\\n/g, '\n')
|
||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
|
||
const escalatedEmbed = new EmbedBuilder()
|
||
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
|
||
.setDescription(escalationBody)
|
||
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
|
||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||
const escalationMsg = await enqueueSend(interaction.channel, {
|
||
content: null,
|
||
embeds: [escalatedEmbed],
|
||
components: [escalationRow]
|
||
});
|
||
|
||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||
}
|
||
|
||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||
try {
|
||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||
// Editable via TICKET_ESCALATION_EMAIL_MESSAGE in .env. Placeholders:
|
||
// {escalator_name}, {tier}; \n for line breaks.
|
||
const emailBody = (CONFIG.TICKET_ESCALATION_EMAIL_MESSAGE || '')
|
||
.replace(/\\n/g, '\n')
|
||
.replace(/\{escalator_name\}/g, escalatorName)
|
||
.replace(/\{tier\}/g, tierLabel);
|
||
await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id);
|
||
} catch (emailErr) {
|
||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||
}
|
||
// File the email thread into the Escalated folder — non-fatal, never blocks
|
||
// the escalation.
|
||
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
|
||
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
|
||
}
|
||
|
||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||
try {
|
||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||
} catch (e) {
|
||
console.error('Failed to update welcome message after escalate:', e.message);
|
||
}
|
||
}
|
||
|
||
const logChan = await fetchLoggingChannel(interaction.client);
|
||
if (logChan) {
|
||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||
await enqueueSend(logChan, {
|
||
content: `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`,
|
||
allowedMentions: { parse: [] }
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||
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;
|
||
|
||
// 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(() => {}));
|
||
|
||
if (!interaction.channel.isThread()) {
|
||
try {
|
||
if (newTier === 0) {
|
||
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
|
||
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
|
||
} else if (newTier === 1) {
|
||
const t2Category = isDiscordTicket
|
||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||
if (t2Category) await enqueueMove(interaction.channel, t2Category);
|
||
}
|
||
} catch (e) {
|
||
console.error('Move error (deescalate):', e);
|
||
}
|
||
}
|
||
|
||
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
|
||
const deescalateEmbed = new EmbedBuilder()
|
||
.setColor(0x00BFFF)
|
||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||
|
||
const logChan = await fetchLoggingChannel(interaction.client);
|
||
if (logChan) {
|
||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||
await enqueueSend(logChan, {
|
||
content: `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`,
|
||
allowedMentions: { parse: [] }
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleEscalate(interaction) {
|
||
const level = interaction.options.getString('level');
|
||
const nextTier = level === '3' ? 2 : 1;
|
||
|
||
const ticket = await findTicketForChannel(interaction);
|
||
if (!ticket) return;
|
||
|
||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||
if (currentTier >= 2) {
|
||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
if (nextTier <= currentTier) {
|
||
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
|
||
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
||
if (!categoryId && !interaction.channel.isThread()) {
|
||
return interaction.reply({
|
||
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
||
flags: MessageFlags.Ephemeral
|
||
});
|
||
}
|
||
|
||
await runDeferred(interaction, 'escalate', () =>
|
||
runEscalation(interaction, ticket, nextTier)
|
||
);
|
||
}
|
||
|
||
async function handleDeescalate(interaction) {
|
||
const ticket = await findTicketForChannel(interaction);
|
||
if (!ticket) return;
|
||
|
||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||
if (currentTier === 0) {
|
||
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
await runDeferred(interaction, 'de-escalate',
|
||
() => runDeescalation(interaction, ticket),
|
||
{ flags: MessageFlags.Ephemeral }
|
||
);
|
||
}
|
||
|
||
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId };
|