Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)
Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths
Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
223 lines
9.2 KiB
JavaScript
223 lines
9.2 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');
|
||
|
||
/**
|
||
* 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) {
|
||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
|
||
|
||
// 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, 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 = 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 };
|