/** * 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, reason) { 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}.${reason ? `\n\nReason: ${reason}` : ''}`; 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}.\nReason: ${reason}` ); } } /** 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 reason = null; 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, reason) ); } 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 };