9 Commits

Author SHA1 Message Date
6a7dee679c Close: make side-effects best-effort so none can abort the commit+delete
runFinalClose ran the transcript archive, creator DM, close log, and closure
email in the same try as the close transition and channel delete, with the
transcript posted *before* the commit. A failure in any of them (notably a
DiscordAPIError 50001 Missing Access when posting the transcript to the archive
channel) aborted the whole close: the customer saw 'ticket closed' but the DB
stayed open and the channel was never deleted.

Rewrite so the close transition + pendingDelete commit FIRST, each side-effect is
individually best-effort via a closeStep wrapper, and scheduleTicketChannelDelete
always runs. finalizeForceClose was already commit-first; wrap its remaining
unguarded archiving send too.
2026-06-05 11:27:45 +00:00
b0e8d15273 Stop tracking .scratch/ (local planning scratchpad) 2026-06-05 03:11:26 +00:00
988151d337 docs: design notes for /forward and folder-lifecycle features 2026-06-05 03:08:32 +00:00
6ae57af885 Close: guard channel delete with pendingDelete so a restart can't orphan it
The button and slash close paths deleted the channel via a bare setTimeout that
never set the pendingDelete flag, so a restart in the 5s grace window orphaned
the channel (closed in DB, still present in Discord) with no recovery — only the
auto-close path used the flag correctly.

Extract scheduleTicketChannelDelete() in services/tickets.js: a grace-delayed,
queue-routed (enqueueDelete) delete that clears pendingDelete on success. All
three close paths now use it. Button/slash set pendingDelete:true and keep
discordThreadId populated so resumePendingDeletes() recovers the delete on the
next boot. The button path previously nulled discordThreadId before the delete,
which made the channel unrecoverable.
2026-06-05 03:08:28 +00:00
61e8ea32e1 Post inbound email attachments to the ticket channel
fetchMessageAttachments downloads a Gmail message's downloadable parts as
discord.js file descriptors, skipping parts over Discord's 25 MB ceiling and
capping at 10 files per message. Nameless inline parts (CID screenshots) get a
synthesized name; nameless text/* parts (the email body Gmail serves as an
attachmentId-backed part) are skipped. The poll posts these on both new tickets
and follow-ups, naming any skipped parts so staff know to check Gmail.
2026-06-05 03:08:21 +00:00
e77be9a3e4 Add per-staff metrics: StaffAction event log + /stats command
Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats
command. Foundation for a future tickets-website analytics dashboard.

Data:
- StaffAction model (event log) + Ticket.game / Ticket.closedAt
- STATS_ADMIN_IDS config (who may view others' stats)

Recording (fire-and-forget, idempotent on real state transitions):
- claim, response (channel reply + /response send), escalate, de-escalate,
  transfer, close (4 sites), reopen — each denormalizes ticketType, tier,
  priority, game, requester (senderEmail / creatorId), guildId
- close events carry closerType / resolverId (claimer credit) / wasClaimed;
  transfer carries fromId / toId; reopen stamps resolverId
- conditional close transition helper (atomic open->closed + closedAt) shared
  by all four close paths

Query + command:
- pure period parser (presets + free-text) and stats shaper (per-metric keys)
- command-aware autocomplete dispatch
- /stats: period (autocomplete) + member (admin-gated) + source (all/email/
  discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed

288+ unit tests; timing/busiest-times data is collected but displayed later.
2026-06-05 02:47:43 +00:00
6bae3e79b1 Gmail folder auto-advance + /forward command
- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply",
  a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY
  / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves
  threads still in the auto-cycle and leaves hand-filed folders alone)
- /forward: forward a ticket's email to another address (handlers/commands/forward.js
  + forward composition in services/gmail.js)
- Tests for the auto-advance cycle; label fixtures updated for the new labels
2026-06-05 02:46:50 +00:00
0fcffe8d33 Email replies: add spacing between signature and quoted message
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:44:25 +00:00
2ccdbf72aa Email ticketing fixes, comms polish, and .env cleanup
Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

Outbound email:
- Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new
  TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails
- Replies quote the customer's latest message (gmail_quote markup so clients
  collapse it), embed custom emoji inline via CID attachment, and strip Discord
  role mentions
- Tagline spacing fix in the company signature

Discord side:
- Suppress all mentions in log + transcript posts (no more pinging on close)
- Drop the staff-role ping from new-ticket and follow-up notifications
- Ticket channels inherit category permissions instead of setting per-channel
  overwrites (removes the Manage Roles requirement)

Gmail folders:
- Folder/label routing (gmailLabels.js) with /folder; close files to Complete

Config:
- Remove ~56 stale .env keys for long-removed features; refresh stale copy

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
2026-06-04 22:05:20 +00:00
40 changed files with 5416 additions and 272 deletions

View File

@@ -60,8 +60,10 @@ SUPPORT_NAME=Support
LOGO_URL= # URL of logo shown in embeds (optional) LOGO_URL= # URL of logo shown in embeds (optional)
EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks) EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks)
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved] TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
# Email tickets only (closure email body): # Email tickets only (closure email body). Placeholders: {closer_name}; \n for line breaks.
TICKET_CLOSE_MESSAGE= # Body of closure email to customer TICKET_CLOSE_MESSAGE= # Body of closure email to customer
# Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}; \n for line breaks.
TICKET_ESCALATION_EMAIL_MESSAGE= # Body of escalation email to customer
TICKET_CLOSE_SIGNATURE= # Signature on closure email TICKET_CLOSE_SIGNATURE= # Signature on closure email
# Discord ticket closure (in-channel before transcript, transcript post, and auto-close): # Discord ticket closure (in-channel before transcript, transcript post, and auto-close):
DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.) DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.)
@@ -74,6 +76,7 @@ RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disable
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissions ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissions
STATS_ADMIN_IDS= # Comma-separated Discord user IDs allowed to view other members' /stats (e.g. 321754640431710226,691678135527276614)
# --- Auto-close --- # --- Auto-close ---
AUTO_CLOSE_ENABLED=false AUTO_CLOSE_ENABLED=false
@@ -103,6 +106,15 @@ ALLOW_CLAIM_OVERWRITE=false
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs) ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60) FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30) GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
GMAIL_POLL_ENABLED= # Inbound email flow master switch; "false" disables polling (default on). Toggle at runtime with /email on|off
GMAIL_LABEL_TRIAGE= # Gmail label for newly created tickets (default "Triage"); auto-created if missing
GMAIL_LABEL_AWAITING_REPLY= # Gmail label set when staff reply emails the customer (default "Awaiting Reply")
GMAIL_LABEL_NEEDS_RESPONSE= # Gmail label set when the customer responds (default "Needs Response")
GMAIL_LABEL_ESCALATED= # Gmail label for escalated tickets (default "Escalated")
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake")
GMAIL_LABEL_DASHBOARD_ERRORS= # /folder option (default "Dashboard Errors")
GMAIL_LABEL_PARTNERSHIP_OFFERS= # /folder option (default "Partnership Offers")
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs
RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs

3
.gitignore vendored
View File

@@ -48,5 +48,8 @@ cursor.yml
.claude/ .claude/
# Local planning/issue-tracker scratchpad — specs & PRDs stay on disk, not in git
.scratch/
CLAUDE.md CLAUDE.md
*.bak* *.bak*

View File

@@ -3,7 +3,7 @@
Broccolini Bot is a single Node.js process. It does three things at once: Broccolini Bot is a single Node.js process. It does three things at once:
1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages. 1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages.
2. **Polls Gmail** — every N seconds, pulls unread `category:primary` mail and turns each thread into a Discord ticket channel. 2. **Polls Gmail** — every N seconds, pulls unread `in:inbox` mail and turns each thread into a Discord ticket channel.
3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API. 3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API.
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API. State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
@@ -27,7 +27,7 @@ Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)`
`gmail-poll.js`: `gmail-poll.js`:
1. Lists unread messages in `category:primary`. 1. Lists unread messages in `in:inbox` (excludes Spam, Trash, and anything filter-skipped from the inbox).
2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document. 2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document.
3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`. 3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`.
4. On subsequent emails in the same thread, just appends the new message to the existing channel. 4. On subsequent emails in the same thread, just appends the new message to the existing channel.

View File

@@ -207,8 +207,12 @@ client.once('ready', async () => {
registerCommands().catch(console.error); registerCommands().catch(console.error);
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); if (CONFIG.GMAIL_POLL_ENABLED) {
poll(client); gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
} else {
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false) — inbox will not be polled. Enable with /email on.');
}
if (CONFIG.AUTO_CLOSE_ENABLED) { if (CONFIG.AUTO_CLOSE_ENABLED) {
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000)); trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));

View File

@@ -368,6 +368,61 @@ async function registerCommands() {
) )
), ),
new SlashCommandBuilder()
.setName('email')
.setDescription('Turn the inbound email flow (Gmail polling) on or off, or check its status')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(sub =>
sub.setName('on').setDescription('Start polling the inbox and creating tickets from email')
)
.addSubcommand(sub =>
sub.setName('off').setDescription('Stop polling the inbox (outbound emails still send)')
)
.addSubcommand(sub =>
sub.setName('status').setDescription('Show whether the inbound email flow is on or off')
),
new SlashCommandBuilder()
.setName('folder')
.setDescription("Move this ticket's email thread into a Gmail folder")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('destination')
.setDescription('Target folder')
.setRequired(true)
.addChoices(
{ name: 'For Jake', value: 'FOR_JAKE' },
{ name: 'Spam', value: 'SPAM' },
{ name: 'Dashboard Errors', value: 'DASHBOARD_ERRORS' },
{ name: 'Partnership Offers', value: 'PARTNERSHIP_OFFERS' }
)
),
new SlashCommandBuilder()
.setName('forward')
.setDescription("Forward this ticket's email thread to another address")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('email')
.setDescription('Destination email address')
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('note')
.setDescription('Optional message to include at the top of the forward')
.setMaxLength(1000)
.setRequired(false)
),
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('cancel-close') .setName('cancel-close')
.setDescription('Cancel a pending force-close countdown') .setDescription('Cancel a pending force-close countdown')
@@ -380,7 +435,38 @@ async function registerCommands() {
.setDescription('Set your personal email signature (valediction, display name, tagline)') .setDescription('Set your personal email signature (valediction, display name, tagline)')
.setContexts([InteractionContextType.Guild]) .setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
new SlashCommandBuilder()
.setName('stats')
.setDescription('View staff metrics for a given time window')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('period')
.setDescription('Time window (e.g. "30 days", "3 months", "1 year")')
.setRequired(false)
.setAutocomplete(true)
)
.addUserOption(opt =>
opt
.setName('member')
.setDescription('Staff member to view stats for (admin only)')
.setRequired(false)
)
.addStringOption(opt =>
opt
.setName('source')
.setDescription('Filter by ticket source')
.setRequired(false)
.addChoices(
{ name: 'All', value: 'all' },
{ name: 'Email', value: 'email' },
{ name: 'Discord', value: 'discord' }
)
)
]; ];
const contextMenuCommands = [ const contextMenuCommands = [

View File

@@ -36,9 +36,11 @@ const CONFIG = {
EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null, EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null,
DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null, DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null,
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.', ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.',
// Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}.
TICKET_ESCALATION_EMAIL_MESSAGE: process.env.TICKET_ESCALATION_EMAIL_MESSAGE || '{escalator_name} escalated this ticket to {tier}.',
TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]', TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]',
// Email tickets only (closure email body): // Email tickets only (closure email body):
TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.', TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || '{closer_name} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.',
TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.', TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.',
// Discord ticket closure (in-channel and transcript): // Discord ticket closure (in-channel and transcript):
DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.', DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.',
@@ -51,6 +53,7 @@ const CONFIG = {
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
STATS_ADMIN_IDS: (process.env.STATS_ADMIN_IDS || '').split(',').map(r => r.trim()).filter(Boolean),
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
@@ -75,6 +78,18 @@ const CONFIG = {
ADMIN_ID: process.env.ADMIN_ID || null, ADMIN_ID: process.env.ADMIN_ID || null,
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
// Inbound email flow master switch. Absent/anything-but-"false" => on, so
// existing deployments keep polling with no .env change. Toggle via /email.
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage',
GMAIL_LABEL_AWAITING_REPLY: process.env.GMAIL_LABEL_AWAITING_REPLY || 'Awaiting Reply',
GMAIL_LABEL_NEEDS_RESPONSE: process.env.GMAIL_LABEL_NEEDS_RESPONSE || 'Needs Response',
GMAIL_LABEL_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated',
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
GMAIL_LABEL_DASHBOARD_ERRORS: process.env.GMAIL_LABEL_DASHBOARD_ERRORS || 'Dashboard Errors',
GMAIL_LABEL_PARTNERSHIP_OFFERS: process.env.GMAIL_LABEL_PARTNERSHIP_OFFERS || 'Partnership Offers',
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',

View File

@@ -0,0 +1,124 @@
# Email Flow Toggle — Design
**Date:** 2026-06-03
**Status:** Approved (design); pending implementation plan
## Goal
A staff slash command to turn the **inbound** email flow on and off at runtime, with the state surviving container restarts.
- **ON** — Gmail polling runs as today: reads the inbox, creates/append ticket channels, runs all downstream features.
- **OFF** — Polling stops entirely. The mailbox is **never read** (inbox untouched). No new tickets are created from email.
- **Outbound is unaffected** in both states — ticket-close emails, Gmail replies, and notification emails still send when staff act on existing tickets. (Decision: "off" scopes to inbound polling only.)
- **Persists across restarts** — the off-state is honored on the next boot. (Decision: persisted, not runtime-only.)
Discord-originated tickets are independent of email polling and are unaffected by this toggle.
## Decisions (locked)
| Decision | Choice |
|----------|--------|
| Scope of OFF | Inbound polling only; outbound email still sends |
| Persistence | Persist to `.env` via existing config-persistence path; honored on boot |
| Command shape | New dedicated `/email on\|off\|status` command (Approach A) |
| Existing `/gmailpoll` | Guarded so it cannot silently re-enable polling while flow is OFF |
Rejected: folding into `/gmailpoll` subcommands (needless churn to a working command); sentinel interval `/gmailpoll 0` (poor discoverability).
## Architecture
Single source of truth: a new boolean config flag `GMAIL_POLL_ENABLED` (default **true**). The live poll timer (`gmailPollInterval` in `broccolini-discord.js`) is started/stopped to match the flag.
### 1. Config flag — `GMAIL_POLL_ENABLED`
- **`config.js`** — add:
```js
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
```
Undefined/absent → `true`, so existing deployments keep polling with no `.env` change required.
- **`services/configSchema.js`** — add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS`. The existing `/ENABLED$/` rule in `inferType()` already classifies it as a boolean validator, so the settings site can also toggle it (bonus, no extra work).
### 2. Boot gate
In `broccolini-discord.js` (`client.once('ready')`, currently ~lines 210-211), only start the poll when enabled:
```js
if (CONFIG.GMAIL_POLL_ENABLED) {
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
} else {
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false)');
}
```
When disabled, `gmailPollInterval` stays `null` — no timer is registered in `activeIntervals`, nothing reads the inbox.
### 3. `/email` command
- **Registration** — `commands/register.js`: a `SlashCommandBuilder` named `email` with three subcommands (`on`, `off`, `status`), `setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)` to match sibling commands.
- **Dispatch** — add `email: handleEmail` to `COMMAND_HANDLERS` in `handlers/commands/index.js`. Staff-gated automatically via `requireStaffRole()` at the dispatcher entry.
- **Handler** — `handleEmail(interaction)`:
- `on`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: true })` (updates runtime `CONFIG` **and** writes `.env`).
2. Clear the auth-suspend latch via `require('../../gmail-poll').setPollSuspended(false)` so a prior `invalid_grant` suspend doesn't keep polling dead. If auth is still broken, the next cycle re-suspends and DMs admin, exactly as today.
3. `setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS)` to start the live timer.
4. Reply (ephemeral): "Email flow is now **on**."
- `off`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: false })`.
2. `clearGmailPollInterval()`.
3. Reply (ephemeral): "Email flow is now **off** — the inbox will not be polled. Outbound emails still send."
- `status`:
- Report `CONFIG.GMAIL_POLL_ENABLED`, the current interval (`CONFIG.GMAIL_POLL_INTERVAL_MS / 1000`s), and whether polling is currently suspended by an auth error.
- On `on`/`off`, fire `logTicketEvent('Email flow toggled', [...], interaction).catch(() => {})` — fire-and-forget, matching `/gmailpoll`.
`applyConfigUpdates` is called in-process (the command runs inside the bot), reusing the same path the internal API uses — no HTTP round-trip.
### 4. Guard `/gmailpoll` against silent re-enable
`handleGmailPoll` currently calls `setGmailPollInterval(ms)`, which *starts* the timer. While flow is OFF that would silently re-enable polling. Change it so that when `CONFIG.GMAIL_POLL_ENABLED` is false:
- update the interval in memory only (`CONFIG.GMAIL_POLL_INTERVAL_MS = ms`) — matching `/gmailpoll`'s existing runtime-only model (it has never persisted to `.env`), but
- do **not** start the live timer, and
- reply: "Interval saved (`<n>`s), but the inbound email flow is currently **off** — it will apply when you run `/email on`."
When flow is ON, `/gmailpoll` behaves exactly as today.
## Data flow
```
/email off ──> applyConfigUpdates({GMAIL_POLL_ENABLED:false}) ──> CONFIG updated + .env written
└─> clearGmailPollInterval() ──> live timer stopped, gmailPollInterval=null
(no inbox reads)
restart ──> config.js reads GMAIL_POLL_ENABLED=false ──> ready gate skips poll start ──> stays off
/email on ──> applyConfigUpdates({GMAIL_POLL_ENABLED:true}) ──> CONFIG updated + .env written
├─> setPollSuspended(false) ──> clear prior auth-suspend latch
└─> setGmailPollInterval(interval) ──> live timer started, immediate poll
```
## Error handling
- Command runs through `runHandler`, so any throw is logged and the user gets an ephemeral "Something went wrong."
- `applyConfigUpdates` returns `{ applied, errors }`; if `GMAIL_POLL_ENABLED` lands in `errors` (should not, given the boolean validator), reply with the error rather than claiming success, and do not flip the live timer.
- Logging stays fire-and-forget (`.catch(() => {})`), per Hard Rule 4.
## Files touched
| File | Change |
|------|--------|
| `config.js` | Add `GMAIL_POLL_ENABLED` (default true) |
| `services/configSchema.js` | Add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS` |
| `broccolini-discord.js` | Gate poll start in `ready` on the flag |
| `commands/register.js` | Register `/email on\|off\|status` |
| `handlers/commands/index.js` | Add `handleEmail`; guard `handleGmailPoll` |
| `.env.example` | Document `GMAIL_POLL_ENABLED` (optional, default true) |
No DB schema changes. No destructive data ops. The mailbox is never read while OFF and is never written by this feature.
## Verification
- `/email off` → logs show no further poll cycles; `.env` contains `GMAIL_POLL_ENABLED=false`.
- Restart container → no polling on boot; `/email status` reports off.
- `/email on` → poll resumes (immediate cycle), `.env` flips to `true`.
- While OFF, `/gmailpoll 60` → interval saved, no polling starts.
- `npm test` (covers `services/configSchema.js`); `node --check` on every edited file.

View File

@@ -0,0 +1,217 @@
# Gmail Folder Routing — Design
**Date:** 2026-06-03
**Status:** Approved (design); pending implementation
## Goal
Route a ticket's Gmail thread into Gmail "folders" (labels) as the ticket moves
through its lifecycle, plus a manual `/folder` command for ad-hoc filing.
- On **ticket creation**, the source email thread goes into a **Triage** folder
(instead of the current plain archive).
- On **escalation**, the thread moves to an **Escalated** folder.
- On **resolution** (close), the thread moves to a **Resolved** folder.
- A **`/folder`** slash command lets staff move the current ticket's thread to one
of four manual folders: **For Jake**, **Spam**, **Dashboard Errors**,
**Partnership Offers**.
Discord-originated tickets (`gmailThreadId` prefixed `discord-`) have no Gmail
thread and are untouched by all of the above.
## Decisions (locked)
| Decision | Choice |
|----------|--------|
| Folder semantics | **Exclusive** — moving to a folder removes every other managed label and drops the thread out of the Inbox. A thread lives in exactly one managed folder. |
| "Spam" target | Gmail's **built-in system `SPAM`** label (trains the filter, hides from normal views). |
| Label names | **Configurable via `.env`**, defaulting to the names above. |
| Missing labels | **Auto-created** on first use (idempotent, cached). The system `SPAM` label is never created. |
| `/folder` options | Exactly the **4 manual folders**. Triage/Escalated/Resolved are lifecycle-driven only, not manually selectable. |
| De-escalation | **Leaves the folder as Escalated** — no auto-move back. |
Gmail labels are additive by nature; "exclusive folder" behavior is synthesized by
always removing the other managed labels on every move (removing an absent label is
a no-op, so this is safe and idempotent).
## Architecture
### 1. New module — `services/gmailLabels.js`
Single home for all label logic. Folders defined by logical key:
| Key | Source | Default name |
|-----|--------|--------------|
| `TRIAGE` | `CONFIG.GMAIL_LABEL_TRIAGE` (`.env GMAIL_LABEL_TRIAGE`) | `Triage` |
| `ESCALATED` | `CONFIG.GMAIL_LABEL_ESCALATED` | `Escalated` |
| `RESOLVED` | `CONFIG.GMAIL_LABEL_RESOLVED` | `Resolved` |
| `FOR_JAKE` | `CONFIG.GMAIL_LABEL_FOR_JAKE` | `For Jake` |
| `DASHBOARD_ERRORS` | `CONFIG.GMAIL_LABEL_DASHBOARD_ERRORS` | `Dashboard Errors` |
| `PARTNERSHIP_OFFERS` | `CONFIG.GMAIL_LABEL_PARTNERSHIP_OFFERS` | `Partnership Offers` |
| `SPAM` | built-in system label `SPAM` | (not configurable) |
`MANAGED_USER_KEYS` = all keys except `SPAM` (these are the user labels whose IDs
get resolved/created and which participate in the remove-others set).
**Exports:**
- `moveThreadToFolder(threadId, folderKey, gmail = getGmailClient())` — the one
operation everything calls.
1. Resolve the target label ID (`resolveLabelId`), and the IDs of all managed
user labels (to build the remove set).
2. `addLabelIds = [targetId]`.
3. `removeLabelIds = [all managed user-label IDs except target] + ['INBOX', 'UNREAD']`
(computed by `computeLabelMutation`). For `SPAM` target, the user labels are
all removed and `SPAM` is added; `INBOX`/`UNREAD` removed as usual.
4. `await gmail.users.threads.modify({ userId: 'me', id: threadId, requestBody: { addLabelIds, removeLabelIds } })`.
- On a `400` "invalid label" (stale cached ID for a label deleted in Gmail),
clear the cache and retry once.
- `resolveLabelId(gmail, key)` — returns the Gmail label ID for a key.
- `SPAM` short-circuits to `'SPAM'`.
- Otherwise: check the module-scoped name→ID cache; on miss, `users.labels.list`
and match by name (case-sensitive, Gmail's behavior); if still absent,
`users.labels.create` it (`labelListVisibility: 'labelShow'`,
`messageListVisibility: 'show'`) and cache the new ID.
- `computeLabelMutation(targetKey, idByKey)`**pure** function returning
`{ addLabelIds, removeLabelIds }`. Split out for unit testing without the network.
**Caching:** module-scoped `Map` of label-name → ID, populated lazily. Cleared and
re-fetched on a stale-label error.
**Client:** `getGmailClient` is required from `services/gmail.js` (acyclic —
`gmail.js` does not depend on `gmailLabels.js`). Callers that already hold a client
(the poll loop) pass it in; others let the default create one.
### 2. Triage on ticket creation — `gmail-poll.js`
Today every processed message hits `markGmailMessageRead` (strips `INBOX`+`UNREAD`)
at the shared bottom of the per-message loop (~line 397). Restructure so the
archive action is branch-specific:
- **New ticket created** (and the **reopened** closed→open case, which runs in the
create branch) → `await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail)`.
- **Follow-up to an existing open ticket** (the `if (ticketChan)` append branch) →
keep `markGmailMessageRead(gmail, msgRef)`. A reply on a thread already filed
under "For Jake"/"Resolved" should not be dragged back to Triage automatically.
- **Self / limit-exceeded / create-failure** early-`continue` paths → unchanged
plain archive (they already call `markGmailMessageRead` before `continue`).
The shared bottom `markGmailMessageRead` call is removed; the two surviving paths
(append, create) each archive/move explicitly.
`moveThreadToFolder` on creation is awaited inside the existing try/catch; a failure
is logged via the poll's existing error handling and does not abort the loop.
### 3. Escalated hook — `handlers/commands/escalation.js`
`runEscalation` is shared by the `/escalate` slash command and the tier-pick
buttons (single hook site). Inside the existing
`if (!isDiscordTicket && ticket.gmailThreadId)` block (where the escalation
notification email is already sent), add:
```js
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
```
Non-fatal — a label failure never blocks the escalation. De-escalation
(`runDeescalation`) is **not** modified.
### 4. Resolved hook — two close finalizers
Both finalizers set `status: 'closed'` and remain separate:
- `handlers/commands/close.js``finalizeForceClose`
- `handlers/buttons.js``runFinalClose`
In each, for non-Discord tickets (`!ticket.gmailThreadId.startsWith('discord-')`),
after the status update, add a non-fatal:
```js
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
```
One added line per finalizer. The move runs regardless of whether a close email is
sent (so close-without-email still files the thread under Resolved).
### 5. `/folder` command
- **Registration** (`commands/register.js`): `SlashCommandBuilder` named `folder`,
`setDefaultMemberPermissions(ManageMessages)`, Guild context / GuildInstall, with
a required string option `destination` and choices:
- `For Jake``FOR_JAKE`
- `Spam``SPAM`
- `Dashboard Errors``DASHBOARD_ERRORS`
- `Partnership Offers``PARTNERSHIP_OFFERS`
- **Dispatch** (`handlers/commands/index.js`): add `folder: handleFolder` to
`COMMAND_HANDLERS`; add a `/folder` line to `/help`.
- **Handler** `handleFolder(interaction)`:
1. `findTicketForChannel(interaction)`; bail if none.
2. If `ticket.gmailThreadId.startsWith('discord-')` → ephemeral
"This ticket has no email thread, so it can't be moved to a Gmail folder."
3. Otherwise `await moveThreadToFolder(ticket.gmailThreadId, folderKey)`.
4. Ephemeral reply: "Moved this ticket's email thread to **<label>**."
5. `logTicketEvent('Email thread filed', [...], interaction).catch(() => {})`.
6. On error, ephemeral "Failed to move the email thread: <reason>."
### 6. Config & docs
- `config.js`: add the six `GMAIL_LABEL_*` keys with the default names above.
- `.env.example`: document the six vars (default-on naming).
- Not added to `ALLOWED_CONFIG_KEYS` — settings-site contract unchanged.
## Data flow
```
inbound email (poll, flow ON)
└─ new ticket ──> moveThreadToFolder(thread, TRIAGE) [add Triage; remove others+INBOX+UNREAD]
└─ follow-up ──> markGmailMessageRead(msg) [remove INBOX+UNREAD on the new msg only]
/escalate or tier button ──> runEscalation ──> moveThreadToFolder(thread, ESCALATED)
close (slash or button) ──> finalize ──> moveThreadToFolder(thread, RESOLVED)
/folder <dest> ──> handleFolder ──> moveThreadToFolder(thread, <dest|SPAM>)
```
Every `moveThreadToFolder` resolves IDs (creating missing user labels), then one
`threads.modify` enforcing exclusive-folder semantics.
## Error handling
- Lifecycle hooks (Triage/Escalated/Resolved) are non-fatal `.catch` — Gmail
problems never block ticket flow. Errors logged via `logError`.
- `/folder` surfaces failures to the invoking staffer ephemerally.
- Stale cached label ID → one cache-clear + retry inside `moveThreadToFolder`.
- Label operations are independent of `CONFIG.GMAIL_POLL_ENABLED` (the `/email`
toggle): they are explicit staff/lifecycle actions, not polling. Triage-on-create
only fires during polling, so it is naturally inert while the flow is off.
## Files touched
| File | Change |
|------|--------|
| `services/gmailLabels.js` | **New** — folder defs, `moveThreadToFolder`, `resolveLabelId`, `computeLabelMutation`, cache |
| `tests/gmailLabels.test.js` | **New** — unit tests for mutation logic + label resolution |
| `config.js` | Add six `GMAIL_LABEL_*` config keys (defaults) |
| `.env.example` | Document the six label-name vars |
| `gmail-poll.js` | Triage on create/reopen; keep plain archive for follow-ups & non-ticket paths |
| `handlers/commands/escalation.js` | `runEscalation`: move thread to Escalated (non-fatal) |
| `handlers/commands/close.js` | `finalizeForceClose`: move thread to Resolved (non-fatal) |
| `handlers/buttons.js` | `runFinalClose`: move thread to Resolved (non-fatal) |
| `commands/register.js` | Register `/folder` with 4 choices |
| `handlers/commands/index.js` | `handleFolder` + dispatch entry + `/help` line |
No DB schema changes. No destructive data ops — `threads.modify` only relabels;
nothing is deleted or trashed. (Moving to `SPAM` is reversible from Gmail.)
## Verification
- `npm test` — existing suite plus new `tests/gmailLabels.test.js`
(`computeLabelMutation` exclusivity; `resolveLabelId` cache-hit / create-on-miss /
SPAM short-circuit, with a fake gmail client).
- `node --check` on every edited file.
- Manual (post-deploy): create an email ticket → its thread lands in Triage and
leaves the inbox; `/escalate` → Escalated; `/folder For Jake` → For Jake (and out
of Escalated); close → Resolved. Discord ticket → `/folder` reports no email
thread.

View File

@@ -0,0 +1,176 @@
# Per-Staff Metrics & Ticket Analytics — Design
**Date:** 2026-06-04
**Status:** Approved (design); implementation pending
**Component:** broccolini-bot
## Goal
Capture per-staff and per-ticket activity as a durable, event-sourced log, and
expose a useful subset via a `/stats` command. The event log is the foundation
for a future analytics dashboard on the tickets website (graphs, timing
analyses, busiest-time heatmaps, per-game and per-emailer reporting).
**Principle: collect rich data now** (history cannot be backfilled); `/stats` v1
surfaces the count metrics; timing/temporal metrics are collected but displayed
on the website later.
Because it is event-sourced, interpretation questions (does a reopen revoke
resolution credit? does time-open reset?) do **not** need a single baked-in
answer — events are recorded with timestamps and the query layer decides.
## Metrics
### Counts (shown in `/stats` v1, per staff member)
- **Claims**; **claims while escalated, by tier** (`claim` events with `tier > 0`)
- **Closes** performed by the staff member
- **Resolved (credit)** — closes where the staff member was the **claimer**
- **Escalations** / **De-escalations**, by tier
- **Unclaimed-at-close** — closes where the ticket had no claimer
- **Transfers** (initiated / received); **Reopens** (rate)
- Every count sliceable **email vs discord** and by **priority**
### Ticket volume & temporal (derived from `Ticket` collection)
- Total tickets, **email vs discord**, over a window.
- **Busiest times** — distribution of `createdAt` by hour-of-day and day-of-week
(and `closedAt` similarly). Stored as full UTC datetimes; website buckets/TZ-adjusts.
### Timing (collected now; website-only display in v1)
Derived from event timestamps + `ticket.createdAt` / `closedAt`:
- time to first staff response; time to claim; time open (created → closed)
- time to escalation; time to first response & to claim after escalation
- time to close after escalation; total time open across reopen cycles
## Ticket source & tier
- **Source:** `discord` if `gmailThreadId` starts with `discord-`/`discord-msg-`, else `email`. Denormalized as `ticketType` on every event.
- **Requester:** email tickets are attributed to `senderEmail`; discord tickets to the creator's `creatorId`. Both denormalized onto events so per-emailer (email) and per-user (discord) reporting work, sliceable by source.
- **Tier** (existing convention): `0` normal, `1` → "Tier 2 Support", `2` → "Tier 3 Support". Events store raw numeric `tier`; `/stats` mirrors the labels.
## Data model
### New model `StaffAction` (event log, in `models.js`)
```
{
staffId: String, // actor's Discord user ID ('system' for automated)
type: String, // 'claim'|'response'|'escalate'|'deescalate'|'close'|'reopen'|'transfer'
tier: Number, // ticket escalationTier at the moment (0 if none)
ticketType: String, // 'email' | 'discord'
priority: String, // ticket priority at the moment
game: String, // detected game (denormalized)
senderEmail: String, // requester for EMAIL tickets (denormalized)
creatorId: String, // requester for DISCORD tickets — creator's user ID (denormalized)
gmailThreadId: String, // ticket linkage / per-ticket timeline join key
guildId: String,
createdAt: Date, // default: Date.now (function reference)
// close-only:
closerType: String, // 'staff' | 'user' | 'system'
resolverId: String, // ticket.claimerId at close (credit); null if unclaimed
wasClaimed: Boolean, // false → unclaimed-at-close
// transfer-only:
fromId: String, // previous claimer
toId: String // new claimer
}
```
For `close`, `staffId` = the closer, `resolverId` = the claimer credited.
For `transfer`, `staffId` = who ran `/transfer`.
For `reopen`, `staffId` = `'system'` (customer email reply re-opens the thread).
Indexes: `{ staffId: 1, createdAt: -1 }` and `{ gmailThreadId: 1, createdAt: 1 }`.
### `Ticket` schema changes
- `game: String` — set at creation from existing `detectGame(subject, rawBody)`.
- `closedAt: Date` — set when a ticket is closed (robust source for time-open /
busiest-close-times even if events are pruned).
## Recording
New `services/staffStats.js``recordAction(staffId, type, payload)`,
fire-and-forget (`.catch(() => {})`), never blocking the action. `ticketType`,
`priority`, `game`, `senderEmail` read from the ticket being acted on.
**Idempotency (correctness requirement):** record an event **only on a successful
state transition** — a claim that actually set the claimer, a close that actually
closed, an escalate that changed tier. No-op / rejected / double-click
interactions must not produce events.
| Event | Site | Notes |
|--------------|-------------------------------------------------------|-------|
| `claim` | `handlers/buttons.js` `handleClaimButton` | only if claim succeeds; `tier` = current |
| `response` | `handlers/messages.js` `handleDiscordReply` | only when author `isStaff`; email & discord; before the email-only early return |
| `escalate` | `handlers/commands/escalation.js` `runEscalation` | `tier` = new tier |
| `deescalate` | `handlers/commands/escalation.js` `runDeescalation` | `tier` = new tier |
| `transfer` | `handlers/commands/index.js` `handleTransfer` | `fromId` = old claimer, `toId` = target, actor = runner |
| `close` | `handlers/buttons.js` `runFinalClose` | sets `closerType`/`resolverId`/`wasClaimed`; also set `ticket.closedAt` |
| `close` | `handlers/commands/close.js` `finalizeForceClose` | capture closer ID via `pendingCloses` (store `interaction.user.id` at countdown start) |
| `reopen` | `gmail-poll.js` reopen path (`existing.status === 'closed'`) | `staffId='system'`, customer-driven |
Auto-close (`services/tickets.js`) and orphan-channel reconcile / auto-unclaim
closes → `close` with `closerType: 'system'`, `staffId: 'system'`, preserving
`resolverId`/`wasClaimed` so resolution credit and unclaimed-at-close still count
without attributing a human.
`response` volume: record each staff reply (low volume); "first response" =
`min(createdAt)`; "after escalation" = first `response` after the `escalate` event.
## `/stats` command
Registered in `commands/register.js`; handler in the commands layer.
- `period` — string, **autocomplete + free text**, default **30 days**. Suggestions:
`7 days`, `30 days`, `3 months`, `6 months`, `1 year`. Parses `<n>d/w/m/mo/y` and
bare number = days; unparseable → 30 days.
- `member` — optional user.
- `source` — optional choice: **all** (default) / **email** / **discord**. Filters
every metric by `ticketType`, so the same command shows combined stats or a
single channel's stats.
Gating: `setDefaultMemberPermissions(ManageMessages)` + `requireStaffRole`,
ephemeral. No `member` → caller's own; `member` set → only if caller ∈
`STATS_ADMIN_IDS`, else "You can only view your own stats."
The command shows the **full** count metric set (not a trimmed subset); `source`
toggles between combined and per-channel views. Aggregation: MongoDB pipeline over
`StaffAction` (+ `Ticket` for volume) filtered by actor, `createdAt >= now - window`,
and optional `ticketType`, grouped by `type`/`tier`/`ticketType`/`priority`.
### Display (embed, v1 — counts)
```
📊 Stats — @member · last 30 days (email 30 · discord 12)
Claimed: 42 (while escalated — Tier 2: 5 · Tier 3: 1)
Closed: 38 (unclaimed: 4)
Resolved (credited): 35
Escalated: Tier 2: 4 · Tier 3: 1
De-escalated: Tier 2: 1 · Tier 3: 0
Transfers: in 2 · out 3 Reopens (their resolved tickets): 1
```
Timing/busiest-times not shown in v1 — collected for the website.
## Configuration
```
STATS_ADMIN_IDS=321754640431710226,691678135527276614,224692549225283584
```
(chicken, broccoli, ketchup) — comma-separated; users allowed to view others' stats.
## Non-goals (v1)
- Tag / Gmail-folder / canned-response usage tracking (dropped).
- Unclaim-action tracking (we track unclaimed-*at-close*).
- Multi-staff leaderboard (per-member view only).
- Website dashboard, graphs, timing/busiest-time displays, per-game/per-emailer
reports, internal-API stats endpoint. **Deferred** — data captured now.
- SLA / business-hours adjustment of timings (website computes from raw UTC).
- Event-log pruning (volume is low; revisit if needed).
## Verification
- Unit: period parser; aggregation shaping (counts by type/tier/ticketType/priority;
resolved-credit; unclaimed-at-close; transfer in/out; reopen).
- Unit: idempotency — no event on no-op claim/close.
- Manual: run claim/respond/escalate/deescalate/transfer/close/reopen across a test
email ticket and a test discord ticket; confirm `StaffAction` docs and fields,
`ticket.game`/`closedAt`; confirm per-ticket timeline yields timing deltas;
`/stats` counts + email/discord split; non-admin can't view others, admin can.
Deploy `docker compose up --build -d`; confirm bot logs ready.
```

View File

@@ -20,11 +20,13 @@ const {
detectGame, detectGame,
sanitizeEmbedText sanitizeEmbedText
} = require('./utils'); } = require('./utils');
const { getGmailClient } = require('./services/gmail'); const { getGmailClient, fetchMessageAttachments } = require('./services/gmail');
const { moveThreadToFolder, autoAdvanceFolder } = require('./services/gmailLabels');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue'); const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents'); const { getTicketActionRow } = require('./utils/ticketComponents');
const { recordAction } = require('./services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript'); const Transcript = mongoose.model('Transcript');
@@ -153,22 +155,11 @@ async function findOrCreateTicketChannel(guild, parsed, number) {
const channel = await guild.channels.create({ const channel = await guild.channels.create({
name: chanName, name: chanName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryId, parent: parentCategoryId
// Email tickets have no Discord creator — the customer is reachable // Permissions are inherited from the ticket category — configure that
// only by email. So the only per-channel allow is the staff role; we // category to deny @everyone View Channel and allow the staff role, so
// still explicitly deny @everyone in case the category permissions // tickets stay staff-only. Inheriting (rather than setting per-channel
// are ever misconfigured to grant View Channel server-wide. // overwrites here) means the bot does not need the Manage Roles permission.
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
...(CONFIG.ROLE_ID_TO_PING ? [{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}] : [])
]
}); });
return { channel, parentCategoryId }; return { channel, parentCategoryId };
} catch (createErr) { } catch (createErr) {
@@ -222,6 +213,34 @@ async function linkPreviousTranscripts(ticketChan, threadId, client) {
} }
} }
/**
* Best-effort: fetch the email's attachments and post them to the ticket channel.
* Files go out in one enqueued message (up to Discord's 10-file limit); any part
* that is too large or fails to download is named in a follow-up note so staff
* know to check Gmail. Never throws — attachment delivery must not break the
* ticket flow.
*/
async function postEmailAttachments(channel, gmail, email, client) {
try {
const { files, skipped } = await fetchMessageAttachments(email.data.id, email.data.payload, gmail);
if (files.length) {
await enqueueSend(channel, {
content: '**Email attachments:**',
files,
allowedMentions: { parse: [] }
});
}
if (skipped.length) {
await enqueueSend(channel, {
content: `⚠️ ${skipped.length} attachment(s) could not be posted (too large or failed to download) — check Gmail: ${skipped.map(s => `\`${s}\``).join(', ')}`,
allowedMentions: { parse: [] }
});
}
} catch (err) {
logError('postEmailAttachments', err, null, client).catch(() => {});
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */ /** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) { async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({ await gmail.users.messages.batchModify({
@@ -260,6 +279,54 @@ function oauthSuspendIfPermanent(err, client) {
return true; return true;
} }
// ============================================================
// Email ticket persistence (Part A: game; Part B: reopen recording)
// ============================================================
/**
* Upsert the email ticket record and, when wasReopened is true, fire-and-forget
* a 'reopen' StaffAction with resolverId = the prior claimerId from the
* returned doc (claimerId is never cleared by any close path).
*
* Injectables: _Ticket (Ticket model), _recordAction (staffStats.recordAction).
* Exported for unit testing.
*/
async function persistEmailTicket(fields, guildId, wasReopened, _Ticket, _recordAction) {
const {
threadId, discordThreadId, senderEmail, subject, createdAt,
ticketNumber, priority, parentCategoryId, game
} = fields;
const doc = await withRetry(() => _Ticket.findOneAndUpdate(
{ gmailThreadId: threadId },
{
$set: {
discordThreadId,
senderEmail,
subject,
createdAt,
status: 'open',
ticketNumber,
priority,
lastActivity: createdAt,
parentCategoryId,
game
}
},
{ upsert: true, new: true }
));
if (wasReopened && doc) {
_recordAction('system', 'reopen', {
ticket: doc,
guildId,
resolverId: doc.claimerId ?? null
});
}
return doc;
}
// ============================================================ // ============================================================
// Orchestrator // Orchestrator
// ============================================================ // ============================================================
@@ -277,7 +344,7 @@ async function poll(client) {
const gmail = getGmailClient(); const gmail = getGmailClient();
const list = await gmail.users.messages.list({ const list = await gmail.users.messages.list({
userId: 'me', userId: 'me',
q: 'is:unread category:primary' q: 'is:unread in:inbox'
}); });
if (!list.data.messages) return; if (!list.data.messages) return;
@@ -297,6 +364,7 @@ async function poll(client) {
.select('gmailThreadId discordThreadId status') .select('gmailThreadId discordThreadId status')
.lean(); .lean();
const wasClosedTicket = !!existing && existing.status === 'closed';
let ticketChan = null; let ticketChan = null;
let parentCategoryIdForTicket = null; let parentCategoryIdForTicket = null;
let isReopened = false; let isReopened = false;
@@ -310,11 +378,28 @@ async function poll(client) {
if (ticketChan) { if (ticketChan) {
// Append follow-up to existing channel. // Append follow-up to existing channel.
const truncatedFollowup = parsed.followupBody.slice(0, 1800); const truncatedFollowup = parsed.followupBody.slice(0, 1800);
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. // No staff role ping; body is attacker-controlled email content — suppress all mentions.
await enqueueSend(ticketChan, { await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`, content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: [] }
}); });
await postEmailAttachments(ticketChan, gmail, email, client);
// Customer responded → advance the thread to Needs Response. A
// successful move strips INBOX+UNREAD (archives + marks read like
// markGmailMessageRead did). If the thread is manually filed (For Jake,
// Spam, …) autoAdvanceFolder leaves it put and returns false — or the
// move may fail — so in either case fall back to marking just the new
// message read, preserving the manual filing and avoiding reprocessing.
let advanced = false;
try {
advanced = await autoAdvanceFolder(parsed.threadId, 'NEEDS_RESPONSE', gmail);
} catch (err) {
logError('autoAdvanceFolder(NEEDS_RESPONSE)', err, null, client).catch(() => {});
}
if (!advanced) {
console.log('Archiving/reading Gmail message', msgRef.id);
await markGmailMessageRead(gmail, msgRef);
}
} else { } else {
// Create a new ticket channel. // Create a new ticket channel.
const limitCheck = await checkTicketLimits(parsed.senderEmail); const limitCheck = await checkTicketLimits(parsed.senderEmail);
@@ -345,10 +430,9 @@ async function poll(client) {
); );
const welcomeMsg = await enqueueSend(ticketChan, { const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed], embeds: [ticketInfoEmbed],
components: [buttons], components: [buttons],
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: [] }
}); });
const { createStaffThread } = require('./services/staffThread'); const { createStaffThread } = require('./services/staffThread');
@@ -369,33 +453,37 @@ async function poll(client) {
content: `**Message:**\n${truncated}`, content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] } allowedMentions: { parse: [] }
}); });
await postEmailAttachments(ticketChan, gmail, email, client);
// Welcome message skipped for email tickets the email body speaks for itself. // Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
const now = new Date(); const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate( await persistEmailTicket(
{ gmailThreadId: parsed.threadId },
{ {
$set: { threadId: parsed.threadId,
discordThreadId: ticketChan.id, discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail, senderEmail: parsed.senderEmail,
subject: parsed.subject, subject: parsed.subject,
createdAt: now, createdAt: now,
status: 'open', ticketNumber: number,
ticketNumber: number, priority: defaultPriority,
priority: defaultPriority, parentCategoryId: parentCategoryIdForTicket,
lastActivity: now, game: detectedGame
parentCategoryId: parentCategoryIdForTicket
}
}, },
{ upsert: true, new: true } guild.id,
)); wasClosedTicket,
} Ticket,
recordAction
);
console.log('Archiving/reading Gmail message', msgRef.id); // New (or reopened) ticket: file the email thread into Triage — out of
await markGmailMessageRead(gmail, msgRef); // the inbox, marked read, awaiting staff action. The threads.modify also
// clears UNREAD, so a success archives it like markGmailMessageRead did.
console.log('Filing Gmail thread into Triage', parsed.threadId);
await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail);
}
} }
authErrorNotified = false; authErrorNotified = false;
} catch (e) { } catch (e) {
@@ -408,4 +496,4 @@ async function poll(client) {
} }
} }
module.exports = { poll, setPollSuspended }; module.exports = { poll, setPollSuspended, persistEmailTicket };

View File

@@ -22,11 +22,13 @@ const {
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition, scheduleTicketChannelDelete } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail'); const { sendTicketClosedEmail } = require('../services/gmail');
const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { sanitizeEmbedText, truncateEmbedDescription, isStaff } = require('../utils');
const { recordAction } = require('../services/staffStats');
const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands'); const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
@@ -157,14 +159,24 @@ async function handleClaimButton(interaction, ticket) {
} }
} }
async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild) { async function applyClaim(interaction, freshTicket, row, btnClose, btnClaim, claimerLabel, guild, _TicketModel, _recordAction) {
await Ticket.updateOne( const T = _TicketModel || Ticket;
{ gmailThreadId: freshTicket.gmailThreadId }, const record = _recordAction || recordAction;
const result = await T.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId, claimerId: { $ne: interaction.user.id } },
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
); );
freshTicket.claimedBy = claimerLabel; freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id; freshTicket.claimerId = interaction.user.id;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'claim', {
ticket: freshTicket,
guildId: interaction.guild?.id
});
}
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
@@ -392,8 +404,20 @@ async function handleDeescalateButton(interaction, ticket) {
* posts to the transcript channel and optionally DMs the creator, sends the * posts to the transcript channel and optionally DMs the creator, sends the
* customer closure email (email tickets only), then deletes the channel. * customer closure email (email tickets only), then deletes the channel.
*/ */
// Run one best-effort close side-effect. A failure is logged but never propagates,
// so it cannot abort the close — the transition and channel delete still happen.
async function closeStep(label, fn) {
try {
await fn();
} catch (e) {
logError(`runFinalClose:${label}`, e).catch(() => {});
}
}
async function runFinalClose(interaction, ticket, sendEmail = true) { async function runFinalClose(interaction, ticket, sendEmail = true) {
const closedAt = new Date(); const closedAt = new Date();
const channel = interaction.channel;
const channelName = channel.name;
try { try {
await interaction.update({ content: 'Archiving and closing...', components: [] }); await interaction.update({ content: 'Archiving and closing...', components: [] });
@@ -402,73 +426,95 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {}); await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
} }
try { // Build the transcript up front — it needs the channel's history, before delete.
const channelName = interaction.channel.name; // Best-effort: a failure here must not block the close.
const transcriptText = await buildTranscriptText(interaction.channel, ticket); let transcriptText = null;
const file = new AttachmentBuilder(Buffer.from(transcriptText), { await closeStep('buildTranscript', async () => { transcriptText = await buildTranscriptText(channel, ticket); });
name: `transcript-${channelName}.txt`
});
// CRITICAL #1 — commit the close and mark pendingDelete (discordThreadId stays
// set for restart recovery). Done BEFORE the fallible side-effects below so none
// of them can leave a "closed"-looking but still-open, undeleted ticket.
let transitioned = false;
let closedTicket = null;
await closeStep('transition', async () => {
({ transitioned, ticket: closedTicket } =
await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }));
});
// Customer-facing close notice (best-effort).
await closeStep('closeMessage', () => enqueueSend(channel, CONFIG.DISCORD_CLOSE_MESSAGE));
// Archive the transcript to the transcript channel (best-effort — a Missing
// Access here previously aborted the whole close).
let transcriptMsg = null;
if (transcriptText != null) {
const openedStr = formatDateForTranscript(ticket.createdAt); const openedStr = formatDateForTranscript(ticket.createdAt);
const closedStr = formatDateForTranscript(closedAt); const closedStr = formatDateForTranscript(closedAt);
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr); await closeStep('transcriptArchive', async () => {
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE); const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` });
const transcriptChan = await interaction.client.channels.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID).catch(() => null);
let transcriptMsg = null; if (transcriptChan) {
const transcriptChan = await interaction.client.channels transcriptMsg = await enqueueSend(transcriptChan, {
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID) content: transcriptContent, files: [file], allowedMentions: { parse: [] }
.catch(() => null); });
if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent,
files: [file]
});
}
// Optionally DM the transcript to the ticket creator. Many users have
// server-member DMs disabled; gated to avoid 50007 noise. Discord-origin
// tickets only.
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
}
await postCloseLogEntry(interaction, ticket, channelName);
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
}
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
);
if (transcriptMsg?.id) {
await Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
});
}
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
} }
}, 6000)); });
} catch (e) {
console.error('Close ticket error:', e); // DM the transcript to the creator (Discord-origin tickets only). Best-effort —
// many users have member DMs disabled (50007).
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
await closeStep('dmCreator', () =>
dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr));
}
} }
await closeStep('closeLog', () => postCloseLogEntry(interaction, ticket, channelName));
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
await closeStep('closeEmail', () => sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id));
}
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transitioned && closedTicket) {
const closerType = isStaff(interaction.member) ? 'staff' : 'user';
recordAction(interaction.user.id, 'close', {
ticket: closedTicket,
guildId: interaction.guild?.id,
closerType,
resolverId: closedTicket.claimerId ?? null,
wasClaimed: Boolean(closedTicket.claimerId)
});
}
if (transcriptMsg?.id) {
await closeStep('transcriptRecord', () => Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
}));
}
// CRITICAL #2 — schedule the channel delete. Always runs, regardless of any
// side-effect failure above.
scheduleTicketChannelDelete(channel, ticket.gmailThreadId);
// Best-effort overflow-category cleanup after the channel is gone.
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
}
}, 6000));
} }
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
@@ -519,7 +565,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
} else { } else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
} }
await enqueueSend(logChan, logMsg); await enqueueSend(logChan, { content: logMsg, allowedMentions: { parse: [] } });
} }
// ============================================================ // ============================================================
@@ -607,7 +653,7 @@ async function handleTicketModal(interaction) {
if (CONFIG.LOGGING_CHANNEL_ID) { if (CONFIG.LOGGING_CHANNEL_ID) {
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null); const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
if (logChan) { if (logChan) {
await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}`); await enqueueSend(logChan, { content: `📝 ${channel.name} created by ${interaction.user.tag}`, allowedMentions: { parse: [] } });
} }
} }
} catch (err) { } catch (err) {
@@ -746,4 +792,4 @@ async function handleButton(interaction) {
return ticketHandler(interaction, ticket); return ticketHandler(interaction, ticket);
} }
module.exports = { handleButton, handleTicketModal }; module.exports = { handleButton, handleTicketModal, runFinalClose, applyClaim };

View File

@@ -11,10 +11,13 @@ const { AttachmentBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection'); const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue'); const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog'); const { logTicketEvent, logError } = require('../../services/debugLog');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses'); const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { attemptCloseTransition, scheduleTicketChannelDelete } = require('../../services/tickets');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -57,33 +60,49 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel; const channelRef = interaction.channel;
const clientRef = interaction.client; const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag }); pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag, closerId: interaction.user.id });
} }
/** Performs the actual force-close work after the countdown elapses. */ /** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) { async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAction, _pendingCloses) {
pendingCloses.delete(channelRef.id); const T = _TicketModel || Ticket;
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean(); const record = _recordAction || recordAction;
const pc = _pendingCloses || pendingCloses;
const pending = pc.get(channelRef.id);
pc.delete(channelRef.id);
const closerId = pending?.closerId ?? null;
const freshTicket = await T.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return; if (!freshTicket || freshTicket.status === 'closed') return;
try { try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry // pendingDelete (with discordThreadId left set) lets resumePendingDeletes()
// a stale message ID pointing into the now-deleted channel. // recover the channel delete if a restart interrupts the grace window.
await Ticket.updateOne( const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }, T);
{ gmailThreadId: freshTicket.gmailThreadId }, if (transitioned) {
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } } record(closerId ?? 'system', 'close', {
); ticket: closedTicket,
guildId: channelRef.guild?.id,
closerType: closerId ? 'staff' : 'system',
resolverId: closedTicket.claimerId ?? null,
wasClaimed: Boolean(closedTicket.claimerId)
});
}
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...'); // File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
moveThreadToFolder(freshTicket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
// Both best-effort — a failure here must not skip the channel delete below.
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...').catch(() => {});
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr => await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr) console.error('Transcript error (force-close):', tErr)
); );
setTimeout(() => { // Queue-routed, pendingDelete-guarded delete (shared with auto-close + button
channelRef.delete('Ticket force-closed').catch(e => // close) so a mid-close restart can't orphan the channel.
console.error('Failed to delete channel:', e) scheduleTicketChannelDelete(channelRef, freshTicket.gmailThreadId);
);
}, 5000);
} catch (err) { } catch (err) {
console.error('Force close error:', err); console.error('Force close error:', err);
} }
@@ -106,7 +125,7 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
const openedStr = formatDateForTranscript(freshTicket.createdAt); const openedStr = formatDateForTranscript(freshTicket.createdAt);
const closedStr = formatDateForTranscript(new Date()); const closedStr = formatDateForTranscript(new Date());
const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr); const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
} }
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose }; module.exports = { handleCloseTimer, handleCancelClose, handleForceClose, finalizeForceClose };

View File

@@ -10,12 +10,14 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { sendTicketNotificationEmail } = require('../../services/gmail'); const { sendTicketNotificationEmail } = require('../../services/gmail');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { getTicketActionRow } = require('../../utils/ticketComponents'); const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
const { pinMessage } = require('../../services/pinMessage'); const { pinMessage } = require('../../services/pinMessage');
const { logError } = require('../../services/debugLog'); const { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers'); const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers'); const { fetchLoggingChannel } = require('./helpers');
const { recordAction } = require('../../services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -36,19 +38,29 @@ function resolveEscalationCategoryId(ticket, nextTier) {
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred. * validate ticket and currentTier < nextTier, and have already deferred.
*/ */
async function runEscalation(interaction, ticket, nextTier) { async function runEscalation(interaction, ticket, nextTier, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = resolveEscalationCategoryId(ticket, nextTier); const categoryId = resolveEscalationCategoryId(ticket, nextTier);
// Clear claim on escalation // Conditional write: only update if the tier hasn't already been set to nextTier.
await Ticket.updateOne( // modifiedCount === 0 means a concurrent request already escalated — no event.
{ gmailThreadId: ticket.gmailThreadId }, const result = await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: nextTier } },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
); );
ticket.escalated = true; ticket.escalated = true;
ticket.escalationTier = nextTier; ticket.escalationTier = nextTier;
ticket.claimedBy = null; ticket.claimedBy = null;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'escalate', {
ticket,
guildId: interaction.guild?.id
});
}
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname); const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
@@ -98,11 +110,20 @@ async function runEscalation(interaction, ticket, nextTier) {
try { try {
const escalatorName = interaction.member?.displayName || interaction.user.username; const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`; // Editable via TICKET_ESCALATION_EMAIL_MESSAGE in .env. Placeholders:
// {escalator_name}, {tier}; \n for line breaks.
const emailBody = (CONFIG.TICKET_ESCALATION_EMAIL_MESSAGE || '')
.replace(/\\n/g, '\n')
.replace(/\{escalator_name\}/g, escalatorName)
.replace(/\{tier\}/g, tierLabel);
await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id); await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id);
} catch (emailErr) { } catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message); console.error('Escalation email failed (non-fatal):', emailErr.message);
} }
// File the email thread into the Escalated folder — non-fatal, never blocks
// the escalation.
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
} }
if (nextTier === 2 && ticket.welcomeMessageId) { if (nextTier === 2 && ticket.welcomeMessageId) {
@@ -118,26 +139,38 @@ async function runEscalation(interaction, ticket, nextTier) {
if (logChan) { if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email'; const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan, await enqueueSend(logChan, {
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.` content: `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`,
); allowedMentions: { parse: [] }
});
} }
} }
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */ /** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
async function runDeescalation(interaction, ticket) { async function runDeescalation(interaction, ticket, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1; const newTier = currentTier - 1;
await Ticket.updateOne( // Conditional write: only update if the tier hasn't already been set to newTier.
{ gmailThreadId: ticket.gmailThreadId }, // modifiedCount === 0 means a concurrent request already deescalated — no event.
const result = await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId, escalationTier: { $ne: newTier } },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } } { $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
); );
ticket.escalated = newTier > 0; ticket.escalated = newTier > 0;
ticket.escalationTier = newTier; ticket.escalationTier = newTier;
ticket.claimedBy = null; ticket.claimedBy = null;
if (result.modifiedCount === 1) {
record(interaction.user.id, 'deescalate', {
ticket,
guildId: interaction.guild?.id
});
}
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated'; const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {})); enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
@@ -168,9 +201,10 @@ async function runDeescalation(interaction, ticket) {
const logChan = await fetchLoggingChannel(interaction.client); const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) { if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email'; const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan, await enqueueSend(logChan, {
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.` content: `${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`,
); allowedMentions: { parse: [] }
});
} }
} }

View File

@@ -0,0 +1,59 @@
/**
* /forward — forward this ticket's email thread to a third-party address.
*
* Builds a fresh outbound email to the target only; the original customer is
* never looped in (see services/gmail.js forwardThread).
*/
const { MessageFlags } = require('discord.js');
const { findTicketForChannel } = require('../sharedHelpers');
const { forwardThread } = require('../../services/gmail');
const { logError, logTicketEvent } = require('../../services/debugLog');
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
async function handleForward(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Discord-origin tickets have no Gmail thread to forward.
if (ticket.gmailThreadId.startsWith('discord-')) {
return interaction.reply({
content: "This ticket has no email thread, so there's nothing to forward.",
flags: MessageFlags.Ephemeral
});
}
const target = interaction.options.getString('email');
const note = interaction.options.getString('note') || '';
// Defer: fetching the thread + downloading attachments can exceed the 3s window.
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const { messageCount, attachmentCount, skipped } = await forwardThread(
ticket.gmailThreadId, target, note
);
logTicketEvent('Email thread forwarded', [
{ name: 'To', value: target },
{ name: 'Messages', value: String(messageCount) },
{ name: 'Forwarded by', value: interaction.user.tag }
], interaction).catch(() => {});
const skippedNote = skipped ? ` (${plural(skipped, 'attachment')} skipped — over the size limit)` : '';
return interaction.editReply({
content: `Forwarded ${plural(messageCount, 'message')} (${plural(attachmentCount, 'attachment')}) to **${target}**.${skippedNote}`
});
} catch (err) {
if (err.code === 'EBADRECIPIENT') {
return interaction.editReply({ content: "That doesn't look like a valid email address." });
}
if (err.code === 'EEMPTY') {
return interaction.editReply({ content: 'This thread has no messages to forward.' });
}
logError('handleForward', err, interaction).catch(() => {});
return interaction.editReply({ content: `Failed to forward: ${err.message}` });
}
}
module.exports = { handleForward };

View File

@@ -19,17 +19,22 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils'); const { isStaff } = require('../../utils');
const { setNotifyDm } = require('../../services/staffSettings'); const { setNotifyDm } = require('../../services/staffSettings');
const { recordAction } = require('../../services/staffStats');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logError, logTicketEvent } = require('../../services/debugLog'); const { logError, logTicketEvent } = require('../../services/debugLog');
const { applyConfigUpdates } = require('../../services/configPersistence');
const { moveThreadToFolder, folderDisplayName } = require('../../services/gmailLabels');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response'); const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel'); const { handlePanel, handleSignature } = require('./panel');
const { handleForward } = require('./forward');
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu'); const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
const { handleStats, handleStatsAutocomplete } = require('./stats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -90,6 +95,57 @@ async function handleRemove(interaction) {
} }
} }
async function applyTransfer(interaction, ticket, guildMember, reason, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const fromId = ticket.claimerId; // capture BEFORE the write
const toId = guildMember.id;
const claimerLabel = guildMember.displayName || guildMember.user.username;
await T.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: toId } }
);
ticket.claimedBy = claimerLabel;
ticket.claimerId = toId;
// Gate: transferring to the member who already holds the claim is a no-op.
if (fromId !== toId) {
record(interaction.user.id, 'transfer', {
ticket,
guildId: interaction.guild?.id,
fromId,
toId
});
}
// Rename the channel to reflect the new claimer — mirrors the /claim
// button flow (applyClaim in handlers/buttons.js). Picks the new
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
// variant when tier >= 1.
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
.catch(err => logError('rename', err).catch(() => {}));
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.editReply({
content: `Ticket transferred to ${guildMember.user} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${guildMember.user.tag}.\nReason: ${reason}`,
allowedMentions: { parse: [] }
});
}
}
async function handleTransfer(interaction) { async function handleTransfer(interaction) {
const member = interaction.options.getUser('member'); const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided'; const reason = interaction.options.getString('reason') || 'No reason provided';
@@ -122,39 +178,7 @@ async function handleTransfer(interaction) {
await interaction.deferReply(); await interaction.deferReply();
try { try {
const claimerLabel = guildMember.displayName || guildMember.user.username; await applyTransfer(interaction, ticket, guildMember, reason);
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
);
ticket.claimedBy = claimerLabel;
ticket.claimerId = guildMember.id;
// Rename the channel to reflect the new claimer — mirrors the /claim
// button flow (applyClaim in handlers/buttons.js). Picks the new
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
// variant when tier >= 1.
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
.catch(err => logError('rename', err).catch(() => {}));
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.editReply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
}
} catch (err) { } catch (err) {
console.error('Transfer error:', err); console.error('Transfer error:', err);
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {}); await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
@@ -176,9 +200,10 @@ async function handleMove(interaction) {
const logChan = await fetchLoggingChannel(interaction.client); const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) { if (logChan) {
await enqueueSend(logChan, await enqueueSend(logChan, {
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` content: `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`,
); allowedMentions: { parse: [] }
});
} }
} catch (err) { } catch (err) {
console.error('Move error:', err); console.error('Move error:', err);
@@ -245,6 +270,16 @@ async function handleGmailPoll(interaction) {
// drop below 30s and trip Gmail's per-user quota under sustained load. // drop below 30s and trip Gmail's per-user quota under sustained load.
const ms = Math.max(30000, requested * 1000); const ms = Math.max(30000, requested * 1000);
const seconds = ms / 1000; const seconds = ms / 1000;
// While the inbound email flow is off, setting an interval must NOT silently
// restart polling. Record it for this session (matches /gmailpoll's existing
// runtime-only model) so it applies the next time someone runs /email on.
if (!CONFIG.GMAIL_POLL_ENABLED) {
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
return interaction.reply({
content: `Interval saved (${seconds}s), but the inbound email flow is currently **off** — it will apply when you run \`/email on\`.`,
flags: MessageFlags.Ephemeral
});
}
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle. // Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
const { setGmailPollInterval } = require('../../broccolini-discord'); const { setGmailPollInterval } = require('../../broccolini-discord');
setGmailPollInterval(ms); setGmailPollInterval(ms);
@@ -255,6 +290,82 @@ async function handleGmailPoll(interaction) {
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral }); return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
} }
async function handleEmail(interaction) {
const sub = interaction.options.getSubcommand();
if (sub === 'status') {
const intervalSec = Math.round(CONFIG.GMAIL_POLL_INTERVAL_MS / 1000);
return interaction.reply({
content: `Inbound email flow is **${CONFIG.GMAIL_POLL_ENABLED ? 'on' : 'off'}**.\nPoll interval: ${intervalSec}s.`,
flags: MessageFlags.Ephemeral
});
}
const enable = sub === 'on';
// applyConfigUpdates writes both CONFIG and .env so the state survives restart.
const { applied, errors } = applyConfigUpdates({ GMAIL_POLL_ENABLED: enable });
if (!applied.includes('GMAIL_POLL_ENABLED')) {
const reason = (errors.find(e => e.key === 'GMAIL_POLL_ENABLED') || {}).error || 'unknown error';
return interaction.reply({
content: `Failed to turn email flow ${enable ? 'on' : 'off'}: ${reason}`,
flags: MessageFlags.Ephemeral
});
}
// Lazy require — broccolini-discord re-exports these and we'd otherwise cycle.
const { setGmailPollInterval, clearGmailPollInterval } = require('../../broccolini-discord');
if (enable) {
// Clear any auth-suspend latch so a prior invalid_grant doesn't keep polling
// dead. If auth is still broken, the next cycle re-suspends and DMs admin.
try { require('../../gmail-poll').setPollSuspended(false); } catch (_) {}
setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
} else {
clearGmailPollInterval();
}
logTicketEvent('Email flow toggled', [
{ name: 'State', value: enable ? 'on' : 'off' },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({
content: enable
? 'Inbound email flow is now **on** — the inbox will be polled.'
: 'Inbound email flow is now **off** — the inbox will not be polled. Outbound emails still send.',
flags: MessageFlags.Ephemeral
});
}
async function handleFolder(interaction) {
const folderKey = interaction.options.getString('destination');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Discord-origin tickets have no Gmail thread to file.
if (ticket.gmailThreadId.startsWith('discord-')) {
return interaction.reply({
content: "This ticket has no email thread, so it can't be moved to a Gmail folder.",
flags: MessageFlags.Ephemeral
});
}
const label = folderDisplayName(folderKey) || 'Spam';
// Defer: resolving/creating labels + threads.modify can exceed the 3s window.
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
await moveThreadToFolder(ticket.gmailThreadId, folderKey);
logTicketEvent('Email thread filed', [
{ name: 'Folder', value: label },
{ name: 'Filed by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.editReply({ content: `Moved this ticket's email thread to **${label}**.` });
} catch (err) {
logError('handleFolder', err, interaction).catch(() => {});
return interaction.editReply({ content: `Failed to move the email thread: ${err.message}` });
}
}
async function handleHelp(interaction) { async function handleHelp(interaction) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Ticket System - Commands') .setTitle('Ticket System - Commands')
@@ -266,7 +377,7 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Ticket Management', name: 'Ticket Management',
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description' value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder\n`/forward <email> [note]` - Forward this ticket\'s email thread to another address'
}, },
{ {
name: 'Saved Responses', name: 'Saved Responses',
@@ -286,7 +397,7 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Staff Configuration', name: 'Staff Configuration',
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval' value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval\n`/email on|off|status` - Turn the inbound email flow on/off'
}, },
{ {
name: 'Right-click (Apps menu)', name: 'Right-click (Apps menu)',
@@ -313,14 +424,18 @@ const COMMAND_HANDLERS = {
staffthread: handleStaffThread, staffthread: handleStaffThread,
pinmessages: handlePinMessages, pinmessages: handlePinMessages,
gmailpoll: handleGmailPoll, gmailpoll: handleGmailPoll,
email: handleEmail,
folder: handleFolder,
closetimer: handleCloseTimer, closetimer: handleCloseTimer,
forward: handleForward,
'cancel-close': handleCancelClose, 'cancel-close': handleCancelClose,
'force-close': handleForceClose, 'force-close': handleForceClose,
topic: handleTopic, topic: handleTopic,
response: handleResponse, response: handleResponse,
signature: handleSignature, signature: handleSignature,
help: handleHelp, help: handleHelp,
panel: handlePanel panel: handlePanel,
stats: handleStats
}; };
const CONTEXT_MENU_HANDLERS = { const CONTEXT_MENU_HANDLERS = {
@@ -328,6 +443,17 @@ const CONTEXT_MENU_HANDLERS = {
'View User Tickets': handleViewUserTickets 'View User Tickets': handleViewUserTickets
}; };
const AUTOCOMPLETE_HANDLERS = {
response: handleResponseAutocomplete,
stats: handleStatsAutocomplete
};
async function handleAutocomplete(interaction, _handlers) {
const handlers = _handlers || AUTOCOMPLETE_HANDLERS;
const handler = handlers[interaction.commandName];
if (handler) await handler(interaction);
}
/** /**
* Slash-command dispatcher. Every command is staff-only — including /help, * Slash-command dispatcher. Every command is staff-only — including /help,
* which previously bypassed the role check. * which previously bypassed the role check.
@@ -351,5 +477,6 @@ module.exports = {
handleAutocomplete, handleAutocomplete,
runEscalation, runEscalation,
runDeescalation, runDeescalation,
resolveEscalationCategoryId resolveEscalationCategoryId,
applyTransfer
}; };

View File

@@ -17,6 +17,7 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { replaceVariables } = require('../../utils'); const { replaceVariables } = require('../../utils');
const { logError } = require('../../services/debugLog'); const { logError } = require('../../services/debugLog');
const { recordAction } = require('../../services/staffStats');
const Tag = mongoose.model('Tag'); const Tag = mongoose.model('Tag');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -38,14 +39,18 @@ async function handleResponse(interaction) {
} }
} }
async function handleResponseSend(interaction) { async function handleResponseSend(interaction, _TagModel, _TicketModel, _recordAction) {
const TTag = _TagModel || Tag;
const TTicket = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const name = interaction.options.getString('name'); const name = interaction.options.getString('name');
const tag = await Tag.findOne({ name }).lean(); const tag = await TTag.findOne({ name }).lean();
if (!tag) { if (!tag) {
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral }); return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
} }
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); const ticket = await TTicket.findOne({ discordThreadId: interaction.channel.id }).lean();
const context = { const context = {
ticket: ticket || {}, ticket: ticket || {},
staff: { staff: {
@@ -57,10 +62,14 @@ async function handleResponseSend(interaction) {
}; };
const content = replaceVariables(tag.content, context); const content = replaceVariables(tag.content, context);
await Tag.updateOne({ name }, { $inc: { useCount: 1 } }); await TTag.updateOne({ name }, { $inc: { useCount: 1 } });
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data. // Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
// Disable mention parsing so a `@everyone` in a tag body never pings. // Disable mention parsing so a `@everyone` in a tag body never pings.
await interaction.reply({ content, allowedMentions: { parse: [] } }); await interaction.reply({ content, allowedMentions: { parse: [] } });
if (ticket) {
record(interaction.user.id, 'response', { ticket, guildId: interaction.guild?.id });
}
} }
async function handleResponseCreate(interaction) { async function handleResponseCreate(interaction) {
@@ -146,9 +155,8 @@ const RESPONSE_SUBCOMMANDS = {
list: handleResponseList list: handleResponseList
}; };
/** Autocomplete handler. Currently only /response uses it. */ /** Autocomplete handler for /response. Routed here by the dispatcher in index.js. */
async function handleAutocomplete(interaction) { async function handleAutocomplete(interaction) {
if (interaction.commandName !== 'response') return;
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
if (!['send', 'edit', 'delete'].includes(subcommand)) return; if (!['send', 'edit', 'delete'].includes(subcommand)) return;
@@ -162,4 +170,4 @@ async function handleAutocomplete(interaction) {
await interaction.respond(filtered); await interaction.respond(filtered);
} }
module.exports = { handleResponse, handleAutocomplete }; module.exports = { handleResponse, handleAutocomplete, handleResponseSend };

139
handlers/commands/stats.js Normal file
View File

@@ -0,0 +1,139 @@
'use strict';
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { parsePeriod, shapeStats } = require('../../services/statsShaping');
const PERIOD_PRESETS = ['7 days', '30 days', '3 months', '6 months', '1 year'];
const TIER_LABELS = { 1: 'Tier 2', 2: 'Tier 3' };
function tierLabel(n) {
return TIER_LABELS[n] || `Tier ${n + 1}`;
}
function formatTierMap(obj) {
const keys = Object.keys(obj).map(Number).sort((a, b) => a - b);
if (!keys.length) return '0';
return keys.map(k => `${tierLabel(k)}: ${obj[k]}`).join(', ');
}
async function handleStatsAutocomplete(interaction) {
const focused = (interaction.options.getFocused() || '').trim();
const lower = focused.toLowerCase();
const suggestions = PERIOD_PRESETS
.filter(p => !focused || p.toLowerCase().includes(lower))
.map(p => ({ name: p, value: p }));
// Echo typed input as an extra suggestion when it differs from all presets.
if (focused && !PERIOD_PRESETS.some(p => p.toLowerCase() === lower)) {
suggestions.unshift({ name: focused, value: focused });
}
await interaction.respond(suggestions.slice(0, 25));
}
async function handleStats(interaction, _deps) {
const StaffAction = (_deps && _deps.StaffAction) || mongoose.model('StaffAction');
const nowMs = (_deps && typeof _deps.now === 'function') ? _deps.now() : Date.now();
const adminIds = (_deps && _deps.adminIds != null) ? _deps.adminIds : CONFIG.STATS_ADMIN_IDS;
const memberUser = interaction.options.getUser('member');
const periodStr = interaction.options.getString('period');
const source = interaction.options.getString('source') || 'all';
if (memberUser && !adminIds.includes(interaction.user.id)) {
return interaction.reply({
content: 'You can only view your own stats.',
flags: MessageFlags.Ephemeral
});
}
const target = memberUser ? memberUser.id : interaction.user.id;
const period = parsePeriod(periodStr);
const cutoff = new Date(nowMs - period.durationMs);
let events;
try {
events = await StaffAction.find({
createdAt: { $gte: cutoff },
$or: [
{ staffId: target },
{ resolverId: target },
{ toId: target },
{ fromId: target }
]
}).lean();
} catch (err) {
console.error('handleStats query error:', err);
return interaction.reply({
content: 'Failed to load stats. Please try again.',
flags: MessageFlags.Ephemeral
}).catch(() => {});
}
const stats = shapeStats(events, target, source);
const targetName = memberUser ? memberUser.username : interaction.user.username;
const sourceLabel = source === 'all' ? 'all sources' : source;
const cweKeys = Object.keys(stats.claimsWhileEscalated).map(Number).sort((a, b) => a - b);
const cweText = cweKeys.length
? `\n↳ while escalated: ${cweKeys.map(k => `${tierLabel(k)}: ${stats.claimsWhileEscalated[k]}`).join(', ')}`
: '';
const embed = new EmbedBuilder()
.setTitle(`Stats — ${targetName}${period.label}`)
.setDescription(`Source: ${sourceLabel}`)
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields([
{
name: 'Claims',
value: `${stats.claims}${cweText}`,
inline: true
},
{
name: 'Closes',
value: `${stats.closes} (unclaimed: ${stats.unclaimedAtClose})`,
inline: true
},
{
name: 'Resolved (credit)',
value: `${stats.resolved}`,
inline: true
},
{
name: 'Escalations',
value: formatTierMap(stats.escalations),
inline: true
},
{
name: 'De-escalations',
value: formatTierMap(stats.deescalations),
inline: true
},
{
name: 'Transfers',
value: `In: ${stats.transfersIn} | Out: ${stats.transfersOut}`,
inline: true
},
{
name: 'Reopens',
value: `${stats.reopens}`,
inline: true
},
{
name: 'Email / Discord split',
value: [
`Email — claims: ${stats.bySource.email.claims}, closes: ${stats.bySource.email.closes}, resolved: ${stats.bySource.email.resolved}`,
`Discord — claims: ${stats.bySource.discord.claims}, closes: ${stats.bySource.discord.closes}, resolved: ${stats.bySource.discord.resolved}`
].join('\n'),
inline: false
}
]);
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
module.exports = { handleStats, handleStatsAutocomplete };

View File

@@ -3,25 +3,31 @@
*/ */
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, isStaff } = require('../utils'); const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { autoAdvanceFolder } = require('../services/gmailLabels');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
const { recordAction } = require('../services/staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
/** /**
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only). * Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
*/ */
async function handleDiscordReply(m) { async function handleDiscordReply(m, _TicketModel, _recordAction, _isStaff) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const checkIsStaff = _isStaff || isStaff;
if (m.author.bot || m.interaction) return; if (m.author.bot || m.interaction) return;
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); const ticket = await T.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return; if (!ticket) return;
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = isStaff(memberForCheck); const isStaffMember = checkIsStaff(memberForCheck);
Ticket.updateOne( T.updateOne(
{ discordThreadId: m.channel.id }, { discordThreadId: m.channel.id },
{ $set: { lastActivity: new Date() } } { $set: { lastActivity: new Date() } }
).catch(err => logError('updateActivity', err).catch(() => {})); ).catch(err => logError('updateActivity', err).catch(() => {}));
@@ -46,6 +52,10 @@ async function handleDiscordReply(m) {
} }
} }
if (isStaffMember) {
record(m.author.id, 'response', { ticket, guildId: m.guild?.id });
}
if (ticket.gmailThreadId.startsWith('discord-')) { if (ticket.gmailThreadId.startsWith('discord-')) {
return; return;
} }
@@ -77,6 +87,10 @@ async function handleDiscordReply(m) {
'Support'; 'Support';
const msgId = const msgId =
last.payload.headers.find(h => h.name === 'Message-ID')?.value; last.payload.headers.find(h => h.name === 'Message-ID')?.value;
const origDate =
last.payload.headers.find(h => h.name === 'Date')?.value || '';
const origFrom =
last.payload.headers.find(h => h.name === 'From')?.value || recipient;
const recipientEmail = extractRawEmail(recipient).toLowerCase(); const recipientEmail = extractRawEmail(recipient).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
@@ -84,14 +98,24 @@ async function handleDiscordReply(m) {
return; return;
} }
// Quote the customer's latest inbound message beneath the staff reply.
const quote = { from: origFrom, date: origDate, body: getCleanBody(last.payload) };
await sendGmailReply( await sendGmailReply(
ticket.gmailThreadId, ticket.gmailThreadId,
m.content, m.content,
recipientEmail, recipientEmail,
subject, subject,
msgId, msgId,
m.author.id m.author.id,
quote
); );
// Staff just replied to the customer → advance to Awaiting Reply (unless the
// thread is manually filed). Fire-and-forget: a label failure must not break
// the reply that already went out.
autoAdvanceFolder(ticket.gmailThreadId, 'AWAITING_REPLY')
.catch(err => logError('autoAdvanceFolder(AWAITING_REPLY)', err).catch(() => {}));
} catch (e) { } catch (e) {
console.error('REPLY ERROR:', e); console.error('REPLY ERROR:', e);
} }

View File

@@ -20,7 +20,9 @@ const ticketSchema = new mongoose.Schema({
claimerId: String, claimerId: String,
creatorId: String, creatorId: String,
parentCategoryId: String, parentCategoryId: String,
pendingDelete: { type: Boolean, default: false } pendingDelete: { type: Boolean, default: false },
game: String,
closedAt: Date
}); });
ticketSchema.index({ status: 1, lastActivity: 1 }); ticketSchema.index({ status: 1, lastActivity: 1 });
ticketSchema.index({ senderEmail: 1, status: 1 }); ticketSchema.index({ senderEmail: 1, status: 1 });
@@ -61,3 +63,29 @@ mongoose.model('StaffSignature', new mongoose.Schema({
tagline: { type: String, default: '' }, tagline: { type: String, default: '' },
updatedAt: { type: Date, default: Date.now } updatedAt: { type: Date, default: Date.now }
})); }));
const staffActionSchema = new mongoose.Schema({
staffId: { type: String, required: true },
type: { type: String, required: true },
tier: { type: Number, default: 0 },
ticketType: String,
priority: String,
game: String,
senderEmail: String,
creatorId: String,
gmailThreadId: String,
guildId: String,
createdAt: { type: Date, default: Date.now },
// close-only
closerType: String,
resolverId: String,
wasClaimed: Boolean,
// transfer-only
fromId: String,
toId: String
});
staffActionSchema.index({ staffId: 1, createdAt: -1 });
staffActionSchema.index({ gmailThreadId: 1, createdAt: 1 });
mongoose.model('StaffAction', staffActionSchema);

View File

@@ -25,7 +25,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff // Roles and staff
'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID', 'ADMIN_ID', 'STATS_ADMIN_IDS',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
// Messages and labels // Messages and labels
@@ -35,17 +35,17 @@ const ALLOWED_CONFIG_KEYS = new Set([
'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding // Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', 'LOGO_URL', 'SUPPORT_NAME', 'GAME_LIST',
// Toggles // Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'ALLOW_CLAIM_OVERWRITE', 'ALLOW_CLAIM_OVERWRITE', 'TRANSCRIPT_DM_TO_CREATOR',
'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds // Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'GMAIL_POLL_ENABLED',
// Embed colors // Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
@@ -163,6 +163,8 @@ function inferType(key) {
if (key.includes('COLOR')) return 'hex_color'; if (key.includes('COLOR')) return 'hex_color';
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it. // ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
if (key === 'ROLE_ID_TO_PING') return 'discord_id'; if (key === 'ROLE_ID_TO_PING') return 'discord_id';
// Boolean toggle whose name doesn't match the ENABLED/_ON pattern.
if (key === 'TRANSCRIPT_DM_TO_CREATOR') return 'boolean';
// 2. Name patterns // 2. Name patterns
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean'; if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';

View File

@@ -36,7 +36,7 @@ async function sendToChannel(channelId, embed, overrideClient) {
if (!c || !channelId) return; if (!c || !channelId) return;
try { try {
const channel = await c.channels.fetch(channelId); const channel = await c.channels.fetch(channelId);
if (channel) await channel.send({ embeds: [embed] }); if (channel) await channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
} catch (_) { } catch (_) {
// ignore send failures // ignore send failures
} }
@@ -59,7 +59,8 @@ async function logError(context, error, interaction = null, overrideClient = nul
const message = redactPII(error.message || String(error)); const message = redactPII(error.message || String(error));
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500); const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
await channel.send({ await channel.send({
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\`` content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``,
allowedMentions: { parse: [] }
}); });
} catch (_) { } catch (_) {
// ignore send failures // ignore send failures

View File

@@ -3,7 +3,7 @@
*/ */
const { google } = require('googleapis'); const { google } = require('googleapis');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, escapeHtml } = require('../utils'); const { extractRawEmail, escapeHtml, getCleanBody } = require('../utils');
const { getStaffSignatureBlocks } = require('./staffSignature'); const { getStaffSignatureBlocks } = require('./staffSignature');
const { logError } = require('./debugLog'); const { logError } = require('./debugLog');
const { readEnvFile } = require('./configPersistence'); const { readEnvFile } = require('./configPersistence');
@@ -23,7 +23,6 @@ function buildCompanySigHtml() {
Indifferent Broccoli Support<br> Indifferent Broccoli Support<br>
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br> <a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br> Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
<br>
<em>Host your own game server. Or not... we don't care.</em> <em>Host your own game server. Or not... we don't care.</em>
</td> </td>
</tr> </tr>
@@ -35,7 +34,6 @@ function buildCompanySigText() {
'Indifferent Broccoli Support', 'Indifferent Broccoli Support',
'https://indifferentbroccoli.com/', 'https://indifferentbroccoli.com/',
'Join us on Discord: https://discord.gg/2vmfrrtvJY', 'Join us on Discord: https://discord.gg/2vmfrrtvJY',
'',
"Host your own game server. Or not... we don't care." "Host your own game server. Or not... we don't care."
].join('\n'); ].join('\n');
} }
@@ -51,8 +49,10 @@ function getGmailClient() {
/** /**
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google. * Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
* Used by the internal /gmail/reload endpoint so the weekly reauth chore does * Used by the internal /gmail/reload endpoint so an occasional re-auth (the
* not require a full container restart. * OAuth app is published, so the token is long-lived — re-auth is only needed
* on revoke/password-change, not on a schedule) does not require a full
* container restart.
* *
* Throws if the env file is missing the token, or if the probe call (getProfile) * Throws if the env file is missing the token, or if the probe call (getProfile)
* fails — the caller surfaces the error so the UI can see why. * fails — the caller surfaces the error so the UI can see why.
@@ -96,23 +96,169 @@ function encodeReplySubject(baseSubject) {
} }
// Compose and send a multipart/alternative reply on an existing Gmail thread. // Compose and send a multipart/alternative reply on an existing Gmail thread.
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) { // Build the "On <date>, <sender> wrote:" attribution line for a quoted reply.
function formatQuoteAttribution(quote) {
const who = (quote.from || '').trim() || 'the sender';
const when = (quote.date || '').trim();
return when ? `On ${when}, ${who} wrote:` : `${who} wrote:`;
}
// Plain-text quoted block: attribution + each original line prefixed with "> ".
// Returns null when there is nothing to quote.
function buildQuoteText(quote) {
if (!quote || !(quote.body || '').trim()) return null;
const quoted = quote.body.replace(/\r\n/g, '\n').split('\n').map(l => `> ${l}`).join('\n');
return `${formatQuoteAttribution(quote)}\n${quoted}`;
}
// HTML quoted block. Mirrors Gmail's own reply markup (gmail_quote / gmail_attr
// classes + the standard blockquote styling) so receiving clients recognize it
// as quoted content and collapse it behind the "•••" toggle. Body is
// attacker-controlled email content — escapeHtml it.
function buildQuoteHtml(quote) {
if (!quote || !(quote.body || '').trim()) return '';
const attribution = escapeHtml(formatQuoteAttribution(quote));
const quotedHtml = escapeHtml(quote.body.replace(/\r\n/g, '\n')).replace(/\n/g, '<br>');
return `<div class="gmail_quote">` +
`<div dir="ltr" class="gmail_attr">${attribution}<br></div>` +
`<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex;">${quotedHtml}</blockquote>` +
`</div>`;
}
// Discord custom emoji token: <:name:id> (static) or <a:name:id> (animated).
const DISCORD_EMOJI_RE = /<(a?):(\w+):(\d+)>/g;
// Same token after escapeHtml has turned the angle brackets into entities.
const DISCORD_EMOJI_RE_ESCAPED = /&lt;(a?):(\w+):(\d+)&gt;/g;
// Plain-text: collapse a custom-emoji token to its :name: shortcode.
function discordEmojiToText(s) {
return (s || '').replace(DISCORD_EMOJI_RE, (_m, _anim, name) => `:${name}:`);
}
// Collect the distinct custom emoji referenced in a message.
function collectDiscordEmojis(s) {
const seen = new Map();
for (const m of (s || '').matchAll(DISCORD_EMOJI_RE)) {
const [, anim, name, id] = m;
if (!seen.has(id)) seen.set(id, { id, name, ext: anim ? 'gif' : 'png' });
}
return [...seen.values()];
}
// Fetch one emoji's bytes from Discord's CDN for inline (cid:) embedding.
// Returns null on any failure so the caller can fall back to a remote <img>.
async function fetchEmojiInline(emoji) {
try {
const res = await fetch(`https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.ext}`);
if (!res.ok) return null;
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
return { ...emoji, base64, cid: `emoji-${emoji.id}@broccolini` };
} catch {
return null;
}
}
// HTML: escape first (body is staff-authored but treated as untrusted), then
// swap the now-escaped emoji tokens for an inline <img>. Prefer a cid: reference
// (embedded part, always renders); fall back to Discord's CDN when not embedded.
// The id is digits-only and name is \w+, so neither can break out of the attribute.
function messageTextToHtml(s, cidById = {}) {
return escapeHtml(s || '')
.replace(DISCORD_EMOJI_RE_ESCAPED, (_m, anim, name, id) => {
const ext = anim ? 'gif' : 'png';
const src = cidById[id] ? `cid:${cidById[id]}` : `https://cdn.discordapp.com/emojis/${id}.${ext}`;
return `<img src="${src}" alt=":${name}:" ` +
`width="20" height="20" style="vertical-align: middle;">`;
})
.replace(/\n/g, '<br>');
}
// Strip Discord role mentions (<@&id>) — internal staff pings like @broccolini
// that mean nothing to an email recipient. Collapse the whitespace left behind.
function stripRoleMentions(s) {
return (s || '')
.replace(/<@&\d+>/g, '')
.replace(/[^\S\r\n]{2,}/g, ' ')
.replace(/[^\S\r\n]+\n/g, '\n')
.trim();
}
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId, quote = null }) {
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' }; const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : ''; const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = sigBlocks.text; const safeStaffSigText = sigBlocks.text;
const cleanText = stripRoleMentions(messageText);
// Embed any custom emoji inline (cid:) so they render without the recipient
// having to load remote images. Failed fetches fall back to a remote <img>.
const inlineEmojis = (await Promise.all(collectDiscordEmojis(cleanText).map(fetchEmojiInline))).filter(Boolean);
const cidById = {};
for (const e of inlineEmojis) cidById[e.id] = e.cid;
const quoteHtml = buildQuoteHtml(quote);
const htmlBody = ` const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;"> <div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p> <p>${messageTextToHtml(cleanText, cidById)}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''} ${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()} ${buildCompanySigHtml()}
${quoteHtml ? `<br><br>${quoteHtml}` : ''}
</div>`; </div>`;
const plainBody = [messageText || '']; const plainBody = [discordEmojiToText(cleanText)];
if (safeStaffSigText) plainBody.push('', safeStaffSigText); if (safeStaffSigText) plainBody.push('', safeStaffSigText);
plainBody.push('', ...buildCompanySigText().split('\n')); plainBody.push('', ...buildCompanySigText().split('\n'));
const quoteText = buildQuoteText(quote);
if (quoteText) plainBody.push('', '', quoteText);
const stamp = Date.now().toString(16);
const altBoundary = 'alt_' + stamp;
const altPart = [
`--${altBoundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
...plainBody,
'',
`--${altBoundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody,
'',
`--${altBoundary}--`
];
// With no inline images the message stays a plain multipart/alternative.
// With them, wrap the alternative + image parts in a multipart/related.
let topContentType;
let bodyLines;
if (inlineEmojis.length) {
const relBoundary = 'rel_' + stamp;
topContentType = `multipart/related; boundary="${relBoundary}"`;
bodyLines = [
`--${relBoundary}`,
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
'',
...altPart,
''
];
for (const e of inlineEmojis) {
bodyLines.push(
`--${relBoundary}`,
`Content-Type: image/${e.ext === 'gif' ? 'gif' : 'png'}`,
'Content-Transfer-Encoding: base64',
`Content-ID: <${e.cid}>`,
`Content-Disposition: inline; filename="${e.name}.${e.ext}"`,
'',
...(e.base64.match(/.{1,76}/g) || []),
''
);
}
bodyLines.push(`--${relBoundary}--`);
} else {
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
bodyLines = altPart;
}
const boundary = '000000000000' + Date.now().toString(16);
const headers = [ const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipient}`, `To: ${recipient}`,
@@ -120,24 +266,10 @@ async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, m
msgId && `In-Reply-To: ${msgId}`, msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`, msgId && `References: ${msgId}`,
'MIME-Version: 1.0', 'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"` `Content-Type: ${topContentType}`
].filter(Boolean); ].filter(Boolean);
const raw = Buffer.from([ const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
...headers,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
...plainBody,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody,
'',
`--${boundary}--`
].join('\r\n'))
.toString('base64') .toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -164,15 +296,20 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
const gmail = getGmailClient(); const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support'); const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; // Editable via TICKET_CLOSE_MESSAGE in .env. Supports a {closer_name}
// placeholder and \n for line breaks.
const messageText = (CONFIG.TICKET_CLOSE_MESSAGE || '')
.replace(/\\n/g, '\n')
.replace(/\{closer_name\}/g, closerName);
// Closing emails intentionally omit the staff signature (userId left out)
// — only the resolution message and the company signature go out.
await sendThreadedEmail(gmail, { await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId, threadId: ticket.gmailThreadId,
recipient, recipient,
encodedSubject, encodedSubject,
msgId, msgId,
messageText, messageText
userId
}); });
} catch (err) { } catch (err) {
console.error('Ticket closed email error:', err); console.error('Ticket closed email error:', err);
@@ -211,7 +348,7 @@ async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId * Send a Gmail reply on an existing thread. Caller supplies subject + messageId
* (typically pulled from the latest non-self message in the thread). * (typically pulled from the latest non-self message in the thread).
*/ */
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) { async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null, quote = null) {
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeRecipient)) { if (!EMAIL_RE.test(safeRecipient)) {
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
@@ -225,14 +362,246 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
encodedSubject: encodeReplySubject(subject || 'Support'), encodedSubject: encodeReplySubject(subject || 'Support'),
msgId: sanitizeHeaderValue(messageId) || null, msgId: sanitizeHeaderValue(messageId) || null,
messageText: replyText, messageText: replyText,
userId userId,
quote
}); });
} }
// Derive a name for an attachment part that has none — typically an embedded
// screenshot carried inline by Content-ID rather than as a named attachment.
// Uses the mime subtype as the extension so the file still opens correctly.
function synthAttachmentName(part, n) {
const subtype = String(part.mimeType || '').split('/')[1] || '';
const ext = (subtype.split(';')[0].replace(/[^a-z0-9]+/gi, '') || 'bin').toLowerCase();
const isImage = /^image\//i.test(part.mimeType || '');
return `${isImage ? 'screenshot' : 'attachment'}-${n}.${ext}`;
}
// Recursively collect downloadable parts (those backed by an attachmentId) from
// a Gmail message payload, at any nesting depth. Named parts are taken as-is;
// nameless non-text parts — embedded/inline screenshots referenced only by
// Content-ID — are kept with a synthesized name. Nameless text/* parts are
// skipped: Gmail serves a large email *body* as an attachmentId-backed text/html
// part with no filename, and that is the message, not an attachment.
function collectAttachmentParts(payload) {
const out = [];
const walk = part => {
if (!part) return;
const isText = /^text\//i.test(part.mimeType || '');
if (part.body?.attachmentId && (part.filename || !isText)) {
out.push({
filename: part.filename || synthAttachmentName(part, out.length + 1),
mimeType: part.mimeType || 'application/octet-stream',
attachmentId: part.body.attachmentId,
size: part.body.size || 0
});
}
if (part.parts) for (const p of part.parts) walk(p);
};
if (payload?.parts) for (const p of payload.parts) walk(p);
else walk(payload);
return out;
}
// Discord's default per-message upload ceiling is 25 MB for any guild (boosting
// raises it, but 25 MB is the universal floor). Parts above this are skipped
// rather than risking a failed send. Discord also caps a single message at 10
// files. Both are conservative so a normal customer attachment always lands.
const DISCORD_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
const DISCORD_MAX_FILES_PER_MESSAGE = 10;
// Strip CR/LF and surrounding whitespace from an attachment filename so it is
// safe to use as a Discord file name and inside a backticked status line.
function sanitizeAttachmentName(name) {
return String(name || '').replace(/[\r\n`]+/g, ' ').trim() || 'attachment';
}
/**
* Fetch a single Gmail message's downloadable attachments as discord.js file
* descriptors ({ name, attachment: Buffer }). Skips parts over Discord's size
* ceiling and caps at 10 files. Best-effort: an individual fetch failure is
* recorded in `skipped`, never thrown — attachment delivery must not break the
* ticket flow.
*
* @param {string} messageId - Gmail message id (email.data.id)
* @param {object} payload - email.data.payload
* @param {object} gmail - authenticated gmail client (getGmailClient())
* @returns {Promise<{ files: Array<{name: string, attachment: Buffer}>, skipped: string[] }>}
*/
async function fetchMessageAttachments(messageId, payload, gmail) {
const parts = collectAttachmentParts(payload);
const files = [];
const skipped = [];
for (const att of parts) {
const name = sanitizeAttachmentName(att.filename);
if (files.length >= DISCORD_MAX_FILES_PER_MESSAGE || (att.size || 0) > DISCORD_ATTACHMENT_MAX_BYTES) {
skipped.push(name);
continue;
}
try {
const res = await gmail.users.messages.attachments.get({
userId: 'me', messageId, id: att.attachmentId
});
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
const buf = Buffer.from(std, 'base64');
if (buf.length > DISCORD_ATTACHMENT_MAX_BYTES) {
skipped.push(name);
continue;
}
files.push({ name, attachment: buf });
} catch (_) {
skipped.push(name);
}
}
return { files, skipped };
}
// Forward an entire ticket thread to a third party as a BRAND-NEW email.
// The original customer is never looped in: To = target only, no Cc/Bcc, no
// threadId, no In-Reply-To/References. Returns counts for the confirmation reply.
const FORWARD_MAX_TOTAL_BYTES = 20 * 1024 * 1024; // ~20 MB attachment ceiling
const FORWARD_DIVIDER = '-'.repeat(40);
async function forwardThread(threadId, targetEmail, note = '') {
const safeTarget = sanitizeHeaderValue(extractRawEmail(targetEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeTarget)) {
const err = new Error(`Invalid forward recipient: ${safeTarget || '(empty)'}`);
err.code = 'EBADRECIPIENT';
throw err;
}
const gmail = getGmailClient();
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
const messages = thread.data.messages || [];
if (!messages.length) {
const err = new Error('Thread has no messages to forward.');
err.code = 'EEMPTY';
throw err;
}
const firstHeaders = messages[0]?.payload?.headers || [];
const baseSubject = firstHeaders.find(h => h.name === 'Subject')?.value || 'No subject';
const fwdSubject = sanitizeHeaderValue(`Fwd: ${String(baseSubject).replace(/^(?:\s*Fwd\s*:\s*)+/i, '')}`);
const encodedSubject = `=?utf-8?B?${Buffer.from(fwdSubject).toString('base64')}?=`;
const textBlocks = [];
const htmlBlocks = [];
const attachments = [];
let skipped = 0;
let totalBytes = 0;
for (const msg of messages) {
const h = msg.payload?.headers || [];
const from = h.find(x => x.name === 'From')?.value || 'Unknown';
const date = h.find(x => x.name === 'Date')?.value || '';
const body = (getCleanBody(msg.payload) || '').replace(/\r\n/g, '\n').trim();
textBlocks.push(`From: ${from}\nDate: ${date}\n\n${body}`);
htmlBlocks.push(
`<div style="margin-bottom:8px;color:#555;font-size:13px;">` +
`<strong>From:</strong> ${escapeHtml(from)}<br>` +
`<strong>Date:</strong> ${escapeHtml(date)}</div>` +
`<div>${escapeHtml(body).replace(/\n/g, '<br>')}</div>`
);
for (const att of collectAttachmentParts(msg.payload)) {
if (totalBytes + (att.size || 0) > FORWARD_MAX_TOTAL_BYTES) { skipped++; continue; }
try {
const res = await gmail.users.messages.attachments.get({
userId: 'me', messageId: msg.id, id: att.attachmentId
});
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
const buf = Buffer.from(std, 'base64');
totalBytes += buf.length;
attachments.push({
filename: sanitizeHeaderValue(att.filename).replace(/"/g, ''),
mimeType: att.mimeType,
base64: buf.toString('base64')
});
} catch (_) {
skipped++;
}
}
}
const transcriptText = textBlocks.join(`\n\n${FORWARD_DIVIDER}\n\n`);
const transcriptHtml = htmlBlocks.join('<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">');
const noteText = note ? `${note}\n\n${FORWARD_DIVIDER}\n\n` : '';
const noteHtml = note
? `<p>${escapeHtml(note).replace(/\n/g, '<br>')}</p><hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">`
: '';
const stamp = Date.now().toString(16);
const altBoundary = 'alt_' + stamp;
const altPart = [
`--${altBoundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
noteText + transcriptText,
'',
`--${altBoundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
`<div style="font-family: sans-serif; font-size: 14px; color: #333;">${noteHtml}${transcriptHtml}</div>`,
'',
`--${altBoundary}--`
];
let topContentType;
let bodyLines;
if (attachments.length) {
const mixBoundary = 'mix_' + stamp;
topContentType = `multipart/mixed; boundary="${mixBoundary}"`;
bodyLines = [
`--${mixBoundary}`,
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
'',
...altPart,
''
];
for (const a of attachments) {
bodyLines.push(
`--${mixBoundary}`,
`Content-Type: ${a.mimeType}; name="${a.filename}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${a.filename}"`,
'',
...(a.base64.match(/.{1,76}/g) || []),
''
);
}
bodyLines.push(`--${mixBoundary}--`);
} else {
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
bodyLines = altPart;
}
// Deliberately omit threadId / In-Reply-To / References so this is a fresh
// conversation to the target only — the original sender is never in the loop.
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeTarget}`,
`Subject: ${encodedSubject}`,
'MIME-Version: 1.0',
`Content-Type: ${topContentType}`
];
const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await gmail.users.messages.send({ userId: 'me', requestBody: { raw } });
return { messageCount: messages.length, attachmentCount: attachments.length, skipped };
}
module.exports = { module.exports = {
getGmailClient, getGmailClient,
reloadGmailClient, reloadGmailClient,
sendGmailReply, sendGmailReply,
sendTicketClosedEmail, sendTicketClosedEmail,
sendTicketNotificationEmail sendTicketNotificationEmail,
forwardThread,
collectAttachmentParts,
fetchMessageAttachments
}; };

206
services/gmailLabels.js Normal file
View File

@@ -0,0 +1,206 @@
/**
* Gmail "folder" routing — map a ticket's Gmail thread into a managed set of
* labels with exclusive-folder semantics.
*
* Gmail labels are additive; we synthesize folders by, on every move, adding the
* target label and removing every *other* managed label plus INBOX + UNREAD
* (removing an absent label is a no-op, so this is idempotent). "Spam" maps to
* the built-in system SPAM label, which is never created.
*
* Acyclic require graph: this module depends on services/gmail (getGmailClient);
* gmail.js does not depend back on this file.
*/
'use strict';
const { CONFIG } = require('../config');
const { getGmailClient } = require('./gmail');
// Logical folder key -> how to resolve its label. User folders read their display
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
const FOLDER_DEFS = {
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
AWAITING_REPLY: { configKey: 'GMAIL_LABEL_AWAITING_REPLY' },
NEEDS_RESPONSE: { configKey: 'GMAIL_LABEL_NEEDS_RESPONSE' },
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
DASHBOARD_ERRORS: { configKey: 'GMAIL_LABEL_DASHBOARD_ERRORS' },
PARTNERSHIP_OFFERS: { configKey: 'GMAIL_LABEL_PARTNERSHIP_OFFERS' },
SPAM: { system: 'SPAM' }
};
// User-managed folder keys (everything but the system SPAM label).
const MANAGED_USER_KEYS = Object.keys(FOLDER_DEFS).filter(k => !FOLDER_DEFS[k].system);
// Folders staff file into by hand. The reply-cycle auto-flow (autoAdvanceFolder)
// never moves a thread out of one of these — a customer reply to something filed
// under "For Jake" or "Spam" stays put. Everything else (Triage, Awaiting Reply,
// Needs Response, Escalated, Resolved) is auto-cycle eligible.
const MANUAL_KEYS = ['FOR_JAKE', 'SPAM', 'PARTNERSHIP_OFFERS', 'DASHBOARD_ERRORS'];
// Always stripped on a move so the thread leaves the inbox and is marked read.
const ALWAYS_REMOVE = ['INBOX', 'UNREAD'];
// Cache: Gmail label display name -> label ID. Populated lazily; cleared on a
// stale-label error so a label recreated in Gmail is re-resolved.
const labelIdByName = new Map();
/** Display name for a user folder key (null for the system SPAM label). */
function folderDisplayName(key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return null;
return CONFIG[def.configKey];
}
async function ensureLabelCache(gmail) {
if (labelIdByName.size > 0) return;
const res = await gmail.users.labels.list({ userId: 'me' });
for (const label of res.data.labels || []) {
labelIdByName.set(label.name, label.id);
}
}
/**
* Resolve a folder key to a Gmail label ID, creating a missing *user* label.
* SPAM short-circuits to the system id and is never created.
*/
async function resolveLabelId(gmail, key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return def.system;
const name = folderDisplayName(key);
await ensureLabelCache(gmail);
if (labelIdByName.has(name)) return labelIdByName.get(name);
const created = await gmail.users.labels.create({
userId: 'me',
requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' }
});
labelIdByName.set(name, created.data.id);
return created.data.id;
}
/**
* Pure: given the target key and a key->id map of every managed label, build the
* add/remove sets for an exclusive-folder move. The target label is added; every
* other managed label plus INBOX + UNREAD is removed.
*/
function computeLabelMutation(targetKey, idByKey) {
const targetId = idByKey[targetKey];
if (!targetId) throw new Error(`Missing resolved id for target folder: ${targetKey}`);
const removeLabelIds = [];
for (const key of Object.keys(idByKey)) {
if (key === targetKey) continue;
const id = idByKey[key];
if (id) removeLabelIds.push(id);
}
for (const sys of ALWAYS_REMOVE) removeLabelIds.push(sys);
return { addLabelIds: [targetId], removeLabelIds };
}
function isInvalidLabelError(err) {
const status = err && ((err.response && err.response.status) || err.code);
const msg = (err && err.message) || '';
return status === 400 || /invalid label|labelId not found/i.test(msg);
}
/**
* Move a Gmail thread into a managed folder with exclusive-folder semantics.
* Resolves (and creates) every managed label, then issues one threads.modify.
* On a stale cached label id (400 invalid label), clears the cache and retries
* once.
*
* @param {string} threadId Gmail thread id (ticket.gmailThreadId)
* @param {string} targetKey one of FOLDER_DEFS keys
* @param {object} [gmail] optional Gmail client (poll loop passes its own)
*/
async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient()) {
if (!threadId) throw new Error('moveThreadToFolder: threadId required');
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
const applyOnce = async () => {
const idByKey = {};
for (const key of Object.keys(FOLDER_DEFS)) {
idByKey[key] = await resolveLabelId(gmail, key);
}
const mutation = computeLabelMutation(targetKey, idByKey);
await gmail.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: mutation
});
};
try {
await applyOnce();
} catch (err) {
if (isInvalidLabelError(err)) {
labelIdByName.clear();
await applyOnce();
} else {
throw err;
}
}
}
/**
* Read-only: which managed folder does this thread currently sit in? Returns a
* FOLDER_DEFS key, or null if none of the managed labels are present.
*
* Unlike resolveLabelId this never *creates* a label — a label that doesn't exist
* yet can't be on the thread, so it's simply skipped.
*/
async function getManagedFolderKey(threadId, gmail = getGmailClient()) {
if (!threadId) throw new Error('getManagedFolderKey: threadId required');
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'minimal' });
const threadLabelIds = new Set();
for (const m of thread.data.messages || []) {
for (const id of m.labelIds || []) threadLabelIds.add(id);
}
await ensureLabelCache(gmail);
for (const key of Object.keys(FOLDER_DEFS)) {
const def = FOLDER_DEFS[key];
const id = def.system ? def.system : labelIdByName.get(folderDisplayName(key));
if (id && threadLabelIds.has(id)) return key;
}
return null;
}
/**
* Advance a thread to `targetKey` as part of the reply cycle — UNLESS it is
* currently filed in a manual folder (MANUAL_KEYS), in which case it is left
* untouched. Threads with no managed label (or an auto-cycle label) advance.
*
* @returns {Promise<boolean>} true if the thread was moved, false if left in place.
*/
async function autoAdvanceFolder(threadId, targetKey, gmail = getGmailClient()) {
if (!threadId) throw new Error('autoAdvanceFolder: threadId required');
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
const current = await getManagedFolderKey(threadId, gmail);
if (current && MANUAL_KEYS.includes(current)) return false;
await moveThreadToFolder(threadId, targetKey, gmail);
return true;
}
module.exports = {
FOLDER_DEFS,
MANAGED_USER_KEYS,
MANUAL_KEYS,
ALWAYS_REMOVE,
folderDisplayName,
resolveLabelId,
computeLabelMutation,
moveThreadToFolder,
getManagedFolderKey,
autoAdvanceFolder,
// test seam: clear the name->id cache between cases
__clearLabelCache: () => labelIdByName.clear()
};

54
services/staffStats.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const mongoose = require('mongoose');
const { logError } = require('./debugLog');
// Derives ticketType from gmailThreadId prefix.
// discord-* and discord-msg-* → 'discord'; everything else → 'email'.
function deriveTicketType(gmailThreadId) {
if (!gmailThreadId) return 'email';
if (gmailThreadId.startsWith('discord-')) return 'discord';
return 'email';
}
// Extracts the standard event fields from a ticket document.
// Returns a plain object — does NOT include guildId (call-site only).
function denormalizeTicket(ticket) {
if (!ticket) return {};
return {
ticketType: deriveTicketType(ticket.gmailThreadId),
tier: ticket.escalationTier ?? 0,
priority: ticket.priority,
game: ticket.game,
senderEmail: ticket.senderEmail,
creatorId: ticket.creatorId,
gmailThreadId: ticket.gmailThreadId
};
}
// recordAction(staffId, type, payload)
//
// payload may carry:
// ticket — a Ticket doc to denormalize standard fields from
// guildId — must come from the call site (not on the Ticket schema)
// any other StaffAction field — these override denormalized values
//
// Fire-and-forget: never throws, never blocks the caller.
// The outer try/catch catches synchronous errors (e.g. model not registered
// during early boot); the inner .catch handles async DB rejections.
function recordAction(staffId, type, payload) {
try {
const { ticket, ...overrides } = payload || {};
const base = denormalizeTicket(ticket);
const doc = { staffId, type, ...base, ...overrides };
const StaffAction = mongoose.model('StaffAction');
StaffAction.create(doc).catch(err => {
logError('staffStats.recordAction', err);
});
} catch (err) {
logError('staffStats.recordAction', err);
}
}
module.exports = { recordAction, denormalizeTicket, deriveTicketType };

154
services/statsShaping.js Normal file
View File

@@ -0,0 +1,154 @@
'use strict';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Months = 30 days, years = 365 days — fixed-day approximation for windowing only.
const MS = {
days: MS_PER_DAY,
weeks: 7 * MS_PER_DAY,
months: 30 * MS_PER_DAY,
years: 365 * MS_PER_DAY
};
const DEFAULT_PERIOD = Object.freeze({ durationMs: 30 * MS_PER_DAY, value: 30, unit: 'days', label: '30 days' });
/**
* parsePeriod(input) → { durationMs, value, unit, label }
*
* Accepts the autocomplete presets ("7 days", "30 days", "3 months", "6 months",
* "1 year") and free text: <n>d / day(s), <n>w / week(s), <n>m / mo / month(s),
* <n>y / year(s), or a bare integer (= days). Case- and whitespace-tolerant.
* Unparseable or zero input returns the 30-day default.
*
* The caller computes the cutoff as: cutoff = Date.now() - durationMs
* Month = 30 days, year = 365 days (windowing approximation, not calendar-accurate).
*/
function parsePeriod(input) {
if (input == null) return Object.assign({}, DEFAULT_PERIOD);
const s = String(input).trim().toLowerCase();
if (!s) return Object.assign({}, DEFAULT_PERIOD);
const match = s.match(/^(\d+)\s*(d|day|days|w|week|weeks|m|mo|month|months|y|year|years)?$/);
if (!match) return Object.assign({}, DEFAULT_PERIOD);
const n = parseInt(match[1], 10);
if (!n) return Object.assign({}, DEFAULT_PERIOD);
const unitStr = match[2];
let unit, durationMs;
if (!unitStr || unitStr === 'd' || unitStr === 'day' || unitStr === 'days') {
unit = 'days';
durationMs = n * MS.days;
} else if (unitStr === 'w' || unitStr === 'week' || unitStr === 'weeks') {
unit = 'weeks';
durationMs = n * MS.weeks;
} else if (unitStr === 'm' || unitStr === 'mo' || unitStr === 'month' || unitStr === 'months') {
unit = 'months';
durationMs = n * MS.months;
} else if (unitStr === 'y' || unitStr === 'year' || unitStr === 'years') {
unit = 'years';
durationMs = n * MS.years;
} else {
return Object.assign({}, DEFAULT_PERIOD);
}
const singular = unit.slice(0, -1);
const label = n === 1 ? `1 ${singular}` : `${n} ${unit}`;
return { durationMs, value: n, unit, label };
}
/**
* shapeStats(events, memberId, source) → counts object
*
* Pure aggregator over an array of StaffAction-shaped objects.
* source: 'all' | 'email' | 'discord' (default: 'all')
*
* Field keying:
* claims type 'claim', staffId === member
* claimsWhileEscalated above, tier > 0, grouped by numeric tier key
* closes type 'close', staffId === member
* resolved type 'close', resolverId === member (claimer credit)
* unclaimedAtClose type 'close', staffId === member, wasClaimed === false
* escalations type 'escalate', staffId === member, grouped by tier
* deescalations type 'deescalate', staffId === member, grouped by tier
* transfersIn type 'transfer', toId === member
* transfersOut type 'transfer', staffId === member (initiator)
* reopens type 'reopen', resolverId === member
*
* Tier labels (tier 1 → "Tier 2", tier 2 → "Tier 3") are NOT applied here;
* Phase 10 maps numeric tier keys to display labels.
*
* bySource breaks headline counts (claims, closes, resolved) by ticketType.
*/
function shapeStats(events, memberId, source) {
const src = source || 'all';
const pool = src === 'all'
? (events || [])
: (events || []).filter(e => e.ticketType === src);
const result = {
claims: 0,
claimsWhileEscalated: {},
closes: 0,
resolved: 0,
unclaimedAtClose: 0,
escalations: {},
deescalations: {},
transfersIn: 0,
transfersOut: 0,
reopens: 0,
bySource: {
email: { claims: 0, closes: 0, resolved: 0 },
discord: { claims: 0, closes: 0, resolved: 0 }
}
};
for (const e of pool) {
const tt = e.ticketType === 'discord' ? 'discord' : 'email';
if (e.type === 'claim' && e.staffId === memberId) {
result.claims++;
result.bySource[tt].claims++;
if (e.tier > 0) {
result.claimsWhileEscalated[e.tier] = (result.claimsWhileEscalated[e.tier] || 0) + 1;
}
}
if (e.type === 'close' && e.staffId === memberId) {
result.closes++;
result.bySource[tt].closes++;
if (e.wasClaimed === false) {
result.unclaimedAtClose++;
}
}
if (e.type === 'close' && e.resolverId === memberId) {
result.resolved++;
result.bySource[tt].resolved++;
}
if (e.type === 'escalate' && e.staffId === memberId) {
result.escalations[e.tier] = (result.escalations[e.tier] || 0) + 1;
}
if (e.type === 'deescalate' && e.staffId === memberId) {
result.deescalations[e.tier] = (result.deescalations[e.tier] || 0) + 1;
}
if (e.type === 'transfer' && e.toId === memberId) {
result.transfersIn++;
}
if (e.type === 'transfer' && e.staffId === memberId) {
result.transfersOut++;
}
if (e.type === 'reopen' && e.resolverId === memberId) {
result.reopens++;
}
}
return result;
}
module.exports = { parsePeriod, shapeStats };

View File

@@ -6,6 +6,7 @@ const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { enqueueSend, enqueueDelete } = require('./channelQueue'); const { enqueueSend, enqueueDelete } = require('./channelQueue');
const { recordAction } = require('./staffStats');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter'); const TicketCounter = mongoose.model('TicketCounter');
@@ -269,15 +270,69 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; return { ok: true };
} }
// --- CLOSE TRANSITION ---
/**
* Atomic conditional close: updates the ticket only when status is 'open'.
* Sets status:'closed', closedAt, and any caller-supplied extra $set/$unset
* fields in ONE update so all side-writes land atomically.
* Returns { transitioned: true, ticket } when an open ticket was just closed,
* { transitioned: false, ticket: null } when the ticket was already closed
* (modifiedCount was 0 — the status filter did not match).
*/
async function attemptCloseTransition(gmailThreadId, extraSet = {}, extraUnset = {}, _TicketModel) {
const T = _TicketModel || Ticket;
const closedAt = new Date();
const update = { $set: { status: 'closed', closedAt, ...extraSet } };
if (Object.keys(extraUnset).length > 0) {
update.$unset = extraUnset;
}
const result = await T.updateOne({ gmailThreadId, status: 'open' }, update);
const transitioned = result.modifiedCount === 1;
const ticket = transitioned ? await T.findOne({ gmailThreadId }).lean() : null;
return { transitioned, ticket };
}
/**
* Schedule the final ticket-channel delete after a short grace period (so staff
* read the close message first), routed through the channel queue.
*
* The delete is guarded by the `pendingDelete` flag: the caller MUST have already
* set `pendingDelete: true` on the ticket AND left `discordThreadId` populated, so
* that a restart during the grace window is recovered on boot by
* resumePendingDeletes() (which re-fetches the channel and deletes it). The flag
* is cleared once enqueueDelete resolves; if the doc is gone the unset is a no-op.
*
* Shared by all three close paths (auto-close, button, slash) so they behave
* identically and none can orphan a channel on a mid-close restart.
*/
function scheduleTicketChannelDelete(channel, gmailThreadId, delayMs = 5000) {
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe at call time.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, delayMs));
}
// --- SCHEDULED CHECKS --- // --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
async function checkAutoClose(client, sendTicketClosedEmail) { async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _recordAction, _deps) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return; const cfg = (_deps && _deps.config) || CONFIG;
if (!cfg.AUTO_CLOSE_ENABLED) return;
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const _withRetry = (_deps && _deps.withRetry) || withRetry;
const _enqueueSend = (_deps && _deps.enqueueSend) || enqueueSend;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); const cutoffTime = new Date(Date.now() - (cfg.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs. // Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({ const staleTickets = await _withRetry(() => T.find({
status: 'open', status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
@@ -289,28 +344,30 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
try { try {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await _enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be // Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete // resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op. // resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne( const { transitioned: autoTransitioned, ticket: autoClosedTicket } =
{ gmailThreadId: ticket.gmailThreadId }, await _withRetry(() => attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, {}, T));
{ $set: { status: 'closed', pendingDelete: true } } if (autoTransitioned) {
)); record('system', 'close', {
ticket: autoClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: autoClosedTicket.claimerId ?? null,
wasClaimed: Boolean(autoClosedTicket.claimerId)
});
}
await sendTicketClosedEmail(ticket, 'Auto-Close System', null); await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe. if (_deps && _deps.scheduleDelete) {
const { trackTimeout } = require('../broccolini-discord'); _deps.scheduleDelete(channel, ticket);
trackTimeout(setTimeout(() => { } else {
enqueueDelete(channel).then(() => { scheduleTicketChannelDelete(channel, ticket.gmailThreadId);
withRetry(() => Ticket.updateOne( }
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000));
} }
} catch (error) { } catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
@@ -352,12 +409,14 @@ async function checkAutoUnclaim(client) {
} }
} }
async function reconcileDeletedTicketChannels(client) { async function reconcileDeletedTicketChannels(client, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return; if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs. // Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({ const openTickets = await T.find({
status: 'open', status: 'open',
discordThreadId: { $ne: null } discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
@@ -369,10 +428,17 @@ async function reconcileDeletedTicketChannels(client) {
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
} }
if (!channel) { if (!channel) {
await Ticket.updateOne( const { transitioned: reconTransitioned, ticket: reconClosedTicket } =
{ gmailThreadId: ticket.gmailThreadId }, await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, {}, T);
{ $set: { status: 'closed', discordThreadId: null } } if (reconTransitioned) {
); record('system', 'close', {
ticket: reconClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: reconClosedTicket.claimerId ?? null,
wasClaimed: Boolean(reconClosedTicket.claimerId)
});
}
} }
} catch (err) { } catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
@@ -417,6 +483,8 @@ module.exports = {
makeTicketName, makeTicketName,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
checkTicketLimits, checkTicketLimits,
attemptCloseTransition,
scheduleTicketChannelDelete,
checkAutoClose, checkAutoClose,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,

View File

@@ -0,0 +1,66 @@
/**
* Phase 9 — command-aware autocomplete dispatcher tests.
*
* Uses the injectable _handlers parameter to test the dispatch seam without
* requiring real Tag/Ticket DB access.
*
* Covers:
* (a) commandName 'response' → response handler called
* (b) unknown commandName → no handler called (no-op)
* (c) handler receives the interaction object unchanged
*/
import { describe, it, expect, vi } from 'vitest';
import { handleAutocomplete } from '../handlers/commands/index.js';
function makeInteraction(commandName) {
return {
commandName,
options: {
getSubcommand: vi.fn().mockReturnValue('send'),
getFocused: vi.fn().mockReturnValue('')
},
respond: vi.fn().mockResolvedValue(undefined)
};
}
describe('autocomplete dispatcher', () => {
it('routes commandName "response" to the response handler', async () => {
const responseHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('response');
await handleAutocomplete(interaction, { response: responseHandler });
expect(responseHandler).toHaveBeenCalledTimes(1);
expect(responseHandler).toHaveBeenCalledWith(interaction);
});
it('routes commandName "stats" to the stats handler', async () => {
const statsHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('stats');
await handleAutocomplete(interaction, { stats: statsHandler });
expect(statsHandler).toHaveBeenCalledTimes(1);
expect(statsHandler).toHaveBeenCalledWith(interaction);
});
it('no-ops for an unknown commandName', async () => {
const responseHandler = vi.fn().mockResolvedValue(undefined);
const interaction = makeInteraction('unknown-command');
await handleAutocomplete(interaction, { response: responseHandler });
expect(responseHandler).not.toHaveBeenCalled();
});
it('passes the interaction object through to the handler unchanged', async () => {
let received = null;
const handler = vi.fn().mockImplementation(async i => { received = i; });
const interaction = makeInteraction('response');
await handleAutocomplete(interaction, { response: handler });
expect(received).toBe(interaction);
});
});

171
tests/claimEvents.test.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* Phase 5a — claim event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) fresh claim — modifiedCount 1 → exactly one 'claim' event
* (b) no-op re-claim — modifiedCount 0 (same user) → no event
* (c) conditional filter — filter must exclude tickets already claimed by actor
* (d) tier captured — escalationTier from ticket at claim time
*/
import { describe, it, expect, vi } from 'vitest';
import { applyClaim } from '../handlers/buttons.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001' },
channel: { id: 'chan-001' },
update: vi.fn().mockResolvedValue(undefined),
followUp: vi.fn().mockResolvedValue(undefined)
};
}
function makeGuild() {
return {
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
claimedBy: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
...overrides
};
}
function makeBtn() {
const btn = {};
const chain = () => btn;
btn.setCustomId = chain;
btn.setLabel = chain;
btn.setEmoji = chain;
btn.setStyle = chain;
btn.setDisabled = chain;
return btn;
}
// ---------------------------------------------------------------------------
// (a) Fresh claim — real transition
// ---------------------------------------------------------------------------
describe('applyClaim — fresh claim emits one event', () => {
it('emits exactly one "claim" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('claim');
});
it('passes the ticket doc so the recorder can denormalize fields', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (b) No-op re-claim — same user double-click, modifiedCount 0
// ---------------------------------------------------------------------------
describe('applyClaim — no-op re-claim emits no event', () => {
it('emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: 'staff-001' });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (c) Conditional filter — DB write must exclude same-user claims
// ---------------------------------------------------------------------------
describe('applyClaim — conditional filter', () => {
it('includes claimerId $ne the acting user in the updateOne filter', async () => {
const ticket = makeTicket({ claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
claimerId: { $ne: 'staff-001' }
});
});
});
// ---------------------------------------------------------------------------
// (d) Tier captured at claim time
// ---------------------------------------------------------------------------
describe('applyClaim — tier captured at claim time', () => {
it('passes the ticket with escalationTier=1 when the ticket is escalated', async () => {
const ticket = makeTicket({ escalationTier: 1, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
});
it('passes the ticket with escalationTier=0 for a non-escalated ticket', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(0);
});
});

310
tests/closeEvents.test.js Normal file
View File

@@ -0,0 +1,310 @@
/**
* Phase 4 — close event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne / findOne / find)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) staff force-close — finalizeForceClose, closerId present in pendingCloses
* (b) system auto-close — reconcileDeletedTicketChannels, channel absent
* (c) no-op close — transitioned=false → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { finalizeForceClose } from '../handlers/commands/close.js';
import { reconcileDeletedTicketChannels, checkAutoClose } from '../services/tickets.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeOpenTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
discordThreadId: 'chan-test-001',
claimerId: 'claimer-001',
claimedBy: 'ClaimerName',
status: 'open',
createdAt: new Date('2026-01-01'),
escalationTier: 0,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeClosedTicket(openTicket) {
return { ...openTicket, status: 'closed', closedAt: new Date() };
}
/**
* Minimal mock model for reconcile tests (only needs find / updateOne / findOne).
*
* @param {object[]} openTickets — rows returned by find()
* @param {object} closedTicket — doc returned by findOne after transition
* @param {number} modifiedCount — 1 = transition succeeded, 0 = no-op
*/
function makeReconcileModel(openTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(openTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
/**
* Minimal mock model for finalizeForceClose.
* findOne is called twice:
* 1st — freshTicket lookup in finalizeForceClose itself
* 2nd — post-transition fetch inside attemptCloseTransition
*/
function makeForceCloseModel(freshTicket, closedTicket, modifiedCount = 1) {
return {
find: vi.fn(),
findOne: vi.fn()
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(freshTicket) })
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
function makeGuild(id = 'guild-001') {
return {
id,
channels: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockResolvedValue(null)
}
};
}
function makeClient(guild) {
return {
guilds: {
cache: {
get: vi.fn().mockReturnValue(null),
first: vi.fn().mockReturnValue(guild)
}
}
};
}
/** Channel mock with send() so enqueueSend doesn't reject immediately. */
function makeChannelRef(id = 'chan-test-001', guildId = 'guild-001') {
return {
id,
name: `ticket-${id}`,
guild: { id: guildId },
send: vi.fn().mockResolvedValue({ id: 'sent-msg' }),
delete: vi.fn().mockResolvedValue(undefined),
messages: undefined // triggers transcript error (caught internally)
};
}
function makeClientRef() {
return {
channels: { fetch: vi.fn().mockResolvedValue(null) }
};
}
/**
* Minimal mock model for checkAutoClose tests.
* Mirrors makeReconcileModel — same shape, renamed for clarity.
*/
function makeAutoCloseModel(staleTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(staleTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
// ===========================================================================
// (a) Staff force-close
// ===========================================================================
describe('finalizeForceClose — staff close', () => {
it('emits one "close" event with closerType "staff", correct staffId / resolverId / wasClaimed', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-001', discordThreadId: 'chan-staff-001' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map([['chan-staff-001', { closerId: 'staff-user-001', username: 'Staff#0001' }]]);
await finalizeForceClose(makeChannelRef('chan-staff-001', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-user-001');
expect(type).toBe('close');
expect(payload.closerType).toBe('staff');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('uses closerType "system" and staffId "system" when no closerId in pendingCloses', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-002', discordThreadId: 'chan-sys-002' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map(); // no entry for this channel
await finalizeForceClose(makeChannelRef('chan-sys-002', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, , payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(payload.closerType).toBe('system');
});
});
// ===========================================================================
// (b) System auto-close: reconcileDeletedTicketChannels
// ===========================================================================
describe('reconcileDeletedTicketChannels — system close', () => {
it('emits one "close" event with closerType "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('reflects wasClaimed=false and resolverId=null for an unclaimed ticket', async () => {
const open = makeOpenTicket({ claimerId: null, claimedBy: null });
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBeNull();
expect(payload.wasClaimed).toBe(false);
});
});
// ===========================================================================
// (c) No-op close — idempotency
// ===========================================================================
describe('no-op close — idempotency', () => {
it('emits no event when the ticket channel still exists (reconcile skips close path)', async () => {
const open = makeOpenTicket();
const model = {
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue([open]) }),
findOne: vi.fn(),
updateOne: vi.fn()
};
const mockRecord = vi.fn();
const guild = {
id: 'guild-001',
channels: {
cache: { get: vi.fn().mockReturnValue({ id: open.discordThreadId }) }, // channel IS present
fetch: vi.fn()
}
};
await reconcileDeletedTicketChannels(makeClient(guild), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
expect(model.updateOne).not.toHaveBeenCalled();
});
it('emits no event when attemptCloseTransition reports transitioned=false (ticket was already closed)', async () => {
const open = makeOpenTicket();
const model = makeReconcileModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ===========================================================================
// (d) System auto-close: checkAutoClose
// ===========================================================================
const TEST_CONFIG = { AUTO_CLOSE_ENABLED: true, AUTO_CLOSE_AFTER_HOURS: 72, DISCORD_AUTO_CLOSE_MESSAGE: 'closing' };
describe('checkAutoClose — system close', () => {
it('emits one "close" event with closerType "system", staffId "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeAutoCloseModel([open], closed, 1);
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('emits no event when attemptCloseTransition reports transitioned=false', async () => {
const open = makeOpenTicket();
const model = makeAutoCloseModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Import the real module — no module-level mocks needed.
// attemptCloseTransition accepts an optional 4th arg (_TicketModel) so tests
// can inject a mock without mocking the whole db-connection chain.
import { attemptCloseTransition } from '../services/tickets.js';
describe('attemptCloseTransition', () => {
let mockUpdateOne, mockFindOne, mockTicket;
beforeEach(() => {
mockUpdateOne = vi.fn();
mockFindOne = vi.fn();
mockTicket = { updateOne: mockUpdateOne, findOne: mockFindOne };
});
it('returns transitioned=true and the fetched ticket when an open ticket is closed', async () => {
const closedTicket = { gmailThreadId: 'thread-open', status: 'closed', closedAt: new Date() };
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) });
const result = await attemptCloseTransition('thread-open', {}, {}, mockTicket);
expect(result.transitioned).toBe(true);
expect(result.ticket).toBe(closedTicket);
});
it('gates the update on status:"open" so only open tickets are closed', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ gmailThreadId: 'thread-open', status: 'open' },
expect.anything()
);
});
it('includes a closedAt Date in the $set', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$set.closedAt).toBeInstanceOf(Date);
});
it('returns transitioned=false and null ticket when the ticket is already closed', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
const result = await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
expect(result.transitioned).toBe(false);
expect(result.ticket).toBeNull();
});
it('does not call findOne when no transition occurred', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
expect(mockFindOne).not.toHaveBeenCalled();
});
it('is a no-op on a second call — idempotency seam later phases rely on', async () => {
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 });
mockFindOne.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue({ gmailThreadId: 'thread-x' }) });
const first = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
expect(first.transitioned).toBe(true);
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 0 });
const second = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
expect(second.transitioned).toBe(false);
expect(second.ticket).toBeNull();
});
it('folds extraSet fields into the $set alongside status and closedAt', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', { discordThreadId: null, pendingDelete: true }, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$set.status).toBe('closed');
expect(update.$set.discordThreadId).toBeNull();
expect(update.$set.pendingDelete).toBe(true);
});
it('includes $unset in the update when extraUnset is non-empty', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', {}, { welcomeMessageId: '' }, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$unset).toEqual({ welcomeMessageId: '' });
});
it('omits $unset from the update when extraUnset is empty', async () => {
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
await attemptCloseTransition('thread-x', {}, {}, mockTicket);
const [, update] = mockUpdateOne.mock.calls[0];
expect(update.$unset).toBeUndefined();
});
});

View File

@@ -19,6 +19,10 @@ describe('ALLOWED_CONFIG_KEYS', () => {
} }
}); });
it('includes STATS_ADMIN_IDS', () => {
expect(ALLOWED_CONFIG_KEYS.has('STATS_ADMIN_IDS')).toBe(true);
});
it('does not contain stale removed keys', () => { it('does not contain stale removed keys', () => {
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) { for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false); expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
@@ -192,9 +196,8 @@ describe('discord_id validator', () => {
describe('discord_id_list validator', () => { describe('discord_id_list validator', () => {
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it // ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so // hits the string fallback. discord_id_list only fires for `*_IDS` keys.
// exercise it with a hypothetical name. const v = getValidator('STATS_ADMIN_IDS');
const v = getValidator('STAFF_USER_IDS');
it('infers type discord_id_list for *_IDS keys', () => { it('infers type discord_id_list for *_IDS keys', () => {
expect(v.type).toBe('discord_id_list'); expect(v.type).toBe('discord_id_list');
@@ -221,6 +224,40 @@ describe('discord_id_list validator', () => {
}); });
}); });
describe('STATS_ADMIN_IDS parsing (config.js pattern)', () => {
// Tests the .split(',').map(r=>r.trim()).filter(Boolean) idiom used for all
// list env vars in config.js — exercised here as a pure expression.
function parseIdList(v) {
return (v || '').split(',').map(r => r.trim()).filter(Boolean);
}
it('returns [] for empty string', () => {
expect(parseIdList('')).toEqual([]);
});
it('returns [] for undefined', () => {
expect(parseIdList(undefined)).toEqual([]);
});
it('returns a single-element array for one ID', () => {
expect(parseIdList('321754640431710226')).toEqual(['321754640431710226']);
});
it('returns multiple IDs for comma-separated input', () => {
expect(parseIdList('321754640431710226,691678135527276614,224692549225283584'))
.toEqual(['321754640431710226', '691678135527276614', '224692549225283584']);
});
it('trims whitespace around each ID', () => {
expect(parseIdList(' 321754640431710226 , 691678135527276614 '))
.toEqual(['321754640431710226', '691678135527276614']);
});
it('drops empty segments from trailing commas', () => {
expect(parseIdList('321754640431710226,')).toEqual(['321754640431710226']);
});
});
describe('string validator (fallback)', () => { describe('string validator (fallback)', () => {
const v = getValidator('TICKET_CATEGORY_NAME'); const v = getValidator('TICKET_CATEGORY_NAME');

View File

@@ -0,0 +1,188 @@
/**
* Phase 5b — escalate / de-escalate event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) escalate real — modifiedCount 1 → one 'escalate' event with new tier
* (b) deescalate real — modifiedCount 1 → one 'deescalate' event with new tier
* (c) no-op write — modifiedCount 0 → no event for either direction
*/
import { describe, it, expect, vi } from 'vitest';
import { runEscalation, runDeescalation } from '../handlers/commands/escalation.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
escalated: false,
claimerId: 'claimer-001',
claimedBy: 'ClaimerName',
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
welcomeMessageId: null,
...overrides
};
}
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
tag: 'staffuser#0001',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: {
id: 'guild-001',
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
},
channel: {
id: 'chan-001',
name: 'ticket-chan-001',
isThread: vi.fn().mockReturnValue(true),
send: vi.fn().mockResolvedValue({ id: 'sent-msg-001' })
},
editReply: vi.fn().mockResolvedValue(undefined),
client: {
channels: { fetch: vi.fn().mockResolvedValue(null) }
}
};
}
// ---------------------------------------------------------------------------
// (a) Real escalate — modifiedCount 1
// ---------------------------------------------------------------------------
describe('runEscalation — real escalate emits one event', () => {
it('emits exactly one "escalate" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('escalate');
});
it('passes the ticket with the new tier', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
expect(payload.guildId).toBe('guild-001');
});
it('uses conditional filter escalationTier $ne nextTier', async () => {
const ticket = makeTicket({ escalationTier: 0 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
escalationTier: { $ne: 1 }
});
});
});
// ---------------------------------------------------------------------------
// (b) Real deescalate — modifiedCount 1
// ---------------------------------------------------------------------------
describe('runDeescalation — real deescalate emits one event', () => {
it('emits exactly one "deescalate" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('deescalate');
});
it('passes the ticket with the new (lower) tier', async () => {
const ticket = makeTicket({ escalationTier: 2, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
expect(payload.guildId).toBe('guild-001');
});
it('uses conditional filter escalationTier $ne newTier', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
escalationTier: { $ne: 0 }
});
});
});
// ---------------------------------------------------------------------------
// (c) No-op write — modifiedCount 0 → no event for either direction
// ---------------------------------------------------------------------------
describe('no-op tier write emits no event', () => {
it('escalate: emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 1 });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
it('deescalate: emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 1, escalated: true });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,135 @@
/**
* fetchMessageAttachments — Gmail attachment → discord.js file descriptor tests.
*
* Uses a fake gmail client (no module mocking) so we exercise the real
* collectAttachmentParts walk, the base64url→Buffer decode, the size ceiling,
* the 10-file cap, and the best-effort skip-on-failure behavior.
*/
import { describe, it, expect, vi } from 'vitest';
import { collectAttachmentParts, fetchMessageAttachments } from '../services/gmail.js';
// Build a payload part carrying a real attachment (filename + attachmentId).
function attPart(filename, attachmentId, size, mimeType = 'image/png') {
return { filename, mimeType, body: { attachmentId, size } };
}
// Fake gmail whose attachments.get returns base64url of the given string per id.
function fakeGmail(dataById) {
return {
users: {
messages: {
attachments: {
get: vi.fn(async ({ id }) => {
if (!(id in dataById)) throw new Error('not found');
const b64url = Buffer.from(dataById[id]).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { data: { data: b64url } };
})
}
}
}
};
}
describe('collectAttachmentParts', () => {
it('finds attachment parts at any nesting depth, skips inline text', () => {
const payload = {
parts: [
{ mimeType: 'text/plain', body: { data: 'aGk=' } },
{ mimeType: 'multipart/mixed', parts: [attPart('log.txt', 'A1', 12)] }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'log.txt', attachmentId: 'A1', size: 12 });
});
it('carries over an embedded screenshot that has no filename', () => {
const payload = {
parts: [
{ mimeType: 'image/png', body: { attachmentId: 'CID1', size: 40000 } }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'screenshot-1.png', attachmentId: 'CID1' });
});
it('skips a nameless text/html body served as an attachmentId part', () => {
const payload = {
parts: [
{ mimeType: 'text/html', body: { attachmentId: 'BODY', size: 200000 } }
]
};
expect(collectAttachmentParts(payload)).toEqual([]);
});
it('names a nameless non-image attachment "attachment-N"', () => {
const payload = {
parts: [
{ mimeType: 'application/pdf', body: { attachmentId: 'P1', size: 1000 } }
]
};
expect(collectAttachmentParts(payload)[0].filename).toBe('attachment-1.pdf');
});
});
describe('fetchMessageAttachments', () => {
it('decodes base64url attachment data into Buffers', async () => {
const payload = { parts: [attPart('hello.txt', 'A1', 5)] };
const gmail = fakeGmail({ A1: 'hello' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(skipped).toEqual([]);
expect(files).toHaveLength(1);
expect(files[0].name).toBe('hello.txt');
expect(Buffer.isBuffer(files[0].attachment)).toBe(true);
expect(files[0].attachment.toString()).toBe('hello');
});
it('skips parts over the 25 MB ceiling without fetching them', async () => {
const payload = { parts: [attPart('huge.bin', 'BIG', 26 * 1024 * 1024)] };
const gmail = fakeGmail({ BIG: 'x' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files).toEqual([]);
expect(skipped).toEqual(['huge.bin']);
expect(gmail.users.messages.attachments.get).not.toHaveBeenCalled();
});
it('records a failed download as skipped, keeps the rest', async () => {
const payload = { parts: [attPart('ok.txt', 'A1', 2), attPart('gone.txt', 'MISSING', 2)] };
const gmail = fakeGmail({ A1: 'ok' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files.map(f => f.name)).toEqual(['ok.txt']);
expect(skipped).toEqual(['gone.txt']);
});
it('caps at 10 files, skipping the overflow', async () => {
const data = {};
const parts = [];
for (let i = 0; i < 12; i++) {
const id = `A${i}`;
data[id] = `f${i}`;
parts.push(attPart(`file${i}.txt`, id, 2));
}
const { files, skipped } = await fetchMessageAttachments('msg1', { parts }, fakeGmail(data));
expect(files).toHaveLength(10);
expect(skipped).toHaveLength(2);
});
it('sanitizes CRLF and backticks out of filenames', async () => {
const payload = { parts: [attPart('bad\nname`.txt', 'A1', 2)] };
const gmail = fakeGmail({ A1: 'hi' });
const { files } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files[0].name).toBe('bad name .txt');
});
});

231
tests/gmailLabels.test.js Normal file
View File

@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
computeLabelMutation,
resolveLabelId,
moveThreadToFolder,
folderDisplayName,
getManagedFolderKey,
autoAdvanceFolder,
MANAGED_USER_KEYS,
__clearLabelCache
} from '../services/gmailLabels.js';
const FULL_IDS = {
TRIAGE: 'L_TRIAGE',
ESCALATED: 'L_ESC',
RESOLVED: 'L_RES',
FOR_JAKE: 'L_FJ',
DASHBOARD_ERRORS: 'L_DE',
PARTNERSHIP_OFFERS: 'L_PO',
SPAM: 'SPAM'
};
const FULL_LABELS = [
{ name: 'Triage', id: 'L_TRIAGE' },
{ name: 'Awaiting Reply', id: 'L_AR' },
{ name: 'Needs Response', id: 'L_NR' },
{ name: 'Escalated', id: 'L_ESC' },
{ name: 'Resolved', id: 'L_RES' },
{ name: 'Complete', id: 'L_RES' },
{ name: 'For Jake', id: 'L_FJ' },
{ name: 'Dashboard Errors', id: 'L_DE' },
{ name: 'Partnership Offers', id: 'L_PO' }
];
describe('computeLabelMutation', () => {
it('adds the target, removes every other managed label plus INBOX/UNREAD', () => {
const { addLabelIds, removeLabelIds } = computeLabelMutation('FOR_JAKE', FULL_IDS);
expect(addLabelIds).toEqual(['L_FJ']);
expect(removeLabelIds).toContain('INBOX');
expect(removeLabelIds).toContain('UNREAD');
expect(removeLabelIds).toContain('SPAM');
expect(removeLabelIds).toContain('L_TRIAGE');
expect(removeLabelIds).not.toContain('L_FJ'); // target is never removed
});
it('moving to SPAM adds SPAM and removes all user labels but not SPAM itself', () => {
const { addLabelIds, removeLabelIds } = computeLabelMutation('SPAM', FULL_IDS);
expect(addLabelIds).toEqual(['SPAM']);
expect(removeLabelIds).not.toContain('SPAM');
expect(removeLabelIds).toContain('L_TRIAGE');
expect(removeLabelIds).toContain('INBOX');
});
it('throws when the target id is missing', () => {
expect(() => computeLabelMutation('FOR_JAKE', { TRIAGE: 'x' })).toThrow();
});
});
describe('folderDisplayName', () => {
it('returns null for the system SPAM folder', () => {
expect(folderDisplayName('SPAM')).toBeNull();
});
it('returns the configured/default name for a user folder', () => {
expect(folderDisplayName('FOR_JAKE')).toBe('For Jake');
});
it('throws on an unknown key', () => {
expect(() => folderDisplayName('NOPE')).toThrow();
});
});
describe('resolveLabelId', () => {
beforeEach(() => __clearLabelCache());
it('short-circuits SPAM to the system id without any API call', async () => {
let called = false;
const gmail = { users: { labels: { list: async () => { called = true; return { data: {} }; } } } };
expect(await resolveLabelId(gmail, 'SPAM')).toBe('SPAM');
expect(called).toBe(false);
});
it('returns an existing label id matched by name', async () => {
const gmail = {
users: { labels: {
list: async () => ({ data: { labels: [{ name: 'For Jake', id: 'L_EXISTING' }] } }),
create: async () => { throw new Error('should not create'); }
} }
};
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_EXISTING');
});
it('creates a missing user label under its configured name and caches it', async () => {
let createdName = null;
const gmail = {
users: { labels: {
list: async () => ({ data: { labels: [] } }),
create: async ({ requestBody }) => { createdName = requestBody.name; return { data: { id: 'L_NEW' } }; }
} }
};
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_NEW');
expect(createdName).toBe('For Jake');
});
});
describe('moveThreadToFolder', () => {
beforeEach(() => __clearLabelCache());
it('resolves labels then issues one threads.modify with exclusive sets', async () => {
let modifyArgs = null;
const gmail = {
users: {
labels: {
list: async () => ({ data: { labels: FULL_LABELS } }),
create: async () => { throw new Error('no create expected'); }
},
threads: { modify: async (args) => { modifyArgs = args; return { data: {} }; } }
}
};
await moveThreadToFolder('thread123', 'ESCALATED', gmail);
expect(modifyArgs.id).toBe('thread123');
expect(modifyArgs.requestBody.addLabelIds).toEqual(['L_ESC']);
expect(modifyArgs.requestBody.removeLabelIds).toContain('L_TRIAGE');
expect(modifyArgs.requestBody.removeLabelIds).toContain('INBOX');
expect(modifyArgs.requestBody.removeLabelIds).not.toContain('L_ESC');
});
it('clears the cache and retries once on an invalid-label error', async () => {
let modifyCalls = 0;
let listCalls = 0;
const gmail = {
users: {
labels: {
list: async () => { listCalls++; return { data: { labels: FULL_LABELS } }; },
create: async () => ({ data: { id: 'X' } })
},
threads: {
modify: async () => {
modifyCalls++;
if (modifyCalls === 1) { const e = new Error('invalid label'); e.code = 400; throw e; }
return { data: {} };
}
}
}
};
await moveThreadToFolder('t1', 'TRIAGE', gmail);
expect(modifyCalls).toBe(2);
expect(listCalls).toBe(2); // cache was cleared and labels re-listed
});
it('rejects an unknown folder key before touching the API', async () => {
const gmail = {
users: {
labels: { list: async () => ({ data: { labels: [] } }) },
threads: { modify: async () => ({}) }
}
};
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
});
});
// Derive label name↔id from the live config so these tests don't depend on the
// actual GMAIL_LABEL_* names in .env (e.g. RESOLVED may be customized to "Complete").
const idForKey = key => `LID_${key}`;
const CYCLE_LABELS = MANAGED_USER_KEYS.map(k => ({ name: folderDisplayName(k), id: idForKey(k) }));
// Mock Gmail whose thread carries the given label ids; records threads.modify.
function makeGmail({ threadLabelIds = [], onModify } = {}) {
return {
users: {
labels: {
list: async () => ({ data: { labels: CYCLE_LABELS } }),
create: async () => { throw new Error('no create expected'); }
},
threads: {
get: async () => ({ data: { messages: [{ labelIds: threadLabelIds }] } }),
modify: async (args) => { if (onModify) onModify(args); return { data: {} }; }
}
}
};
}
describe('getManagedFolderKey', () => {
beforeEach(() => __clearLabelCache());
it('maps a thread label id to its managed folder key', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: [idForKey('FOR_JAKE'), 'INBOX'] }))).toBe('FOR_JAKE');
});
it('detects the system SPAM label', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['SPAM'] }))).toBe('SPAM');
});
it('returns null when no managed label is present', async () => {
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['INBOX', 'UNREAD'] }))).toBeNull();
});
});
describe('autoAdvanceFolder', () => {
beforeEach(() => __clearLabelCache());
it('advances an auto-cycle thread (Awaiting Reply → Needs Response)', async () => {
let modifyArgs = null;
const gmail = makeGmail({ threadLabelIds: [idForKey('AWAITING_REPLY')], onModify: a => { modifyArgs = a; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(true);
expect(modifyArgs.requestBody.addLabelIds).toEqual([idForKey('NEEDS_RESPONSE')]);
});
it('advances a thread with no managed label', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: ['INBOX'], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'AWAITING_REPLY', gmail)).toBe(true);
expect(called).toBe(true);
});
it('leaves a manually-filed thread (For Jake) untouched', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: [idForKey('FOR_JAKE')], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
expect(called).toBe(false);
});
it('leaves a SPAM-filed thread untouched', async () => {
let called = false;
const gmail = makeGmail({ threadLabelIds: ['SPAM'], onModify: () => { called = true; } });
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
expect(called).toBe(false);
});
it('rejects an unknown target key before touching the API', async () => {
await expect(autoAdvanceFolder('t', 'BOGUS', makeGmail())).rejects.toThrow();
});
});

View File

@@ -0,0 +1,169 @@
/**
* Phase 7 — gmail-poll email ticket persistence + reopen event recording.
*
* Uses the injectable-parameter pattern: persistEmailTicket accepts
* _Ticket (model) and _recordAction as the 4th and 5th parameters.
* No vi.mock needed; dependencies are injected directly.
*
* Covers:
* (a) game persisted: findOneAndUpdate $set includes game from detectGame
* (b) reopen event: staffId='system', resolverId = prior claimerId, guildId correct
* (c) payload.ticket: the returned doc is passed verbatim for denormalization
* (d) no reopen: wasReopened=false → _recordAction not called
* (e) null claimerId: resolverId=null for unclaimed-ticket reopen
*/
import { describe, it, expect, vi } from 'vitest';
import { persistEmailTicket } from '../gmail-poll.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeFields(overrides = {}) {
return {
threadId: 'gmail-thread-001',
discordThreadId: 'chan-001',
senderEmail: 'user@example.com',
subject: 'Help needed',
createdAt: new Date('2026-01-01'),
ticketNumber: 1,
priority: 'normal',
parentCategoryId: 'cat-001',
game: 'TestGame',
...overrides
};
}
function makeReturnedDoc(overrides = {}) {
return {
gmailThreadId: 'gmail-thread-001',
senderEmail: 'user@example.com',
claimerId: 'claimer-001',
escalationTier: 0,
priority: 'normal',
game: 'TestGame',
...overrides
};
}
function makeTicketModel(returnedDoc) {
return {
findOneAndUpdate: vi.fn().mockResolvedValue(returnedDoc)
};
}
// ---------------------------------------------------------------------------
// (a) game included in the $set
// ---------------------------------------------------------------------------
describe('persistEmailTicket — game persisted in $set', () => {
it('includes game in the findOneAndUpdate $set', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields({ game: 'Minecraft' }), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.game).toBe('Minecraft');
});
it('passes null game when no game is detected', async () => {
const model = makeTicketModel(makeReturnedDoc({ game: null }));
await persistEmailTicket(makeFields({ game: null }), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.game).toBeNull();
});
it('uses gmailThreadId as the findOneAndUpdate filter', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields({ threadId: 'thread-xyz' }), 'guild-001', false, model, vi.fn());
const [filter] = model.findOneAndUpdate.mock.calls[0];
expect(filter.gmailThreadId).toBe('thread-xyz');
});
it('sets status:"open" in the $set', async () => {
const model = makeTicketModel(makeReturnedDoc());
await persistEmailTicket(makeFields(), 'guild-001', false, model, vi.fn());
const [, update] = model.findOneAndUpdate.mock.calls[0];
expect(update.$set.status).toBe('open');
});
});
// ---------------------------------------------------------------------------
// (b) Reopen event: staffId='system', resolverId=prior claimerId, guildId correct
// ---------------------------------------------------------------------------
describe('persistEmailTicket — reopen event recording', () => {
it('calls _recordAction once with staffId=system and type=reopen', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: 'prev-claimer' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('reopen');
});
it('sets resolverId = the claimerId from the returned doc', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: 'prev-claimer-123' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBe('prev-claimer-123');
});
it('includes guildId in the payload', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc();
await persistEmailTicket(makeFields(), 'guild-999', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.guildId).toBe('guild-999');
});
});
// ---------------------------------------------------------------------------
// (c) payload.ticket is the returned doc (so denormalization gets all fields)
// ---------------------------------------------------------------------------
describe('persistEmailTicket — returned doc passed as payload.ticket', () => {
it('sets payload.ticket to the doc returned by findOneAndUpdate', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ game: 'SomeGame', senderEmail: 'a@b.com' });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket).toBe(doc);
});
});
// ---------------------------------------------------------------------------
// (d) No reopen event when wasReopened=false
// ---------------------------------------------------------------------------
describe('persistEmailTicket — no reopen on brand-new ticket', () => {
it('does not call _recordAction when wasReopened=false', async () => {
const mockRecord = vi.fn();
await persistEmailTicket(makeFields(), 'guild-001', false, makeTicketModel(makeReturnedDoc()), mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (e) resolverId=null when prior claimerId is null (unclaimed ticket reopened)
// ---------------------------------------------------------------------------
describe('persistEmailTicket — reopen of unclaimed ticket', () => {
it('sets resolverId=null when the prior claimerId is null', async () => {
const mockRecord = vi.fn();
const doc = makeReturnedDoc({ claimerId: null });
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBeNull();
});
});

View File

@@ -0,0 +1,245 @@
/**
* Phase 6 — staff response event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls Ticket DB layer (findOne, updateOne)
* _TagModel — controls Tag DB layer (findOne, updateOne) for /response send
* _recordAction — captures recording calls without any module mocking
* _isStaff — controls staff check result (messages.js path only)
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) handleDiscordReply — staff message in a discord ticket → one 'response' event
* (b) handleDiscordReply — staff message in an email ticket → one 'response' event
* (c) handleDiscordReply — bot message → no event
* (d) handleDiscordReply — non-staff message → no event
* (e) handleResponseSend — /response send in a ticket → one 'response' event
* (f) handleResponseSend — no ticket found → no event
* (g) handleResponseSend — tag not found → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { handleDiscordReply } from '../handlers/messages.js';
import { handleResponseSend } from '../handlers/commands/response.js';
// ---------------------------------------------------------------------------
// Shared factories — handleDiscordReply
// ---------------------------------------------------------------------------
function makeMessage(overrides = {}) {
return {
author: { bot: false, id: 'staff-001' },
interaction: null,
channel: { id: 'chan-001', name: 'ticket-chan-001' },
guild: {
id: 'guild-001',
members: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockRejectedValue(new Error('no members in test env'))
}
},
content: 'Hello customer',
id: 'msg-001',
...overrides
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeMessageTicketModel(ticket) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 0 })
};
}
// ---------------------------------------------------------------------------
// (a) + (b) Staff message records one 'response' — discord + email tickets
// ---------------------------------------------------------------------------
describe('handleDiscordReply — staff message records response', () => {
it('records one "response" event for a discord ticket', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
it('records one "response" event for an email ticket (before the discord early-return)', async () => {
const ticket = makeTicket({ gmailThreadId: '18f3a2b1c0d4e5f6' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
// Gmail relay will fail but that's caught internally — record already fired.
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (c) Bot message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — bot message records nothing', () => {
it('records nothing when author.bot is true', async () => {
const mockModel = makeMessageTicketModel(makeTicket());
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
const m = makeMessage({ author: { bot: true, id: 'bot-001' } });
await handleDiscordReply(m, mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (d) Non-staff message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — non-staff message records nothing', () => {
it('records nothing when isStaff returns false', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(false);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
it('records nothing when the message is not in a ticket channel', async () => {
const mockModel = makeMessageTicketModel(null); // no ticket
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Shared factories — handleResponseSend
// ---------------------------------------------------------------------------
function makeInteraction(overrides = {}) {
return {
user: {
id: 'staff-001',
username: 'staffuser',
toString: () => '<@staff-001>'
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001', name: 'Test Guild', memberCount: 10 },
channel: { id: 'chan-001' },
options: { getString: vi.fn().mockReturnValue('my-tag') },
reply: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
function makeTagModel(tag) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(tag) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 })
};
}
function makeResponseTicketModel(ticket) {
return { findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }) };
}
// ---------------------------------------------------------------------------
// (e) /response send in a ticket channel → one event
// ---------------------------------------------------------------------------
describe('handleResponseSend — records one "response" event', () => {
it('records staffId, guildId, and ticket when ticket is found', async () => {
const ticket = makeTicket();
const tag = { name: 'my-tag', content: 'Hello {ticket.user}', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(ticket),
mockRecord
);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (f) No ticket found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — no ticket records nothing', () => {
it('records nothing when no ticket exists for the channel', async () => {
const tag = { name: 'my-tag', content: 'Hello', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(null),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (g) Tag not found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — tag not found records nothing', () => {
it('records nothing when the tag does not exist', async () => {
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(null),
makeResponseTicketModel(makeTicket()),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});

187
tests/staffStats.test.js Normal file
View File

@@ -0,0 +1,187 @@
import { describe, it, expect, vi } from 'vitest';
// Stub debugLog so the import chain doesn't pull in discord.js / config.
vi.mock('../services/debugLog.js', () => ({
logError: vi.fn()
}));
import { recordAction, denormalizeTicket, deriveTicketType } from '../services/staffStats.js';
// ---------------------------------------------------------------------------
// deriveTicketType
// ---------------------------------------------------------------------------
describe('deriveTicketType', () => {
it('returns "discord" for discord- prefix', () => {
expect(deriveTicketType('discord-abc123')).toBe('discord');
});
it('returns "discord" for discord-msg- prefix', () => {
expect(deriveTicketType('discord-msg-abc123')).toBe('discord');
});
it('returns "email" for a Gmail thread ID', () => {
expect(deriveTicketType('18f3a2b1c0d4e5f6')).toBe('email');
});
it('returns "email" for null / undefined / empty gmailThreadId', () => {
expect(deriveTicketType(null)).toBe('email');
expect(deriveTicketType(undefined)).toBe('email');
expect(deriveTicketType('')).toBe('email');
});
});
// ---------------------------------------------------------------------------
// denormalizeTicket — field extraction
// ---------------------------------------------------------------------------
describe('denormalizeTicket', () => {
const emailTicket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 1,
priority: 'high',
game: 'Minecraft',
senderEmail: 'user@example.com',
creatorId: '111222333444555666',
claimerId: '999888777666555444'
};
const discordTicket = {
gmailThreadId: 'discord-msg-xyz789',
escalationTier: 0,
priority: 'normal',
game: null,
senderEmail: 'noreply@discord',
creatorId: '777666555444333222'
};
it('derives ticketType "email" for a Gmail thread', () => {
expect(denormalizeTicket(emailTicket).ticketType).toBe('email');
});
it('derives ticketType "discord" for a discord-msg- thread', () => {
expect(denormalizeTicket(discordTicket).ticketType).toBe('discord');
});
it('copies all standard event fields from the ticket', () => {
const f = denormalizeTicket(emailTicket);
expect(f.tier).toBe(1);
expect(f.priority).toBe('high');
expect(f.game).toBe('Minecraft');
expect(f.senderEmail).toBe('user@example.com');
expect(f.creatorId).toBe('111222333444555666');
expect(f.gmailThreadId).toBe('18f3a2b1c0d4e5f6');
});
it('defaults tier to 0 when escalationTier is absent', () => {
expect(denormalizeTicket({ gmailThreadId: 'abc' }).tier).toBe(0);
});
it('does NOT include guildId (must come from call site)', () => {
const f = denormalizeTicket(emailTicket);
expect(Object.prototype.hasOwnProperty.call(f, 'guildId')).toBe(false);
});
it('returns {} for a null ticket', () => {
expect(denormalizeTicket(null)).toEqual({});
});
});
// ---------------------------------------------------------------------------
// recordAction — payload merging / override precedence
// ---------------------------------------------------------------------------
describe('recordAction payload merging', () => {
const ticket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 2,
priority: 'medium',
game: 'Rust',
senderEmail: 'player@example.com',
creatorId: '100200300400500600'
};
it('payload fields override denormalized ticket fields', () => {
const { ticket: t, ...overrides } = {
ticket,
guildId: '555666777888999000',
game: 'OverriddenGame',
priority: 'low'
};
const merged = { ...denormalizeTicket(t), ...overrides };
expect(merged.game).toBe('OverriddenGame');
expect(merged.priority).toBe('low');
expect(merged.guildId).toBe('555666777888999000');
});
it('guildId (call-site only) passes through from payload', () => {
const { ticket: t, ...rest } = { ticket, guildId: '123456789012345678' };
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.guildId).toBe('123456789012345678');
});
it('close-only fields pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.closerType).toBe('staff');
expect(merged.resolverId).toBe('123456789012345678');
expect(merged.wasClaimed).toBe(true);
});
it('transfer-only fields (fromId/toId) pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
fromId: '111111111111111111',
toId: '222222222222222222'
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.fromId).toBe('111111111111111111');
expect(merged.toId).toBe('222222222222222222');
});
});
// ---------------------------------------------------------------------------
// recordAction — fire-and-forget discipline
//
// In the test environment there is no real MongoDB connection and the
// StaffAction model schema is not registered, so mongoose.model('StaffAction')
// throws MissingSchemaError synchronously. This is exactly the kind of error
// recordAction must swallow — it proves the outer try/catch works. The async
// .catch() path is exercised transitively: any callers in later phases that
// succeed with a real DB connection will hit that path.
// ---------------------------------------------------------------------------
describe('recordAction fire-and-forget', () => {
it('returns undefined (callers do not await)', () => {
expect(recordAction('staff1', 'claim', {})).toBeUndefined();
});
it('does not throw when called with null / undefined / empty payload', () => {
expect(() => recordAction('staff1', 'reopen', undefined)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', null)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', {})).not.toThrow();
});
it('does not throw even when the model layer errors (model not registered)', () => {
// mongoose.model('StaffAction') throws MissingSchemaError synchronously in
// this environment — recordAction must absorb it and never rethrow.
expect(() => recordAction('staff1', 'close', {
ticket: {
gmailThreadId: 'discord-abc',
escalationTier: 0,
priority: 'normal',
senderEmail: 'a@b.com',
creatorId: '123'
},
guildId: '999',
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
})).not.toThrow();
});
});

350
tests/statsHandler.test.js Normal file
View File

@@ -0,0 +1,350 @@
/**
* Phase 10 — /stats command handler tests.
* Injectable deps — no vi.mock.
*/
import { describe, it, expect, vi } from 'vitest';
import { MessageFlags } from 'discord.js';
import { handleStats, handleStatsAutocomplete } from '../handlers/commands/stats.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const NOW_MS = 1_700_000_000_000; // fixed epoch for deterministic cutoff assertions
// ---------------------------------------------------------------------------
// Factories
// ---------------------------------------------------------------------------
function makeInteraction({ userId = 'caller-001', memberUserId, memberUsername, periodStr, source } = {}) {
return {
user: { id: userId, username: 'testuser' },
guildId: 'guild-001',
options: {
getUser: (name) => {
if (name === 'member' && memberUserId) {
return { id: memberUserId, username: memberUsername || 'member-user' };
}
return null;
},
getString: (name) => {
if (name === 'period') return periodStr || null;
if (name === 'source') return source || null;
return null;
}
},
reply: vi.fn().mockResolvedValue(undefined)
};
}
function makeStaffAction(events = []) {
return {
find: vi.fn().mockReturnValue({ lean: () => Promise.resolve(events) })
};
}
function captureStaffAction() {
let capturedFilter;
return {
sa: {
find: (filter) => {
capturedFilter = filter;
return { lean: () => Promise.resolve([]) };
}
},
getFilter: () => capturedFilter
};
}
function deps(overrides = {}) {
return {
StaffAction: makeStaffAction(),
now: () => NOW_MS,
adminIds: [],
...overrides
};
}
// ---------------------------------------------------------------------------
// handleStatsAutocomplete
// ---------------------------------------------------------------------------
describe('handleStatsAutocomplete', () => {
function makeAutoInteraction(focusedValue = '') {
return {
options: { getFocused: () => focusedValue },
respond: vi.fn().mockResolvedValue(undefined)
};
}
it('returns all 5 presets when focused input is empty', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions).toHaveLength(5);
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).toContain('3 months');
expect(values).toContain('6 months');
expect(values).toContain('1 year');
});
it('filters to presets matching the typed substring', async () => {
const i = makeAutoInteraction('days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).not.toContain('3 months');
expect(values).not.toContain('1 year');
});
it('echoes typed input as first suggestion when it does not exactly match a preset', async () => {
const i = makeAutoInteraction('14d');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions[0].value).toBe('14d');
expect(suggestions[0].name).toBe('14d');
});
it('does not duplicate a preset when typed input exactly matches one', async () => {
const i = makeAutoInteraction('30 days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions.filter(s => s.value === '30 days')).toHaveLength(1);
});
it('calls interaction.respond exactly once', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
expect(i.respond).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// handleStats — gating (STATS_ADMIN_IDS)
// ---------------------------------------------------------------------------
describe('handleStats — gating', () => {
it('caller views own stats when no member option is provided', async () => {
const interaction = makeInteraction({ userId: 'caller-001' });
await handleStats(interaction, deps());
expect(interaction.reply).toHaveBeenCalledTimes(1);
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('admin can view another member\'s stats', async () => {
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: ['admin-001'] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toBeDefined();
expect(replyArg.content).toBeUndefined();
});
it('non-admin is blocked with the exact error message when member option is set', async () => {
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBe('You can only view your own stats.');
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
expect(replyArg.embeds).toBeUndefined();
});
it('non-admin can view their own stats (no member option)', async () => {
const interaction = makeInteraction({ userId: 'plain-001' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('StaffAction is never queried when the non-admin gate fires', async () => {
const sa = makeStaffAction();
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ StaffAction: sa, adminIds: [] }));
expect(sa.find).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// handleStats — period default (no option → 30 days)
// ---------------------------------------------------------------------------
describe('handleStats — period default', () => {
it('uses a 30-day cutoff when no period option is given', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const expectedCutoff = new Date(NOW_MS - 30 * MS_PER_DAY);
expect(getFilter().createdAt.$gte.getTime()).toBe(expectedCutoff.getTime());
});
it('embed title includes "30 days" when no period option', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('30 days');
});
it('embed title includes the user-supplied period label', async () => {
const interaction = makeInteraction({ periodStr: '7 days' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('7 days');
});
});
// ---------------------------------------------------------------------------
// handleStats — source filter passthrough
// ---------------------------------------------------------------------------
describe('handleStats — source filter', () => {
it('source="email" counts only email events', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction({ source: 'email' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
// Only 1 email claim should be counted
expect(claimsField.value).toMatch(/^1/);
});
it('source label appears in embed description', async () => {
const interaction = makeInteraction({ source: 'discord' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('discord');
});
it('omitted source defaults to "all" and shows "all sources" in description', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('all sources');
});
});
// ---------------------------------------------------------------------------
// handleStats — StaffAction query filter shape
// ---------------------------------------------------------------------------
describe('handleStats — query filter shape', () => {
it('$or includes all 4 target fields', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'user-001' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().$or).toHaveLength(4);
expect(getFilter().$or).toContainEqual({ staffId: 'user-001' });
expect(getFilter().$or).toContainEqual({ resolverId: 'user-001' });
expect(getFilter().$or).toContainEqual({ toId: 'user-001' });
expect(getFilter().$or).toContainEqual({ fromId: 'user-001' });
});
it('createdAt.$gte is a Date instance', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().createdAt.$gte).toBeInstanceOf(Date);
});
it('admin querying another member uses member id (not admin id) in $or', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: ['admin-001'] });
expect(getFilter().$or).toContainEqual({ staffId: 'other-002' });
expect(getFilter().$or).not.toContainEqual({ staffId: 'admin-001' });
});
});
// ---------------------------------------------------------------------------
// handleStats — embed output reflects shapeStats with tier labels
// ---------------------------------------------------------------------------
describe('handleStats — embed content', () => {
it('reply is ephemeral', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
});
it('embed title contains the target username', async () => {
const interaction = makeInteraction({ userId: 'caller-001' }); // username = 'testuser'
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('testuser');
});
it('escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'escalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'escalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('de-escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'deescalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'deescalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'De-escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('claims-while-escalated sub-breakdown uses tier labels in Claims field', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('empty stats returns a valid zero-filled embed without throwing', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toHaveLength(1);
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(claimsField.value).toMatch(/^0/);
const escalField = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(escalField.value).toBe('0');
});
it('Email / Discord split field reflects bySource from shapeStats', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const splitField = replyArg.embeds[0].data.fields.find(f => f.name === 'Email / Discord split');
expect(splitField.value).toContain('Email');
expect(splitField.value).toContain('Discord');
});
});

716
tests/statsShaping.test.js Normal file
View File

@@ -0,0 +1,716 @@
import { describe, it, expect } from 'vitest';
import { parsePeriod, shapeStats } from '../services/statsShaping.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// parsePeriod — presets (autocomplete suggestions)
// ---------------------------------------------------------------------------
describe('parsePeriod — presets', () => {
it('"7 days" → 7 days', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
expect(r.label).toBe('7 days');
});
it('"30 days" → 30 days', () => {
const r = parsePeriod('30 days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
});
it('"3 months" → 3 × 30 days', () => {
const r = parsePeriod('3 months');
expect(r.value).toBe(3);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
expect(r.label).toBe('3 months');
});
it('"6 months" → 6 × 30 days', () => {
const r = parsePeriod('6 months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('"1 year" → 365 days', () => {
const r = parsePeriod('1 year');
expect(r.value).toBe(1);
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
expect(r.label).toBe('1 year');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — day unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — day variants', () => {
it('<n>d', () => {
const r = parsePeriod('14d');
expect(r.value).toBe(14);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(14 * MS_PER_DAY);
});
it('<n>day (singular, no space)', () => {
const r = parsePeriod('1day');
expect(r.value).toBe(1);
expect(r.unit).toBe('days');
expect(r.label).toBe('1 day');
});
it('<n> day (singular, with space)', () => {
const r = parsePeriod('1 day');
expect(r.value).toBe(1);
expect(r.label).toBe('1 day');
});
it('<n> days', () => {
const r = parsePeriod('10 days');
expect(r.durationMs).toBe(10 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — week unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — week variants', () => {
it('<n>w', () => {
const r = parsePeriod('2w');
expect(r.value).toBe(2);
expect(r.unit).toBe('weeks');
expect(r.durationMs).toBe(2 * 7 * MS_PER_DAY);
expect(r.label).toBe('2 weeks');
});
it('<n> week (singular)', () => {
const r = parsePeriod('1 week');
expect(r.value).toBe(1);
expect(r.label).toBe('1 week');
});
it('<n>weeks (no space)', () => {
const r = parsePeriod('4weeks');
expect(r.value).toBe(4);
expect(r.unit).toBe('weeks');
});
it('<n> weeks', () => {
const r = parsePeriod('4 weeks');
expect(r.durationMs).toBe(4 * 7 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — month unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — month variants', () => {
it('<n>m', () => {
const r = parsePeriod('3m');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
});
it('<n>mo', () => {
const r = parsePeriod('6mo');
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
});
it('<n> month (singular)', () => {
const r = parsePeriod('1 month');
expect(r.value).toBe(1);
expect(r.label).toBe('1 month');
});
it('<n> months', () => {
const r = parsePeriod('12 months');
expect(r.value).toBe(12);
expect(r.unit).toBe('months');
expect(r.durationMs).toBe(12 * 30 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — year unit variants
// ---------------------------------------------------------------------------
describe('parsePeriod — year variants', () => {
it('<n>y', () => {
const r = parsePeriod('1y');
expect(r.unit).toBe('years');
expect(r.durationMs).toBe(365 * MS_PER_DAY);
});
it('<n> year (singular)', () => {
const r = parsePeriod('1 year');
expect(r.unit).toBe('years');
expect(r.label).toBe('1 year');
});
it('<n> years', () => {
const r = parsePeriod('2 years');
expect(r.value).toBe(2);
expect(r.label).toBe('2 years');
expect(r.durationMs).toBe(2 * 365 * MS_PER_DAY);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — bare number = days
// ---------------------------------------------------------------------------
describe('parsePeriod — bare number defaults to days', () => {
it('"30" → 30 days', () => {
const r = parsePeriod('30');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
});
it('"7" → 7 days', () => {
const r = parsePeriod('7');
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(7 * MS_PER_DAY);
});
it('"365" → 365 days (not 1 year)', () => {
const r = parsePeriod('365');
expect(r.unit).toBe('days');
expect(r.value).toBe(365);
});
});
// ---------------------------------------------------------------------------
// parsePeriod — case & whitespace tolerance
// ---------------------------------------------------------------------------
describe('parsePeriod — case and whitespace tolerance', () => {
it('uppercase "7 DAYS"', () => {
const r = parsePeriod('7 DAYS');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('mixed case "3 Months"', () => {
const r = parsePeriod('3 Months');
expect(r.unit).toBe('months');
});
it('leading and trailing whitespace " 30 days "', () => {
const r = parsePeriod(' 30 days ');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('multiple internal spaces "7 days"', () => {
const r = parsePeriod('7 days');
expect(r.value).toBe(7);
expect(r.unit).toBe('days');
});
it('no space between number and unit "30days"', () => {
const r = parsePeriod('30days');
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
});
it('"1YEAR" (uppercase, no space)', () => {
const r = parsePeriod('1YEAR');
expect(r.unit).toBe('years');
});
});
// ---------------------------------------------------------------------------
// parsePeriod — unparseable → 30-day default
// ---------------------------------------------------------------------------
describe('parsePeriod — unparseable inputs → 30-day default', () => {
const expectDefault = r => {
expect(r.value).toBe(30);
expect(r.unit).toBe('days');
expect(r.durationMs).toBe(30 * MS_PER_DAY);
expect(r.label).toBe('30 days');
};
it('null → default', () => expectDefault(parsePeriod(null)));
it('undefined → default', () => expectDefault(parsePeriod(undefined)));
it('empty string → default', () => expectDefault(parsePeriod('')));
it('whitespace only → default', () => expectDefault(parsePeriod(' ')));
it('letters only → default', () => expectDefault(parsePeriod('abc')));
it('natural language → default', () => expectDefault(parsePeriod('last month')));
it('unknown unit "5x" → default', () => expectDefault(parsePeriod('5x')));
it('"0" → default (zero is nonsensical)', () => expectDefault(parsePeriod('0')));
it('"0d" → default', () => expectDefault(parsePeriod('0d')));
it('negative-like "-5d" → default (not a digit-start)', () => expectDefault(parsePeriod('-5d')));
});
// ---------------------------------------------------------------------------
// parsePeriod — return shape invariant
// ---------------------------------------------------------------------------
describe('parsePeriod — return shape', () => {
it('always returns { durationMs, value, unit, label }', () => {
for (const input of ['7d', '2w', '3m', '6mo', '1y', '30', null, 'junk']) {
const r = parsePeriod(input);
expect(typeof r.durationMs).toBe('number');
expect(typeof r.value).toBe('number');
expect(typeof r.unit).toBe('string');
expect(typeof r.label).toBe('string');
}
});
it('returns a fresh object each call (not the same frozen reference)', () => {
const a = parsePeriod(null);
const b = parsePeriod(undefined);
expect(a).not.toBe(b);
});
});
// ===========================================================================
// shapeStats — fixtures
// ===========================================================================
const MEMBER = 'member-001';
const OTHER = 'other-002';
function event(overrides) {
return {
staffId: OTHER,
type: 'claim',
tier: 0,
ticketType: 'email',
wasClaimed: null,
resolverId: null,
fromId: null,
toId: null,
...overrides
};
}
// ---------------------------------------------------------------------------
// shapeStats — claims
// ---------------------------------------------------------------------------
describe('shapeStats — claims', () => {
it('counts claim events where staffId===member', () => {
const events = [
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: MEMBER }),
event({ type: 'claim', staffId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('does not count claims by other staff', () => {
expect(shapeStats([event({ type: 'claim', staffId: OTHER })], MEMBER, 'all').claims).toBe(0);
});
it('non-claim event types do not increment claims', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(0);
});
it('claimsWhileEscalated groups by numeric tier for tier > 0', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
event({ type: 'claim', staffId: MEMBER, tier: 2 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(4);
expect(r.claimsWhileEscalated).toEqual({ 1: 2, 2: 1 });
});
it('tier=0 claims are NOT included in claimsWhileEscalated', () => {
const events = [event({ type: 'claim', staffId: MEMBER, tier: 0 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
it('claimsWhileEscalated only includes the member\'s own claims', () => {
const events = [event({ type: 'claim', staffId: OTHER, tier: 1 })];
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
});
});
// ---------------------------------------------------------------------------
// shapeStats — closes
// ---------------------------------------------------------------------------
describe('shapeStats — closes', () => {
it('counts close events where staffId===member', () => {
const events = [
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: MEMBER }),
event({ type: 'close', staffId: OTHER }),
event({ type: 'close', staffId: 'system' }),
];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(2);
});
it('system closes do not count toward member closes', () => {
const events = [event({ type: 'close', staffId: 'system', resolverId: MEMBER })];
expect(shapeStats(events, MEMBER, 'all').closes).toBe(0);
});
it('unclaimedAtClose counts member closes where wasClaimed===false', () => {
const events = [
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: MEMBER, wasClaimed: true }),
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
event({ type: 'close', staffId: OTHER, wasClaimed: false }),
];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(2);
});
it('wasClaimed===true does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: true })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
it('wasClaimed===null does NOT count as unclaimed-at-close', () => {
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: null })];
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — resolved (credit to claimer via resolverId)
// ---------------------------------------------------------------------------
describe('shapeStats — resolved', () => {
it('counts close events where resolverId===member', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(2);
expect(r.closes).toBe(1);
});
it('resolved is distinct from closes — different field keys', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.resolved).toBe(1);
expect(r.closes).toBe(0);
});
it('a self-close-and-resolve increments both closes and resolved', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true })
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — escalations / de-escalations
// ---------------------------------------------------------------------------
describe('shapeStats — escalations', () => {
it('groups escalate events by numeric tier', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'escalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').escalations).toEqual({ 1: 2, 2: 1 });
});
it('ignores escalations by other staff', () => {
expect(shapeStats([event({ type: 'escalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').escalations).toEqual({});
});
it('escalations is empty when no escalate events', () => {
expect(shapeStats([event({ type: 'claim', staffId: MEMBER })], MEMBER, 'all').escalations).toEqual({});
});
});
describe('shapeStats — de-escalations', () => {
it('groups deescalate events by numeric tier', () => {
const events = [
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
];
expect(shapeStats(events, MEMBER, 'all').deescalations).toEqual({ 1: 1, 2: 2 });
});
it('ignores deescalations by other staff', () => {
expect(shapeStats([event({ type: 'deescalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').deescalations).toEqual({});
});
it('escalations and deescalations are counted independently', () => {
const events = [
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.escalations).toEqual({ 1: 1 });
expect(r.deescalations).toEqual({ 1: 1 });
});
});
// ---------------------------------------------------------------------------
// shapeStats — transfers in vs out
// ---------------------------------------------------------------------------
describe('shapeStats — transfers', () => {
it('transfersIn counts transfer events where toId===member', () => {
const events = [
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersIn).toBe(1);
});
it('transfersOut counts transfer events where staffId===member (initiator)', () => {
const events = [
event({ type: 'transfer', staffId: MEMBER, toId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').transfersOut).toBe(1);
});
it('single transfer counts out for sender, in for receiver', () => {
const events = [event({ type: 'transfer', staffId: MEMBER, toId: OTHER })];
const rMember = shapeStats(events, MEMBER, 'all');
const rOther = shapeStats(events, OTHER, 'all');
expect(rMember.transfersOut).toBe(1);
expect(rMember.transfersIn).toBe(0);
expect(rOther.transfersIn).toBe(1);
expect(rOther.transfersOut).toBe(0);
});
it('transfersIn and transfersOut are counted on a single event if member is both', () => {
// Degenerate: staffId===toId===member. Phase 5b prevents this in practice,
// but the shaper is pure and should still count both dimensions.
const events = [event({ type: 'transfer', staffId: MEMBER, toId: MEMBER })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.transfersOut).toBe(1);
expect(r.transfersIn).toBe(1);
});
});
// ---------------------------------------------------------------------------
// shapeStats — reopens (via resolverId, not staffId)
// ---------------------------------------------------------------------------
describe('shapeStats — reopens', () => {
it('counts reopen events where resolverId===member', () => {
const events = [
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(1);
});
it('staffId on reopen is typically "system" — does not drive the reopen count', () => {
const events = [event({ type: 'reopen', staffId: MEMBER, resolverId: OTHER })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
it('null resolverId does not count', () => {
const events = [event({ type: 'reopen', staffId: 'system', resolverId: null })];
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
});
});
// ---------------------------------------------------------------------------
// shapeStats — source filter
// ---------------------------------------------------------------------------
describe('shapeStats — source filter', () => {
it('"all" includes both email and discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
});
it('"email" includes only email events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'email').claims).toBe(1);
});
it('"discord" includes only discord events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER, 'discord').claims).toBe(1);
});
it('source filter applies before all metric calculations', () => {
const events = [
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'email');
expect(r.closes).toBe(1);
expect(r.resolved).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
});
it('undefined source defaults to "all"', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
expect(shapeStats(events, MEMBER).claims).toBe(2);
});
});
// ---------------------------------------------------------------------------
// shapeStats — bySource breakdown
// ---------------------------------------------------------------------------
describe('shapeStats — bySource breakdown', () => {
it('splits claims by email/discord', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(2);
});
it('splits closes by email/discord', () => {
const events = [
event({ type: 'close', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.closes).toBe(1);
expect(r.bySource.discord.closes).toBe(1);
});
it('splits resolved by email/discord (using resolverId key)', () => {
const events = [
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.resolved).toBe(1);
expect(r.bySource.discord.resolved).toBe(1);
});
it('events with unknown ticketType are bucketed as email', () => {
const events = [event({ type: 'claim', staffId: MEMBER, ticketType: undefined })];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims).toBe(1);
expect(r.bySource.discord.claims).toBe(0);
});
it('bySource totals match headline counts', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.bySource.email.claims + r.bySource.discord.claims).toBe(r.claims);
expect(r.bySource.email.closes + r.bySource.discord.closes).toBe(r.closes);
expect(r.bySource.email.resolved + r.bySource.discord.resolved).toBe(r.resolved);
});
});
// ---------------------------------------------------------------------------
// shapeStats — edge cases
// ---------------------------------------------------------------------------
describe('shapeStats — edge cases', () => {
it('empty events array returns zero counts', () => {
const r = shapeStats([], MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.unclaimedAtClose).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.claimsWhileEscalated).toEqual({});
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
expect(r.bySource.email.claims).toBe(0);
expect(r.bySource.discord.claims).toBe(0);
});
it('null events array is treated as empty', () => {
const r = shapeStats(null, MEMBER, 'all');
expect(r.claims).toBe(0);
});
it('events from other members are ignored for the requested member', () => {
const events = [
event({ type: 'claim', staffId: OTHER }),
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
event({ type: 'escalate', staffId: OTHER, tier: 1 }),
event({ type: 'deescalate',staffId: OTHER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(0);
expect(r.closes).toBe(0);
expect(r.resolved).toBe(0);
expect(r.transfersIn).toBe(0);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(0);
expect(r.escalations).toEqual({});
expect(r.deescalations).toEqual({});
});
it('handles member appearing in multiple roles across events', () => {
const events = [
event({ type: 'claim', staffId: MEMBER, tier: 0, ticketType: 'email' }),
event({ type: 'close', staffId: MEMBER, resolverId: OTHER, wasClaimed: false, ticketType: 'email' }),
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims).toBe(1);
expect(r.closes).toBe(1);
expect(r.unclaimedAtClose).toBe(1);
expect(r.resolved).toBe(1);
expect(r.transfersIn).toBe(1);
expect(r.transfersOut).toBe(0);
expect(r.reopens).toBe(1);
expect(r.escalations).toEqual({ 1: 1 });
});
it('events matching no member fields contribute nothing', () => {
const events = [
event({ type: 'response', staffId: MEMBER }), // 'response' type has no shaper rule
];
const r = shapeStats(events, MEMBER, 'all');
expect(r.claims + r.closes + r.resolved + r.transfersIn + r.transfersOut + r.reopens).toBe(0);
});
});

View File

@@ -0,0 +1,166 @@
/**
* Phase 5b — transfer event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Tests applyTransfer directly (same pattern as testing applyClaim directly).
*
* Covers:
* (a) transfer to different member — one 'transfer' event with fromId/toId
* (b) transfer to current claimer — fromId === toId → no event
* (c) fromId/toId/staffId/guildId correctness
*/
import { describe, it, expect, vi } from 'vitest';
import { applyTransfer } from '../handlers/commands/index.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
escalated: false,
claimerId: 'prev-claimer-001',
claimedBy: 'PrevClaimerName',
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
...overrides
};
}
function makeGuildMember(userId = 'new-claimer-002') {
return {
id: userId,
displayName: 'New Claimer',
user: {
id: userId,
username: 'newclaimer',
tag: 'newclaimer#0002',
toString: () => `<@${userId}>`
}
};
}
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
tag: 'staffuser#0001',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: {
id: 'guild-001',
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
},
channel: {
id: 'chan-001',
name: 'ticket-chan-001',
isThread: vi.fn().mockReturnValue(true),
send: vi.fn().mockResolvedValue({ id: 'sent-msg-001' })
},
editReply: vi.fn().mockResolvedValue(undefined),
client: {
channels: { fetch: vi.fn().mockResolvedValue(null) }
}
};
}
// ---------------------------------------------------------------------------
// (a) Transfer to a different member — one event
// ---------------------------------------------------------------------------
describe('applyTransfer — different member emits one event', () => {
it('emits exactly one "transfer" event', async () => {
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('transfer');
});
it('passes correct fromId, toId, guildId, and ticket', async () => {
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBe('prev-claimer-001');
expect(payload.toId).toBe('new-claimer-002');
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(ticket);
});
it('fromId is the pre-write claimerId (captured before the DB write)', async () => {
const ticket = makeTicket({ claimerId: 'original-claimer' });
const guildMember = makeGuildMember('new-staff-003');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBe('original-claimer');
expect(payload.toId).toBe('new-staff-003');
});
});
// ---------------------------------------------------------------------------
// (b) Transfer to the current claimer — no event
// ---------------------------------------------------------------------------
describe('applyTransfer — transfer to current claimer emits no event', () => {
it('emits no event when fromId === toId (transferring to existing claimer)', async () => {
const ticket = makeTicket({ claimerId: 'same-claimer-001' });
const guildMember = makeGuildMember('same-claimer-001');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
it('emits one event when the ticket is unclaimed (fromId null) and toId is non-null — null !== toId satisfies the fromId !== toId gate', async () => {
// null !== 'new-claimer' so this IS a real transfer — event IS emitted
const ticket = makeTicket({ claimerId: null });
const guildMember = makeGuildMember('new-claimer-002');
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
// null !== 'new-claimer-002' → event IS emitted
expect(mockRecord).toHaveBeenCalledTimes(1);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.fromId).toBeNull();
expect(payload.toId).toBe('new-claimer-002');
});
});