Files
broccolini-bot/broccolini_bot_context.md
2026-04-18 11:10:41 +00:00

39 KiB
Raw Blame History

broccolini_bot_context.md

Single-source structural map of /opt/broccolini-bot. Generated for review use; not authoritative over code — re-read files before acting on anything here.

Overview

Node.js (CommonJS) Discord ticketing bot for Indifferent Broccoli. Single process hosts:

  • A discord.js v14 client (ticket lifecycle, slash/button/modal handlers, context menus)
  • A Gmail bridge (~30s polling → Discord channels; staff replies → Gmail)
  • A Mongoose/MongoDB Atlas layer (broccoli_db) for tickets + settings
  • Two Express servers: healthcheck + bOSScord API (PORT, default 5000 → host 8892), and an internal settings API (INTERNAL_API_PORT)
  • Background jobs: auto-close, unclaimed reminders, auto-unclaim, pattern detection, surge detection, chat monitoring, orphan reconciliation

Container: docker compose up --build -d. Port 5000 inside → 8892 outside. No test runner, linter, or build step.

CLAUDE.md Hard Rule #3 clarification: the repo's services/channelQueue.js only exposes enqueueRename / enqueueMove. There is no enqueueSend. In practice the rule applies to renames and category moves, not to channel.send. Direct channel.send is the norm throughout handlers/ and is not treated as a violation in this document.

File tree (one-line purposes)

Root

  • broccolini-discord.js — entry point; wires client, events, background jobs, two HTTP servers
  • config.js — env → CONFIG object (119 vars, lines 111276); game list, tags, staff emoji map
  • db-connection.js — Mongo connect + require models.js; retry helper, shutdown hook
  • models.jsall 13 Mongoose schemas in one file
  • gmail-poll.js — Gmail inbox poll → new ticket creation / follow-up routing
  • get-refresh-token.js — one-shot OAuth helper (redirect http://localhost:3000/oauth2callback)
  • utils.js — email/game helpers, response template variables
  • package.json / Dockerfile / docker-compose.yml — deploy
  • .env.example / .env.test.example — env reference

handlers/

  • buttons.js — button + modal interactions: claim/unclaim, close confirm, escalate T2/T3, de-escalate, priority, tag, ticket-creation modal
  • commands.js — slash command router: /escalate, /deescalate, /add, /remove, /transfer, /move, /claim, /unclaim, /close, /priority, /tags, /email-routing, /setup, /help, /stats, /history, /search, /notification, /staffthread, /pinmessages, /panel, /backup, /export, /accountinfo, /gmailpoll
  • messages.jsmessageCreate: staff reply → Gmail relay; notify claimer on customer reply; DM alert toggle
  • setup.js — multi-step /setup wizard (modals + select menus)
  • accountinfo.js/accountinfo lookup + "send to channel" context menu
  • analytics.js — in-memory counters: interactions, errors, uptime
  • pendingCloses.js — shared Map<channelId, timeout> for force-close timer

services/

  • channelQueue.jsenqueueRename (p-queue-style, serialized per channel, respects Discord's 2-rename/10-min cap) and enqueueMove (direct setParent)
  • tickets.js — counters, naming, rate limits, auto-close, auto-unclaim, reconcileDeletedTicketChannels
  • gmail.jsgetGmailClient, sendGmailReply, sendTicketClosedEmail, sendTicketNotificationEmail
  • debugLog.js — fire-and-forget logging to dedicated Discord channels (logError, logWarn, logTicketEvent, logGmail, logAutomation, logSecurity, logIntegrity, logSystem)
  • staffNotifications.jsnotifyStaffOfReply (per-ticket cooldown), notifyAllStaffUnclaimed (30-min digest)
  • staffSettings.jsStaffSettings.notifyDm get/set
  • staffSignature.js — per-staff valediction/display name/tagline blocks
  • staffPresence.js — presence + message-activity tracking for "no staff available" surge alerts
  • staffThread.js — optional per-ticket private staff thread + auto-add members of STAFF_THREAD_ROLE_ID
  • staffChannel.jsdeprecated. Legacy per-staffer mirror channels. STAFF_CATEGORIES is empty in current config.js; createStaffChannel is not called from the claim flow.
  • pinMessage.js — pin helper with optional system-message suppression
  • patternStore.js — in-memory counter store with scheduled daily/weekly/monthly resets, escalating-cooldown helper
  • patternChecker.js — periodic pattern detection (user/game/tag/escalation/staff)
  • surgeChecker.js — volume, game, stale, needs-response, unclaimed, T3-unclaimed, no-staff surge alerts
  • chatAlertChecker.js — monitor configured chat channels for unresponded messages
  • configPersistence.js — save/load runtime config to Mongo
  • guildSettings.js — per-guild emailRouting (thread | category)

commands/

  • register.js — slash + context menu registration via discord.js REST v10

routes/

  • bosscord.js/api/tickets* for bOSScord (Bearer BOSSCORD_API_KEY, CORS, DB-ready gate)
  • internalApi.js/internal/* for the settings site (X-Internal-Secret)

api/

  • bosscordClient.js — singleton holder for the Discord client (set at startup, read by routes)

settings-site/

  • Separate Express app. server.js talks to the bot's internal API over INTERNAL_API_PORT using INTERNAL_API_SECRET. Password-protected dashboard (SETTINGS_ADMIN_PASSWORD).

scripts/

  • test-mongodb.js — connectivity smoke test (npm run test-mongodb)

docs/

  • README.md, CRITICAL_FILES_AND_HOW_IT_WORKS.md, setup/*, features/*, api/*, architecture/*

Discord event handler map

Event Wired in Dispatch
ready broccolini-discord.js (single-fire) DB connect → registerCommands() → mount bOSScord API → start 8 background intervals → start internal API server
interactionCreate broccolini-discord.js Routes by type: isButton / isModalSubmithandlers/buttons.js and handlers/setup.js; isChatInputCommandhandlers/commands.js; isContextMenuCommandhandlers/accountinfo.js; isAutocomplete → tags/responses
messageCreate broccolini-discord.js staffPresence.updateStaffLastSeenchatAlertChecker.handleChatMessagehandlers/messages.handleDiscordReply
unhandledRejection broccolini-discord.js logError('unhandledRejection', …).catch(() => {})
SIGTERM/SIGINT broccolini-discord.js handleShutdown() — log + exit

Background intervals (all started in ready)

Job Interval Source Config gate
Gmail poll GMAIL_POLL_INTERVAL_MS (~30s) gmail-poll.js:poll always on
Auto-close 60 min services/tickets.checkAutoClose AUTO_CLOSE_ENABLED
Unclaimed digest 30 min services/staffNotifications.notifyAllStaffUnclaimed UNCLAIMED_REMINDER_THRESHOLDS
Auto-unclaim 60 min services/tickets.checkAutoUnclaim AUTO_UNCLAIM_*
Pattern checks PATTERN_CHECK_INTERVAL_MINUTES services/patternChecker.runPatternChecks pattern channel envs
Surge checks 5 min (+30s initial delay) services/surgeChecker.runSurgeChecks ALL_STAFF_CHANNEL_ID
Chat monitoring 5 min services/chatAlertChecker.runChatAlertChecks CHAT_ALERT_CHANNEL_IDS
Orphan reconciliation 60 min services/tickets.reconcileDeletedTicketChannels always on

Button / modal custom IDs

open_ticket, open_ticket_thread, open_ticket_channel, email_routing_thread, email_routing_category, claim_ticket, close_ticket, confirm_close, cancel_close, escalate_ticket, escalate_to_tier2, escalate_to_tier3, deescalate_ticket, priority_*, open_panel, ticket_modal, ticket_modal_thread, ticket_modal_channel, setup_* (wizard), send_account_info_*.

Ticket lifecycle

Two sources, one Ticket document:

  • Email-sourced — real Gmail threadId in gmailThreadId. Staff replies relay to Gmail via handlers/messages.jssendGmailReply.
  • Discord-sourcedgmailThreadId prefixed discord- / discord-msg-. No Gmail relay; conversation stays in Discord.

State machine:

  (poll or /panel modal)
          │
          ▼
  ┌─────────────────┐
  │  created        │ — Ticket doc inserted; Discord channel (or thread) created under
  │  (status: open, │   TICKET_CATEGORY_ID / DISCORD_TICKET_CATEGORY_ID (+overflow if full);
  │   claimedBy: ∅) │   welcome embed + action row posted; role ping; optional pin; optional
  └────────┬────────┘   staff thread; optional staff notification alerts
           │
     [Claim button] ───▶ claimedBy set; channel renamed via channelQueue (STAFF_EMOJIS prefix)
           │                │
           │         [Unclaim / auto-unclaim / claim-timeout] ──▶ back to unclaimed
           │
     [/escalate or Escalate button → T2 / T3]
           │   Non-thread: enqueueMove → *_ESCALATED2/3_CHANNEL_ID category
           │   Thread:     skips category move (threads can't reparent)
           │   Action: "unclaim" clears claim + resets unclaimedReminderssent; "keep" preserves
           ▼
  ┌─────────────────┐
  │  escalated      │  escalationTier ∈ {2, 3}
  └────────┬────────┘
           │
     [/deescalate] ──▶ step down one tier
           │
     [Close button → confirm_close → FORCE_CLOSE_TIMER grace]
           │
           ▼
  ┌─────────────────┐
  │  closed         │  transcript posted to TRANSCRIPT_CHANNEL_ID; closure email sent
  │  (status: closed│  for email tickets; channel deleted (5s delay); Transcript doc written
  └─────────────────┘

Orphan path: reconcileDeletedTicketChannels (60 min) finds open tickets whose Discord channel no longer exists and marks them closed.

MongoDB collections (models.js)

All schemas live in a single file. Only indexes explicitly declared are listed; implicit _id and unique: true (which creates an index) are marked ✓.

Collection Key fields Indexes Notes
Host hostname, ip, region, status, memFree, cpuUsage, diskFree, lastSeen, lostInUse[], statsHistory[] none lastSeen: { default: Date.now() } — frozen at schema-definition time, bug (see P3)
User email, discordID, customerId, passwordHash, sessionToken, servers[], subusers[], activities[] none 700+ lines, shared website schema. email / discordID queried in handlers/accountinfo.js:47-54 without index
DashboardMetrics timestamp (TTL 1yr), activeUsers, workerId TTL ✓
ErrorLog timestamp (TTL 30d), statusCode, message, stack, url, method, userId, userEmail, authenticated, sessionValid TTL ✓
Ticket gmailThreadId ✓ unique, discordThreadId, senderEmail, subject, status (open/closed), priority, claimedBy (display), claimerId, ticketNumber, createdAt, lastActivity, escalated, escalationTier, welcomeMessageId, ticketTag, unclaimedReminderssent[] (typo preserved — see below) gmailThreadId unique ✓ discordThreadId, claimedBy, status, ticketNumber, senderEmail are all hot query fields with no index. unclaimedReminderssent typo is load-bearing — preserved across models.js:819, services/staffNotifications.js:85,111, handlers/commands.js:77
TicketCounter senderLocal ✓ unique, counter
Transcript gmailThreadId, transcriptMessageId, createdAt none gmailThreadId queried in gmail-poll.js:267 without index
Tag name ✓ unique, content, createdBy, useCount Saved response templates
CloseRequest ticketId ✓ unique, requestedBy, reason
GuildSettings guildId ✓ unique, emailRouting (thread/category)
StaffSettings userId ✓ unique, guildId, notifyDm
StaffNotification userId ✓ unique, guildId, channelId, cooldownHours Per-staffer reply-alert channel
StaffSignature userId ✓ unique, guildId, valediction, displayName, tagline

Express API route table

routes/bosscord.js — mounted at /api after ready, only if BOSSCORD_API_KEY is set

Method Path Auth Input Response
GET /api/tickets Bearer query: status, priority, claimedBy, limit (≤100) { tickets: [...] }
GET /api/me/tickets Bearer header X-Staff-Discord-Id or query claimedBy { tickets: [...] }
GET /api/tickets/:id Bearer path: ObjectId / ticketNumber / gmailThreadId raw ticket object (inconsistent)
GET /api/tickets/:id/messages Bearer query: limit (≤100) { messages: [...] }
POST /api/tickets/:id/messages Bearer { content: string, displayName?: string } { ok: true } (201)

Middleware (applied once via router.use): corsMiddleware (BOSSCORD_CORS_ORIGIN, defaults to *) → authMiddleware (Bearer) → requireDb.

routes/internalApi.js/internal/* on a separate port (INTERNAL_API_PORT)

Method Path Auth Input Response
GET /internal/config X-Internal-Secret { key: value, ... } (redacted)
POST /internal/config X-Internal-Secret { [key]: value } { applied: [...], errors: [...] }
GET /internal/discord/guild X-Internal-Secret { channels, roles, members, categories }
POST /internal/restart X-Internal-Secret { mode, scheduledFor? }, modes: immediate / scheduled / cancel_scheduled / pending { ok: true, mode, ... }
GET /internal/restart/status X-Internal-Secret { scheduledRestart: boolean }

Environment variables

Vars read in config.js but missing from .env.example

  • DISCORD_BOT_TOKEN (alias for DISCORD_TOKEN)
  • HEALTHCHECK_HOST
  • NOTIFICATION_THRESHOLDS_JSON
  • ROLE_TO_PING_ID (alias for ROLE_ID_TO_PING)
  • EMAIL_TICKET_OVERFLOW_CATEGORY_IDS
  • DISCORD_TICKET_OVERFLOW_CATEGORY_IDS
  • NODE_ENV, ENV_FILE (implicit)

Vars in .env.example but not read via config.js

  • GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET — read directly by services/gmail.js and broccolini-discord.js, not via CONFIG
  • MONGODB_URI — read directly by broccolini-discord.js:99 and scripts/test-mongodb.js, not via CONFIG
  • NGROK_URL — unused
  • DISCORD_ESCALATED_CATEGORY_ID, EMAIL_ESCALATED_CATEGORY_ID — legacy names, superseded by *_ESCALATED2/3_CHANNEL_ID

Key env categories (see .env.example for the full list)

Category Vars
Discord core DISCORD_TOKEN, DISCORD_APPLICATION_ID, DISCORD_GUILD_ID, TICKET_CATEGORY_ID, DISCORD_TICKET_CATEGORY_ID, *_OVERFLOW_CATEGORY_IDS, ROLE_ID_TO_PING, TRANSCRIPT_CHANNEL_ID, LOGGING_CHANNEL_ID, DEBUGGING_CHANNEL_ID
Escalation EMAIL_ESCALATED2/3_CHANNEL_ID, DISCORD_ESCALATED2/3_CHANNEL_ID
Staff notifications STAFF_NOTIFICATION_CATEGORY_ID, STAFF_EMOJIS, CLAIMER_EMOJI_FALLBACK, ADMIN_ID, UNCLAIMED_REMINDER_THRESHOLDS
Gmail GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, REFRESH_TOKEN, MY_EMAIL, GMAIL_POLL_INTERVAL_MS
MongoDB MONGODB_URI
HTTP DISCORD_ONLY_PORT/PORT, HEALTHCHECK_HOST, BOSSCORD_API_KEY, BOSSCORD_CORS_ORIGIN, INTERNAL_API_PORT, INTERNAL_API_SECRET
Automation AUTO_CLOSE_*, REMINDER_*, AUTO_UNCLAIM_*, CLAIM_TIMEOUT_*, FORCE_CLOSE_TIMER
Rate limits GLOBAL_TICKET_LIMIT, TICKET_LIMIT_PER_CATEGORY, RATE_LIMIT_*
Patterns PATTERN_*_THRESHOLD, *_PATTERNS_CHANNEL_ID
Surge SURGE_*, ALL_STAFF_CHANNEL_ID, SURGE_ROLE_ID, STAFF_IDS
Chat alerts CHAT_ALERT_CHANNEL_IDS, ALL_STAFF_CHAT_ALERT_CHANNEL_ID, CHAT_ALERT_*
Branding SUPPORT_NAME, LOGO_URL, SIGNATURE, TICKET_WELCOME_MESSAGE, TICKET_CLAIMED_MESSAGE, ESCALATION_MESSAGE, embed colors

Key patterns

Channel queue

services/channelQueue.js serializes renames (enqueueRename) and moves (enqueueMove). Discord caps renames at 2 per 10 min per channel; the queue emits a relative-time message in the channel when blocked. Rule: any code that changes a channel's name or parent must use these helpers. handlers/commands.js:540 (/move) currently bypasses this with a direct setParent — see P1 prompt.

Logging

services/debugLog.js is fire-and-forget: every log helper returns a promise and callers attach .catch(() => {}). Rule: never await logging on a hot path. Channels are selected by the *_LOG_CHANNEL_ID env vars (GMAIL_LOG_CHANNEL_ID, AUTOMATION_LOG_CHANNEL_ID, RENAME_LOG_CHANNEL_ID, SECURITY_LOG_CHANNEL_ID, SYSTEM_LOG_CHANNEL_ID, DEBUGGING_CHANNEL_ID).

Staff detection

Staff = members with ROLE_ID_TO_PING or any role in ADDITIONAL_STAFF_ROLES. ADMIN_ID is a single-user gate for /staffnotification. STAFF_IDS drives surge "no staff available" calculations with STAFF_DND_COUNTS_AS_AVAILABLE as a tiebreaker.

Claim identity

Ticket.claimedBy is a display label (string), Ticket.claimerId is the Discord user ID. Channel-name emoji comes from STAFF_EMOJIS (userId:emoji,...) with CLAIMER_EMOJI_FALLBACK.

Pattern/counter store

services/patternStore.js holds in-memory counters keyed by namespace + window (today/week/month) with auto-reset timers from scheduleResets(). Not persisted — resets on process restart.

Deprecated

services/staffChannel.js and the STAFF_CATEGORIES map are legacy. STAFF_CATEGORIES is empty in current config.js, createStaffChannel is not called from the claim flow, and Ticket.staffChannelId is effectively unused. Reply alerts instead flow through StaffNotification channels (/notification add).

Known issues (root causes documented; NO fix prompts)

  1. Gmail invalid_grantgmail-poll.js:351-372. Polling catches auth errors (invalid_grant / unauthorized / Invalid Credentials / HTTP 401), logs via logError('Gmail OAuth', …), DMs ADMIN_ID once (authErrorNotified flag), and silently no-ops subsequent polls. By design — requires manual REFRESH_TOKEN refresh via node get-refresh-token.js. The surrounding bot and bOSScord API continue to function.
  2. STAFF_EMOJIS encodingconfig.js parses userId:emoji pairs from env; some custom emojis render as mojibake in channel names. Root cause not yet identified; likely interaction between .env file encoding (UTF-8 vs BOM), dotenv-expand handling, and Discord's custom emoji syntax (<:name:id>) vs Unicode codepoints. Needs a targeted trace through config.js parsing.
  3. Escalation buttonhandlers/buttons.js handlers for escalate_to_tier2 / escalate_to_tier3. Reports of the handler "not firing reliably." Root cause not yet identified. Candidate areas: interaction deferral timing (3 s rule), missing return between button branches in the dispatcher, or enqueueMove back-pressure when the target category is full and the handler errors before replying.

Improvement prompts

Each prompt follows CLAUDE.md's format. Prompts intended for OpenCode to execute. None of the known issues above appear here.


P0 — Fix undefined vars in ticket-closure email body

Priority: P0 (broken) Files: /opt/broccolini-bot/services/gmail.js (lines 108129), /opt/broccolini-bot/config.js (to confirm TICKET_CLOSE_MESSAGE / signature vars) Problem: sendTicketClosedEmail references safeCloseMessage and safeCloseSignature on lines 115116 of the HTML body, but neither variable is defined anywhere in the function. Every closure email sent for an email-sourced ticket currently contains literal undefined text in both the message paragraph and the signature line, which customers see. This has been broken for an unknown period because nothing tests closure email rendering. Fix:

  1. Read the full sendTicketClosedEmail function (surrounding ~50 lines) to confirm the escape pattern used by safeReply / safeLogoUrl / safeSignature.
  2. Immediately after line 110 (where safeSignature is computed), add:
    const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
    const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
    
    — adjust the CONFIG key to whichever close-message var actually exists (CONFIG.TICKET_CLOSE_MESSAGE is the most likely name; fall back to the existing DISCORD_CLOSE_MESSAGE if not present). Do not invent a new env var.
  3. Do not modify the HTML template structure. Verify:
  • Trigger a close on a throwaway email-sourced ticket in the test environment.
  • Inspect the resulting Gmail message (the customer-bound send) and confirm the <p> that previously said undefined now contains the configured close message, and the signature block below it renders correctly.
  • If no test env exists for Gmail, at minimum console-log htmlBody once and grep for undefined.

P1 — Route /move through enqueueMove instead of direct setParent

Priority: P1 (channel queue bypass — CLAUDE.md Hard Rule #3) Files: /opt/broccolini-bot/handlers/commands.js (around line 540), /opt/broccolini-bot/services/channelQueue.js Problem: The /move slash handler calls await interaction.channel.setParent(category.id, { lockPermissions: true }) directly. Every other category move in the codebase flows through services/channelQueue.js's enqueueMove, which serializes moves and logs via the rename channel. Direct setParent skips that serialization and, more importantly, skips the rate-limit / error handling the queue provides. Fix:

  1. At the top of handlers/commands.js, confirm enqueueMove is imported from ../services/channelQueue. Add the import if missing.
  2. Replace line 540 with await enqueueMove(interaction.channel, category.id);
  3. Confirm enqueueMove preserves lockPermissions: true behavior (read services/channelQueue.js:~95). If it does not, add a lockPermissions option to enqueueMove (defaulting to true to match existing callers), rather than reverting /move to a direct call.
  4. Leave the surrounding interaction.reply / log-channel send untouched. Verify:
  • Run /move in a test ticket channel targeting another category. Confirm it moves.
  • Run two /move commands back-to-back from different ticket channels. Confirm both complete without rate-limit errors and both appear in RENAME_LOG_CHANNEL_ID (if the queue logs moves there).

P1 — Validate and bound content on POST /api/tickets/:id/messages

Priority: P1 (input validation / security boundary) Files: /opt/broccolini-bot/routes/bosscord.js (lines 159223) Problem: The endpoint accepts an arbitrary content string with only a type check (typeof content !== 'string'). There is no length cap, no whitespace check, and req.body.displayName is piped into sendGmailReply as discordUser without validation. A client bug or malicious caller can post a 10 MB string to Discord (which will error partway through but only after a channel.send attempt) or inject arbitrary display names into outbound email. Discord's own cap is 2000 chars per message. Fix:

  1. After the existing content type check (line 169), add:
    const trimmed = content.trim();
    if (!trimmed) return res.status(400).json({ error: 'content is empty' });
    if (trimmed.length > 2000) return res.status(400).json({ error: 'content exceeds 2000 characters' });
    
    Use trimmed for the rest of the handler.
  2. Validate displayName: coerce to string, trim, cap at 80 chars, and replace anything outside [\w \-.'] with empty string. If the result is empty, fall back to 'bOSScord'. Do not echo unvalidated user input into the outbound email header.
  3. Do not change the response shape. Verify:
  • curl the endpoint with a 3000-char body and confirm a 400 response.
  • curl with {"content":"hi","displayName":"<script>alert(1)</script>"} and confirm the email (if sent) shows a sanitized display name.
  • curl with a normal {"content":"test"} and confirm the existing happy path still returns {ok: true} and delivers to Discord.

P1 — Add hot-path indexes to Ticket

Priority: P1 (data layer / performance and correctness under load) Files: /opt/broccolini-bot/models.js (the ticketSchema block ~lines 795821) Problem: Only gmailThreadId is indexed on Ticket. The live query hotspots are discordThreadId (every messageCreate does a findOne on it — see handlers/messages.js), claimedBy + status (the bOSScord /api/me/tickets filter), status alone (unclaimed-reminder job scans it every 30 min), and senderEmail + ticketNumber (search commands). As the collection grows, these turn into full-collection scans on every Discord message. Fix: Inside the ticketSchema definition (not inline on the field — use ticketSchema.index(...) calls at the end of the schema block so it's obvious what the indexes are):

ticketSchema.index({ discordThreadId: 1 }, { unique: true, sparse: true });
ticketSchema.index({ status: 1, claimedBy: 1 });
ticketSchema.index({ status: 1, lastActivity: -1 });
ticketSchema.index({ senderEmail: 1, createdAt: -1 });
ticketSchema.index({ ticketNumber: 1 });

discordThreadId should be unique, sparse because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify). Verify:

  • Before deploy, run db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}]) against broccoli_db to confirm no duplicate discordThreadId values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
  • After redeploy, run db.tickets.getIndexes() in Atlas and confirm all five new indexes exist.
  • Spot-check with db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats") — should show IXSCAN, not COLLSCAN.

P1 — Add index on Transcript.gmailThreadId

Priority: P1 Files: /opt/broccolini-bot/models.js (transcriptSchema, ~lines 828832) Problem: gmail-poll.js:267 queries Transcript.findOne({ gmailThreadId }) on every inbound email that might be a reopen, with no index. Fix: Append transcriptSchema.index({ gmailThreadId: 1 }); to the schema definition block. Verify: db.transcripts.getIndexes() shows the new index; db.transcripts.find({gmailThreadId: "<id>"}).explain("executionStats") is IXSCAN.


P1 — Validate /internal/config POST body against an allowlist

Priority: P1 (admin API; wide blast radius) Files: /opt/broccolini-bot/routes/internalApi.js (~lines 2939), /opt/broccolini-bot/config.js (to derive the allowlist) Problem: POST /internal/config forwards the request body to applyConfigUpdates() with only a type check (typeof body === 'object'). Any caller with INTERNAL_API_SECRET can set arbitrary keys. An attacker who exfiltrates the secret can poison CONFIG with unknown keys that silently shadow code reads. Fix:

  1. Build a module-level const ALLOWED_CONFIG_KEYS = new Set([...]) containing every key defined in config.js. Generate this by reading config.js; do not hand-type it. If config.js exports the list (or can cheaply derive it from Object.keys(CONFIG)), prefer that.
  2. At the top of the POST handler, iterate Object.keys(req.body) and collect any not in ALLOWED_CONFIG_KEYS. If any exist, return 400 with { error: 'Unknown config keys', rejected: [...] }.
  3. Do not change successful-path behavior. Verify:
  • curl -H "x-internal-secret: $S" -H 'content-type: application/json' -d '{"TICKET_CATEGORY_ID":"123"}' .../internal/config — still works.
  • curl ... -d '{"NOT_A_REAL_KEY":"x"}' ... — returns 400 with the rejected key listed.

P1 — Validate scheduledFor on /internal/restart

Priority: P1 Files: /opt/broccolini-bot/routes/internalApi.js (~lines 87123) Problem: POST /internal/restart passes scheduledFor to new Date() without format checks. Invalid strings become Invalid Date, past timestamps schedule in the past (immediate restart), and there is no upper bound on how far in the future a restart can be scheduled. Fix: When mode === 'scheduled':

  1. Require scheduledFor to be a string matching ISO-8601 (Date.parse returning a finite number is sufficient).
  2. Reject if Number.isNaN(parsed) — return 400 { error: 'scheduledFor must be a valid ISO-8601 timestamp' }.
  3. Reject if the timestamp is in the past or more than 24 hours in the future — return 400. Verify: POST with {mode:"scheduled", scheduledFor:"not-a-date"} returns 400. POST with a timestamp 2 min in the future succeeds. POST with a timestamp 1 week in the future returns 400.

P2 — Fix unsafe async IIFE in force-close cleanup

Priority: P2 (silent error swallowing; reliability) Files: /opt/broccolini-bot/handlers/buttons.js (lines ~595605) Problem: After channel deletion on force-close, a setTimeout wraps an async IIFE that calls cleanupEmptyOverflowCategory(...) without a .catch. A thrown error from that cleanup is an unhandled rejection that the global handler logs but no one sees per-ticket, and the force-close flow appears successful even when cleanup failed. Fix: Replace the IIFE with:

setTimeout(() => {
  cleanupEmptyOverflowCategory(/* same args */)
    .catch((err) => logError('cleanupEmptyOverflowCategory', err).catch(() => {}));
}, 6000);

(Do not await the logError call — logging is fire-and-forget per CLAUDE.md Hard Rule #4.) Verify: Force-close a ticket in an overflow category with the cleanup function temporarily throwing. Confirm the error surfaces in the debug channel instead of only the global unhandledRejection log.


P2 — Normalize /api/tickets/:id response shape

Priority: P2 (API contract — coordinate with bOSScord) Files: /opt/broccolini-bot/routes/bosscord.js (lines 106119), plus bOSScord client code (out of tree) Problem: /api/tickets returns { tickets: [...] }, /api/me/tickets returns { tickets: [...] }, /api/tickets/:id/messages returns { messages: [...] }, but /api/tickets/:id returns the raw ticket object. bOSScord has to handle two shapes. CLAUDE.md warns that response-shape changes will break bOSScord. Fix: This is a coordinated change. Do not modify routes/bosscord.js in isolation. Instead:

  1. Open this as a doc-only prompt first: add a note to docs/api/ (create the file if needed) listing the current shapes and marking the single-ticket endpoint as "wrapped in { ticket } in vNext — bOSScord must be updated in lockstep."
  2. Separately, coordinate with the bOSScord repo. Once bOSScord is updated, a follow-up prompt will change line 114 from res.json(out) to res.json({ ticket: out }). Verify (for the doc-only step): docs/api/bosscord.md exists and accurately describes the five endpoints' current and target shapes.

P2 — Audit long-running slash commands for deferReply

Priority: P2 (Discord.js best practices) Files: /opt/broccolini-bot/handlers/commands.js (read-only audit), /opt/broccolini-bot/handlers/buttons.js Problem: Discord requires an interaction response (reply or defer) within 3 seconds. Any command that fetches from Mongo + makes multiple Discord API calls + possibly calls Gmail is at risk. /escalate (queue move + channel rename + log send + email?), /move, /transfer, /backup, /export are candidates. Fix:

  1. Read-only first: grep handlers/commands.js for each of /escalate, /deescalate, /move, /transfer, /backup, /export, /search, /history, /gmailpoll check, and identify the first user-visible response on each path.
  2. For any command where the first interaction.reply / interaction.editReply happens after two or more awaited calls, add await interaction.deferReply({ ephemeral: <matching existing ephemerality> }); as the very first action, and convert subsequent interaction.reply calls on that path to interaction.editReply or interaction.followUp.
  3. Do not touch commands that already defer. Verify:
  • Run /backup and /export on a server with 100+ tickets. Confirm no InteractionAlreadyReplied or Unknown interaction errors in console.
  • Run /escalate and confirm the loading state appears immediately, then resolves.

P2 — Add try/catch around handleDiscordReply

Priority: P2 Files: /opt/broccolini-bot/broccolini-discord.js (messageCreate listener, ~lines 159170) Problem: handleDiscordReply(msg) is called inside the messageCreate listener without explicit error handling. Any rejection (Gmail send failure, Mongo write error) becomes an unhandledRejection that the global handler logs but without message/channel context. Fix: Wrap the call:

handleDiscordReply(msg).catch((err) =>
  logError('handleDiscordReply', err, null).catch(() => {})
);

Do not await — the event listener should not block on relay. Verify: Throw a test error inside handleDiscordReply once; confirm the debug channel shows the error with the handleDiscordReply context label, not unhandledRejection.


P2 — Sweep for token leakage in error logs

Priority: P2 (defense in depth) Files: /opt/broccolini-bot/services/gmail.js, /opt/broccolini-bot/gmail-poll.js, /opt/broccolini-bot/routes/bosscord.js, /opt/broccolini-bot/routes/internalApi.js, /opt/broccolini-bot/services/debugLog.js Problem: logError(ctx, err) forwards err.stack and err.message to a Discord channel. OAuth 401 responses from googleapis sometimes include the bearer token or refresh token in the error object's config.headers.Authorization. The bOSScord auth middleware sees raw Authorization headers. There is no active sanitization on the way to the log channel. Fix:

  1. Audit: read services/debugLog.js:logError and confirm exactly what fields of err get embedded in the Discord embed.
  2. If err.config or err.response.config.headers are interpolated, add a sanitize step that strips Authorization, refresh_token, access_token, and any key matching /token|secret|password/i from the logged object before calling .send.
  3. If only err.message and err.stack are logged, grep those for process.env.REFRESH_TOKEN, process.env.BOSSCORD_API_KEY, process.env.INTERNAL_API_SECRET literally — if the values appear, redact them before posting. Verify: Force a Gmail 401 (e.g., in test env with a deliberately invalid token) and confirm the debug-channel log does not contain the refresh token string.

P3 — Fix Host.lastSeen default (frozen at schema-definition time)

Priority: P3 Files: /opt/broccolini-bot/models.js (Host schema, around the lastSeen field) Problem: lastSeen: { type: Number, default: Date.now() }Date.now() is called once when the schema is defined at process start. Every new Host document gets the same timestamp (process start time) as the default, not the creation time. Fix: Change to default: Date.now (pass the function reference) or default: () => Date.now(). No behavior change for existing docs. Verify: new Host({hostname:'x'}).save() twice across a few seconds; confirm the two documents have different lastSeen values.


P3 — Remove unused p-queue dependency

Priority: P3 Files: /opt/broccolini-bot/package.json, /opt/broccolini-bot/package-lock.json Problem: p-queue@^6.6.2 is declared in dependencies but never required anywhere in the codebase (the channel queue implements its own serialization). Dead dependency bloats the install and the supply-chain surface. Fix: npm uninstall p-queue. Commit both package.json and package-lock.json. Verify: grep -r "p-queue" . returns no results outside node_modules. npm ls does not list it. Bot starts cleanly.


P3 — Mark services/staffChannel.js as deprecated (or delete)

Priority: P3 Files: /opt/broccolini-bot/services/staffChannel.js, /opt/broccolini-bot/models.js (Ticket.staffChannelId) Problem: STAFF_CATEGORIES is empty in config.js, createStaffChannel is not called from the claim flow, Ticket.staffChannelId is never read. The file still exports four functions that could mislead a reader into thinking the mirror-channel pattern is active. Fix:

  1. First verify: grep the repo for staffChannel, createStaffChannel, staffChannelId. Confirm the only matches are definitions + legacy doc references.
  2. If truly unreferenced: add a file-top comment // DEPRECATED: legacy per-staffer mirror channels. Not used in the current claim flow. Kept for history — do not reintroduce. Leave the code in place to avoid git-history loss. Do not delete Ticket.staffChannelId (old tickets may have the field).
  3. If any active caller exists (unexpected), stop and report the finding — do not modify. Verify: After the comment is added, bot starts cleanly. grep -r staffChannelId handlers services routes shows no runtime read-sites.

P3 — Reconcile .env.example with config.js

Priority: P3 (documentation hygiene) Files: /opt/broccolini-bot/.env.example, /opt/broccolini-bot/config.js Problem: 8 vars are read in code but not documented; 6 are documented but never read. New operators hit both problems on day one. Fix:

  1. Add to .env.example (as commented entries with one-line descriptions): HEALTHCHECK_HOST, NOTIFICATION_THRESHOLDS_JSON, ROLE_TO_PING_ID (as alias note on the existing ROLE_ID_TO_PING), EMAIL_TICKET_OVERFLOW_CATEGORY_IDS, DISCORD_TICKET_OVERFLOW_CATEGORY_IDS. DISCORD_BOT_TOKEN should be added as an explicit alias comment under DISCORD_TOKEN.
  2. Remove from .env.example: NGROK_URL (unused), DISCORD_ESCALATED_CATEGORY_ID, EMAIL_ESCALATED_CATEGORY_ID (legacy; superseded by *_ESCALATED2/3_CHANNEL_ID).
  3. Do not move GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, or MONGODB_URI — they are read directly (not via CONFIG) and should stay in .env.example. Verify: Diff .env.example against Object.keys(require('./config').CONFIG) plus the three directly-read vars. No gaps either way.

P3 — CVE sweep on top-level dependencies

Priority: P3 (read-only audit) Files: /opt/broccolini-bot/package.json, /opt/broccolini-bot/package-lock.json Problem: mongoose@^6.12.0 is a generation behind (v7/v8 shipped), express@^5.2.1 is early in the v5 line, googleapis@^171.x ships frequently with transitive fixes. No active npm audit output is documented. Fix (read-only): Run npm audit --omit=dev --json at the repo root and paste the result into a new docs/audit/npm-audit-YYYY-MM-DD.md. Do not auto-upgrade. Flag any high / critical findings separately so they can be triaged individually. Verify: The audit file exists and lists each finding with CVE ID, affected package, and fix version. No package.json changes in this prompt.


End of improvement prompts

Total: 1 P0, 7 P1, 5 P2, 5 P3 — 18 prompts. Three known issues deliberately excluded (Gmail invalid_grant, STAFF_EMOJIS encoding, escalation button).