Compare commits
9 Commits
cleanup-20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7dee679c | |||
| b0e8d15273 | |||
| 988151d337 | |||
| 6ae57af885 | |||
| 61e8ea32e1 | |||
| e77be9a3e4 | |||
| 6bae3e79b1 | |||
| 0fcffe8d33 | |||
| 2ccdbf72aa |
14
.env.example
14
.env.example
@@ -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
3
.gitignore
vendored
@@ -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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
17
config.js
17
config.js
@@ -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',
|
||||||
|
|||||||
124
docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md
Normal file
124
docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md
Normal 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.
|
||||||
217
docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md
Normal file
217
docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md
Normal 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.
|
||||||
176
docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md
Normal file
176
docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md
Normal 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.
|
||||||
|
```
|
||||||
172
gmail-poll.js
172
gmail-poll.js
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
content: `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`,
|
||||||
);
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
handlers/commands/forward.js
Normal file
59
handlers/commands/forward.js
Normal 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 };
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
139
handlers/commands/stats.js
Normal 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 };
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
30
models.js
30
models.js
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = /<(a?):(\w+):(\d+)>/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
206
services/gmailLabels.js
Normal 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
54
services/staffStats.js
Normal 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
154
services/statsShaping.js
Normal 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 };
|
||||||
@@ -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,
|
||||||
|
|||||||
66
tests/autocompleteDispatch.test.js
Normal file
66
tests/autocompleteDispatch.test.js
Normal 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
171
tests/claimEvents.test.js
Normal 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
310
tests/closeEvents.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
tests/closeTransition.test.js
Normal file
110
tests/closeTransition.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
188
tests/escalateEvents.test.js
Normal file
188
tests/escalateEvents.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
135
tests/gmailAttachments.test.js
Normal file
135
tests/gmailAttachments.test.js
Normal 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
231
tests/gmailLabels.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
169
tests/gmailPollEvents.test.js
Normal file
169
tests/gmailPollEvents.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
245
tests/responseEvents.test.js
Normal file
245
tests/responseEvents.test.js
Normal 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
187
tests/staffStats.test.js
Normal 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
350
tests/statsHandler.test.js
Normal 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
716
tests/statsShaping.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
166
tests/transferEvents.test.js
Normal file
166
tests/transferEvents.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user