# 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 `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. ```