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
177 lines
9.0 KiB
Markdown
177 lines
9.0 KiB
Markdown
# 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.
|
|
```
|