39 KiB
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 serversconfig.js— env →CONFIGobject (119 vars, lines 111–276); game list, tags, staff emoji mapdb-connection.js— Mongo connect + requiremodels.js; retry helper, shutdown hookmodels.js— all 13 Mongoose schemas in one filegmail-poll.js— Gmail inbox poll → new ticket creation / follow-up routingget-refresh-token.js— one-shot OAuth helper (redirecthttp://localhost:3000/oauth2callback)utils.js— email/game helpers, response template variablespackage.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 modalcommands.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,/gmailpollmessages.js—messageCreate: staff reply → Gmail relay; notify claimer on customer reply; DM alert togglesetup.js— multi-step/setupwizard (modals + select menus)accountinfo.js—/accountinfolookup + "send to channel" context menuanalytics.js— in-memory counters: interactions, errors, uptimependingCloses.js— sharedMap<channelId, timeout>for force-close timer
services/
channelQueue.js—enqueueRename(p-queue-style, serialized per channel, respects Discord's 2-rename/10-min cap) andenqueueMove(directsetParent)tickets.js— counters, naming, rate limits, auto-close, auto-unclaim,reconcileDeletedTicketChannelsgmail.js—getGmailClient,sendGmailReply,sendTicketClosedEmail,sendTicketNotificationEmaildebugLog.js— fire-and-forget logging to dedicated Discord channels (logError,logWarn,logTicketEvent,logGmail,logAutomation,logSecurity,logIntegrity,logSystem)staffNotifications.js—notifyStaffOfReply(per-ticket cooldown),notifyAllStaffUnclaimed(30-min digest)staffSettings.js—StaffSettings.notifyDmget/setstaffSignature.js— per-staff valediction/display name/tagline blocksstaffPresence.js— presence + message-activity tracking for "no staff available" surge alertsstaffThread.js— optional per-ticket private staff thread + auto-add members ofSTAFF_THREAD_ROLE_IDstaffChannel.js— deprecated. Legacy per-staffer mirror channels.STAFF_CATEGORIESis empty in currentconfig.js;createStaffChannelis not called from the claim flow.pinMessage.js— pin helper with optional system-message suppressionpatternStore.js— in-memory counter store with scheduled daily/weekly/monthly resets, escalating-cooldown helperpatternChecker.js— periodic pattern detection (user/game/tag/escalation/staff)surgeChecker.js— volume, game, stale, needs-response, unclaimed, T3-unclaimed, no-staff surge alertschatAlertChecker.js— monitor configured chat channels for unresponded messagesconfigPersistence.js— save/load runtime config to MongoguildSettings.js— per-guildemailRouting(thread|category)
commands/
register.js— slash + context menu registration via discord.js REST v10
routes/
bosscord.js—/api/tickets*for bOSScord (BearerBOSSCORD_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.jstalks to the bot's internal API overINTERNAL_API_PORTusingINTERNAL_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 / isModalSubmit → handlers/buttons.js and handlers/setup.js; isChatInputCommand → handlers/commands.js; isContextMenuCommand → handlers/accountinfo.js; isAutocomplete → tags/responses |
messageCreate |
broccolini-discord.js |
staffPresence.updateStaffLastSeen → chatAlertChecker.handleChatMessage → handlers/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
threadIdingmailThreadId. Staff replies relay to Gmail viahandlers/messages.js→sendGmailReply. - Discord-sourced —
gmailThreadIdprefixeddiscord-/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 forDISCORD_TOKEN)HEALTHCHECK_HOSTNOTIFICATION_THRESHOLDS_JSONROLE_TO_PING_ID(alias forROLE_ID_TO_PING)EMAIL_TICKET_OVERFLOW_CATEGORY_IDSDISCORD_TICKET_OVERFLOW_CATEGORY_IDSNODE_ENV,ENV_FILE(implicit)
Vars in .env.example but not read via config.js
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET— read directly byservices/gmail.jsandbroccolini-discord.js, not viaCONFIGMONGODB_URI— read directly bybroccolini-discord.js:99andscripts/test-mongodb.js, not viaCONFIGNGROK_URL— unusedDISCORD_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)
- Gmail
invalid_grant—gmail-poll.js:351-372. Polling catches auth errors (invalid_grant/unauthorized/Invalid Credentials/ HTTP 401), logs vialogError('Gmail OAuth', …), DMsADMIN_IDonce (authErrorNotifiedflag), and silently no-ops subsequent polls. By design — requires manualREFRESH_TOKENrefresh vianode get-refresh-token.js. The surrounding bot and bOSScord API continue to function. STAFF_EMOJISencoding —config.jsparsesuserId:emojipairs from env; some custom emojis render as mojibake in channel names. Root cause not yet identified; likely interaction between.envfile encoding (UTF-8 vs BOM),dotenv-expandhandling, and Discord's custom emoji syntax (<:name:id>) vs Unicode codepoints. Needs a targeted trace throughconfig.jsparsing.- Escalation button —
handlers/buttons.jshandlers forescalate_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), missingreturnbetween button branches in the dispatcher, orenqueueMoveback-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 108–129), /opt/broccolini-bot/config.js (to confirm TICKET_CLOSE_MESSAGE / signature vars)
Problem: sendTicketClosedEmail references safeCloseMessage and safeCloseSignature on lines 115–116 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:
- Read the full
sendTicketClosedEmailfunction (surrounding ~50 lines) to confirm the escape pattern used bysafeReply/safeLogoUrl/safeSignature. - Immediately after line 110 (where
safeSignatureis computed), add:— adjust the CONFIG key to whichever close-message var actually exists (const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '<br>'); const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');CONFIG.TICKET_CLOSE_MESSAGEis the most likely name; fall back to the existingDISCORD_CLOSE_MESSAGEif not present). Do not invent a new env var. - 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 saidundefinednow contains the configured close message, and the signature block below it renders correctly. - If no test env exists for Gmail, at minimum console-log
htmlBodyonce and grep forundefined.
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:
- At the top of
handlers/commands.js, confirmenqueueMoveis imported from../services/channelQueue. Add the import if missing. - Replace line 540 with
await enqueueMove(interaction.channel, category.id); - Confirm
enqueueMovepreserveslockPermissions: truebehavior (readservices/channelQueue.js:~95). If it does not, add alockPermissionsoption toenqueueMove(defaulting totrueto match existing callers), rather than reverting/moveto a direct call. - Leave the surrounding
interaction.reply/ log-channel send untouched. Verify:
- Run
/movein a test ticket channel targeting another category. Confirm it moves. - Run two
/movecommands back-to-back from different ticket channels. Confirm both complete without rate-limit errors and both appear inRENAME_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 159–223)
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:
- After the existing
contenttype check (line 169), add:Useconst 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' });trimmedfor the rest of the handler. - 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. - Do not change the response shape. Verify:
curlthe endpoint with a 3000-char body and confirm a 400 response.curlwith{"content":"hi","displayName":"<script>alert(1)</script>"}and confirm the email (if sent) shows a sanitized display name.curlwith 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 795–821)
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}}}])againstbroccoli_dbto confirm no duplicatediscordThreadIdvalues 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 showIXSCAN, notCOLLSCAN.
P1 — Add index on Transcript.gmailThreadId
Priority: P1
Files: /opt/broccolini-bot/models.js (transcriptSchema, ~lines 828–832)
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 29–39), /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:
- Build a module-level
const ALLOWED_CONFIG_KEYS = new Set([...])containing every key defined inconfig.js. Generate this by readingconfig.js; do not hand-type it. Ifconfig.jsexports the list (or can cheaply derive it fromObject.keys(CONFIG)), prefer that. - At the top of the POST handler, iterate
Object.keys(req.body)and collect any not inALLOWED_CONFIG_KEYS. If any exist, return 400 with{ error: 'Unknown config keys', rejected: [...] }. - 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 87–123)
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':
- Require
scheduledForto be a string matching ISO-8601 (Date.parsereturning a finite number is sufficient). - Reject if
Number.isNaN(parsed)— return 400{ error: 'scheduledFor must be a valid ISO-8601 timestamp' }. - 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 ~595–605)
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 106–119), 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:
- 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." - Separately, coordinate with the bOSScord repo. Once bOSScord is updated, a follow-up prompt will change line 114 from
res.json(out)tores.json({ ticket: out }). Verify (for the doc-only step):docs/api/bosscord.mdexists 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:
- Read-only first: grep
handlers/commands.jsfor each of/escalate,/deescalate,/move,/transfer,/backup,/export,/search,/history,/gmailpoll check, and identify the first user-visible response on each path. - For any command where the first
interaction.reply/interaction.editReplyhappens after two or more awaited calls, addawait interaction.deferReply({ ephemeral: <matching existing ephemerality> });as the very first action, and convert subsequentinteraction.replycalls on that path tointeraction.editReplyorinteraction.followUp. - Do not touch commands that already defer. Verify:
- Run
/backupand/exporton a server with 100+ tickets. Confirm noInteractionAlreadyRepliedorUnknown interactionerrors in console. - Run
/escalateand 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 159–170)
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:
- Audit: read
services/debugLog.js:logErrorand confirm exactly what fields oferrget embedded in the Discord embed. - If
err.configorerr.response.config.headersare interpolated, add a sanitize step that stripsAuthorization,refresh_token,access_token, and any key matching/token|secret|password/ifrom the logged object before calling.send. - If only
err.messageanderr.stackare logged, grep those forprocess.env.REFRESH_TOKEN,process.env.BOSSCORD_API_KEY,process.env.INTERNAL_API_SECRETliterally — 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:
- First verify: grep the repo for
staffChannel,createStaffChannel,staffChannelId. Confirm the only matches are definitions + legacy doc references. - 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 deleteTicket.staffChannelId(old tickets may have the field). - 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 routesshows 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:
- 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 existingROLE_ID_TO_PING),EMAIL_TICKET_OVERFLOW_CATEGORY_IDS,DISCORD_TICKET_OVERFLOW_CATEGORY_IDS.DISCORD_BOT_TOKENshould be added as an explicit alias comment underDISCORD_TOKEN. - Remove from
.env.example:NGROK_URL(unused),DISCORD_ESCALATED_CATEGORY_ID,EMAIL_ESCALATED_CATEGORY_ID(legacy; superseded by*_ESCALATED2/3_CHANNEL_ID). - Do not move
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, orMONGODB_URI— they are read directly (not viaCONFIG) and should stay in.env.example. Verify: Diff.env.exampleagainstObject.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).