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
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 (
claimevents withtier > 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
createdAtby hour-of-day and day-of-week (andclosedAtsimilarly). 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:
discordifgmailThreadIdstarts withdiscord-/discord-msg-, elseemail. Denormalized asticketTypeon every event. - Requester: email tickets are attributed to
senderEmail; discord tickets to the creator'screatorId. Both denormalized onto events so per-emailer (email) and per-user (discord) reporting work, sliceable by source. - Tier (existing convention):
0normal,1→ "Tier 2 Support",2→ "Tier 3 Support". Events store raw numerictier;/statsmirrors 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 existingdetectGame(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/yand bare number = days; unparseable → 30 days.member— optional user.source— optional choice: all (default) / email / discord. Filters every metric byticketType, 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
StaffActiondocs and fields,ticket.game/closedAt; confirm per-ticket timeline yields timing deltas;/statscounts + email/discord split; non-admin can't view others, admin can. Deploydocker compose up --build -d; confirm bot logs ready.