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

513 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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<channelId, timeout>` 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 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:
```js
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:
```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":"<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):
```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: "<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:
```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 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:
```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).