Files
broccolini-bot/docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md
indifferentketchup 2ccdbf72aa Email ticketing fixes, comms polish, and .env cleanup
Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

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

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

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

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

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
2026-06-04 22:05:20 +00:00

9.0 KiB

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.jsrecordAction(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.