# 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 111–276); game list, tags, staff emoji map - `db-connection.js` — Mongo connect + require `models.js`; retry helper, shutdown hook - `models.js` — **all 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.js` — `messageCreate`: 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` for force-close timer ### `services/` - `channelQueue.js` — `enqueueRename` (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.js` — `getGmailClient`, `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail` - `debugLog.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.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.js` — **deprecated.** 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` / `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 `threadId` in `gmailThreadId`. Staff replies relay to Gmail via `handlers/messages.js` → `sendGmailReply`. - **Discord-sourced** — `gmailThreadId` 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_grant`** — `gmail-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` encoding** — `config.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 button** — `handlers/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 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:** 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: ```js const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '
'); const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); ``` — 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 `

` 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 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:** 1. After the existing `content` type check (line 169), add: ```js 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":""}` 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 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): ```js 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: ""}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`. --- ## 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: ""}).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:** 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 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'`: 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 ~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: ```js 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: 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: });` 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 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: ```js 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 `require`d 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).