audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup

QUAL-006  store ticket.creatorId on creation; legacy split-pop returned the
          message ID for discord-msg-* tickets, breaking transcript DM, close
          log, and channel rename for context-menu-created tickets. Adds the
          field to the Ticket schema and writes a one-shot backfill script
          (scripts/backfill-creatorId.js, dry-run by default).

QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
          (chain on renameChains alongside enqueueMove). Migrate handleAdd /
          handleRemove / handleMove / handleTopic so permissionOverwrites,
          setParent, and setTopic no longer race pending renames or sends.
          handleMove now uses the existing enqueueMove. Initial overwrites in
          handleTicketModal stay inline; channel doesn't exist yet so no race.

DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
            broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
            handlers/commands.js. runDeferred opts now take { flags } directly.

SEC-003   /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
          choices and clamp Math.max(30000, ms) in handleGmailPoll for
          defense in depth.

QUAL-001  upgrade silent .catch(() => {}) on the lastActivity updateOne in
          handlers/messages.js to log via logError, so transient Mongo errors
          surface in the debug channel instead of disappearing.

QUAL-002  drop await from logError/logWarn calls in services/staffThread.js
          and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.

QUAL-003  wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
          runFinalClose channel-delete + overflow-cleanup, checkAutoClose
          delete-after-email) in trackTimeout via lazy require so they clear
          on shutdown.
This commit is contained in:
2026-05-08 20:19:14 +00:00
parent e3b3b8d48c
commit cdf85f6364
12 changed files with 287 additions and 97 deletions

View File

@@ -15,6 +15,7 @@ const {
ButtonStyle,
AttachmentBuilder,
EmbedBuilder,
MessageFlags,
PermissionFlagsBits,
ModalBuilder,
TextInputBuilder,
@@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) {
async function handleClaimButton(interaction, ticket) {
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
if (!freshTicket) {
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral });
}
const isClaimed = !!freshTicket.claimedBy;
@@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) {
const [row0] = interaction.message.components;
if (!row0) {
return interaction.reply({ content: 'No components to update.', ephemeral: true });
return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral });
}
const row = ActionRowBuilder.from(row0);
const [btnClose, btnClaim] = row.components;
if (!btnClose || !btnClaim) {
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral });
}
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true
flags: MessageFlags.Ephemeral
});
}
@@ -277,7 +278,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
}
const cancelRow = new ActionRowBuilder().addComponents(
@@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const channelName = interaction.channel.name;
const userTag = interaction.user.tag;
const timerId = setTimeout(async () => {
// Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle.
const { trackTimeout } = require('../broccolini-discord');
const timerId = trackTimeout(setTimeout(async () => {
const pending = pendingCloses.get(channelId);
pendingCloses.delete(channelId);
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
@@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const effectiveSendEmail = pending?.sendEmail ?? true;
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, timerSeconds * 1000);
}, timerSeconds * 1000));
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
}
@@ -324,7 +327,7 @@ async function handleCancelCloseRequest(interaction) {
async function handleEscalatePrompt(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
}
const buttons = [];
@@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) {
return interaction.reply({
content: 'Escalate to which tier?',
components: [new ActionRowBuilder().addComponents(buttons)],
ephemeral: true
flags: MessageFlags.Ephemeral
});
}
@@ -351,7 +354,7 @@ async function handleEscalateButton(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= tier) {
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true });
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
@@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) {
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
ephemeral: true
flags: MessageFlags.Ephemeral
});
}
@@ -372,12 +375,12 @@ async function handleEscalateButton(interaction, ticket) {
async function handleDeescalateButton(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
}
await runDeferred(interaction, 'deescalate',
() => runDeescalation(interaction, ticket),
{ ephemeral: true }
{ flags: MessageFlags.Ephemeral }
);
}
@@ -455,12 +458,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
setTimeout(() => {
// 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(() => {});
}
}, 6000);
}, 6000));
} catch (e) {
console.error('Close ticket error:', e);
}
@@ -494,7 +499,12 @@ function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr)
}
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
const creatorId = ticket.gmailThreadId.split('-').pop();
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
// pre-creatorId modal tickets only — split-pop returns the wrong value for
// discord-msg-* tickets (it yields the message ID, not the user ID).
const creatorId = ticket.creatorId
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
if (!creatorId) return;
try {
const creator = await client.users.fetch(creatorId);
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
@@ -524,13 +534,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
let logMsg;
if (ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop();
try {
const creator = await interaction.client.users.fetch(creatorId);
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
} catch {
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
const creatorId = ticket.creatorId
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
let creator = null;
if (creatorId) {
creator = await interaction.client.users.fetch(creatorId).catch(() => null);
}
logMsg = creator
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
: `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
} else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
}
@@ -542,7 +554,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
// ============================================================
async function handleTicketModal(interaction) {
await interaction.deferReply({ ephemeral: true });
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
const game = interaction.fields.getTextInputValue('ticket_game').trim();
@@ -578,7 +590,10 @@ async function handleTicketModal(interaction) {
let channel;
try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
// Initial permissionOverwrites on guild.channels.create are safe-by-construction:
// the channel doesn't exist yet, so there's no in-flight rename/send/move to race
// against. Any *subsequent* mutation on this channel (add/remove user, move,
// topic, rename) must go through services/channelQueue.js.
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
@@ -613,6 +628,7 @@ async function handleTicketModal(interaction) {
ticketNumber,
priority,
lastActivity: now,
creatorId: interaction.user.id,
parentCategoryId: parentCategoryIdForTicket
});