Close: make side-effects best-effort so none can abort the commit+delete

runFinalClose ran the transcript archive, creator DM, close log, and closure
email in the same try as the close transition and channel delete, with the
transcript posted *before* the commit. A failure in any of them (notably a
DiscordAPIError 50001 Missing Access when posting the transcript to the archive
channel) aborted the whole close: the customer saw 'ticket closed' but the DB
stayed open and the channel was never deleted.

Rewrite so the close transition + pendingDelete commit FIRST, each side-effect is
individually best-effort via a closeStep wrapper, and scheduleTicketChannelDelete
always runs. finalizeForceClose was already commit-first; wrap its remaining
unguarded archiving send too.
This commit is contained in:
2026-06-05 11:27:45 +00:00
parent b0e8d15273
commit 6a7dee679c
2 changed files with 98 additions and 80 deletions

View File

@@ -404,8 +404,20 @@ async function handleDeescalateButton(interaction, ticket) {
* posts to the transcript channel and optionally DMs the creator, sends the
* customer closure email (email tickets only), then deletes the channel.
*/
// Run one best-effort close side-effect. A failure is logged but never propagates,
// so it cannot abort the close — the transition and channel delete still happen.
async function closeStep(label, fn) {
try {
await fn();
} catch (e) {
logError(`runFinalClose:${label}`, e).catch(() => {});
}
}
async function runFinalClose(interaction, ticket, sendEmail = true) {
const closedAt = new Date();
const channel = interaction.channel;
const channelName = channel.name;
try {
await interaction.update({ content: 'Archiving and closing...', components: [] });
@@ -414,49 +426,63 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
}
try {
const channelName = interaction.channel.name;
const transcriptText = await buildTranscriptText(interaction.channel, ticket);
const file = new AttachmentBuilder(Buffer.from(transcriptText), {
name: `transcript-${channelName}.txt`
// Build the transcript up front — it needs the channel's history, before delete.
// Best-effort: a failure here must not block the close.
let transcriptText = null;
await closeStep('buildTranscript', async () => { transcriptText = await buildTranscriptText(channel, ticket); });
// CRITICAL #1 — commit the close and mark pendingDelete (discordThreadId stays
// set for restart recovery). Done BEFORE the fallible side-effects below so none
// of them can leave a "closed"-looking but still-open, undeleted ticket.
let transitioned = false;
let closedTicket = null;
await closeStep('transition', async () => {
({ transitioned, ticket: closedTicket } =
await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }));
});
// Customer-facing close notice (best-effort).
await closeStep('closeMessage', () => enqueueSend(channel, CONFIG.DISCORD_CLOSE_MESSAGE));
// Archive the transcript to the transcript channel (best-effort — a Missing
// Access here previously aborted the whole close).
let transcriptMsg = null;
if (transcriptText != null) {
const openedStr = formatDateForTranscript(ticket.createdAt);
const closedStr = formatDateForTranscript(closedAt);
await closeStep('transcriptArchive', async () => {
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE);
let transcriptMsg = null;
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` });
const transcriptChan = await interaction.client.channels.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID).catch(() => null);
if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent,
files: [file],
allowedMentions: { parse: [] }
content: transcriptContent, files: [file], allowedMentions: { parse: [] }
});
}
});
// Optionally DM the transcript to the ticket creator. Many users have
// server-member DMs disabled; gated to avoid 50007 noise. Discord-origin
// tickets only.
// DM the transcript to the creator (Discord-origin tickets only). Best-effort —
// many users have member DMs disabled (50007).
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
await closeStep('dmCreator', () =>
dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr));
}
}
await postCloseLogEntry(interaction, ticket, channelName);
await closeStep('closeLog', () => postCloseLogEntry(interaction, ticket, channelName));
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
await closeStep('closeEmail', () => sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id));
}
// Keep discordThreadId set and mark pendingDelete so a restart during the
// grace window before the channel delete is recovered by resumePendingDeletes().
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' });
if (transitioned) {
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transitioned && closedTicket) {
const closerType = isStaff(interaction.member) ? 'staff' : 'user';
recordAction(interaction.user.id, 'close', {
ticket: closedTicket,
@@ -467,27 +493,21 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
});
}
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transcriptMsg?.id) {
await Transcript.create({
await closeStep('transcriptRecord', () => Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
});
}));
}
// CRITICAL #2 — schedule the channel delete. Always runs, regardless of any
// side-effect failure above.
scheduleTicketChannelDelete(channel, ticket.gmailThreadId);
// Best-effort overflow-category cleanup after the channel is gone.
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
// Queue-routed, pendingDelete-guarded delete (shared with auto-close + slash
// close) so a mid-close restart can't orphan the channel.
scheduleTicketChannelDelete(interaction.channel, ticket.gmailThreadId);
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
@@ -495,9 +515,6 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
}
}, 6000));
} catch (e) {
console.error('Close ticket error:', e);
}
}
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {

View File

@@ -94,7 +94,8 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
// Both best-effort — a failure here must not skip the channel delete below.
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...').catch(() => {});
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr)
);