Close: guard channel delete with pendingDelete so a restart can't orphan it

The button and slash close paths deleted the channel via a bare setTimeout that
never set the pendingDelete flag, so a restart in the 5s grace window orphaned
the channel (closed in DB, still present in Discord) with no recovery — only the
auto-close path used the flag correctly.

Extract scheduleTicketChannelDelete() in services/tickets.js: a grace-delayed,
queue-routed (enqueueDelete) delete that clears pendingDelete on success. All
three close paths now use it. Button/slash set pendingDelete:true and keep
discordThreadId populated so resumePendingDeletes() recovers the delete on the
next boot. The button path previously nulled discordThreadId before the delete,
which made the channel unrecoverable.
This commit is contained in:
2026-06-05 03:08:28 +00:00
parent 61e8ea32e1
commit 6ae57af885
4 changed files with 116 additions and 20 deletions

View File

@@ -22,7 +22,7 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition } = require('../services/tickets');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition, scheduleTicketChannelDelete } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
@@ -453,7 +453,9 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
}
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, { welcomeMessageId: '' });
// 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) {
const closerType = isStaff(interaction.member) ? 'staff' : 'user';
recordAction(interaction.user.id, 'close', {
@@ -482,9 +484,12 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
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(() => interaction.channel.delete().catch(() => {}), 5000));
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});

View File

@@ -15,7 +15,7 @@ const { logTicketEvent, logError } = require('../../services/debugLog');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const { attemptCloseTransition } = require('../../services/tickets');
const { attemptCloseTransition, scheduleTicketChannelDelete } = require('../../services/tickets');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const { recordAction } = require('../../services/staffStats');
@@ -75,7 +75,9 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc
if (!freshTicket || freshTicket.status === 'closed') return;
try {
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T);
// pendingDelete (with discordThreadId left set) lets resumePendingDeletes()
// recover the channel delete if a restart interrupts the grace window.
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }, T);
if (transitioned) {
record(closerId ?? 'system', 'close', {
ticket: closedTicket,
@@ -97,11 +99,9 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc
console.error('Transcript error (force-close):', tErr)
);
setTimeout(() => {
channelRef.delete('Ticket force-closed').catch(e =>
console.error('Failed to delete channel:', e)
);
}, 5000);
// Queue-routed, pendingDelete-guarded delete (shared with auto-close + button
// close) so a mid-close restart can't orphan the channel.
scheduleTicketChannelDelete(channelRef, freshTicket.gmailThreadId);
} catch (err) {
console.error('Force close error:', err);
}