Email ticketing fixes, comms polish, and .env cleanup

Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

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

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

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

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

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
This commit is contained in:
2026-06-04 22:05:20 +00:00
parent 3e20f9cf86
commit 2ccdbf72aa
19 changed files with 1224 additions and 83 deletions

View File

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

View File

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

View File

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