The /escalate slash command never had a reason option in its definition
(commands/register.js only takes a 'level' option), so handleEscalate
hardcoded reason=null. The escalate button path passed null explicitly.
The log line wrote it verbatim as "Reason: null" on every escalate.
Remove the dead surface:
- runEscalation signature drops the reason parameter.
- The customer-facing email body drops the conditional reason suffix
(`reason ? `\n\nReason: ${reason}` : ''`) — always-false branch.
- The logging-channel post drops "\nReason: ${reason}".
- handleEscalate drops the `const reason = null;` line and the call-site arg.
- handleEscalateButton (handlers/buttons.js) drops the trailing `null` arg.
If we ever want to capture a reason, the slash command would need a
StringOption('reason') and an escalate-modal for the button path —
neither exists today.
214 lines
9.0 KiB
JavaScript
214 lines
9.0 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 { 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 Ticket = mongoose.model('Ticket');
|
||
|
||
/**
|
||
* 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) {
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
const categoryId = nextTier === 1
|
||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||
|
||
// Clear claim on escalation
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||
);
|
||
ticket.escalated = true;
|
||
ticket.escalationTier = nextTier;
|
||
ticket.claimedBy = null;
|
||
|
||
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';
|
||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`;
|
||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||
} catch (emailErr) {
|
||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||
}
|
||
}
|
||
|
||
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,
|
||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||
async function runDeescalation(interaction, ticket) {
|
||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
const newTier = currentTier - 1;
|
||
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
|
||
);
|
||
ticket.escalated = newTier > 0;
|
||
ticket.escalationTier = newTier;
|
||
ticket.claimedBy = null;
|
||
|
||
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,
|
||
`${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||
);
|
||
}
|
||
}
|
||
|
||
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 = nextTier === 1
|
||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||
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 };
|