Add per-staff metrics: StaffAction event log + /stats command

Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats
command. Foundation for a future tickets-website analytics dashboard.

Data:
- StaffAction model (event log) + Ticket.game / Ticket.closedAt
- STATS_ADMIN_IDS config (who may view others' stats)

Recording (fire-and-forget, idempotent on real state transitions):
- claim, response (channel reply + /response send), escalate, de-escalate,
  transfer, close (4 sites), reopen — each denormalizes ticketType, tier,
  priority, game, requester (senderEmail / creatorId), guildId
- close events carry closerType / resolverId (claimer credit) / wasClaimed;
  transfer carries fromId / toId; reopen stamps resolverId
- conditional close transition helper (atomic open->closed + closedAt) shared
  by all four close paths

Query + command:
- pure period parser (presets + free-text) and stats shaper (per-metric keys)
- command-aware autocomplete dispatch
- /stats: period (autocomplete) + member (admin-gated) + source (all/email/
  discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed

288+ unit tests; timing/busiest-times data is collected but displayed later.
This commit is contained in:
2026-06-05 02:02:48 +00:00
parent 0fcffe8d33
commit cdb5db0082
28 changed files with 3447 additions and 124 deletions

154
services/statsShaping.js Normal file
View File

@@ -0,0 +1,154 @@
'use strict';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// Months = 30 days, years = 365 days — fixed-day approximation for windowing only.
const MS = {
days: MS_PER_DAY,
weeks: 7 * MS_PER_DAY,
months: 30 * MS_PER_DAY,
years: 365 * MS_PER_DAY
};
const DEFAULT_PERIOD = Object.freeze({ durationMs: 30 * MS_PER_DAY, value: 30, unit: 'days', label: '30 days' });
/**
* parsePeriod(input) → { durationMs, value, unit, label }
*
* Accepts the autocomplete presets ("7 days", "30 days", "3 months", "6 months",
* "1 year") and free text: <n>d / day(s), <n>w / week(s), <n>m / mo / month(s),
* <n>y / year(s), or a bare integer (= days). Case- and whitespace-tolerant.
* Unparseable or zero input returns the 30-day default.
*
* The caller computes the cutoff as: cutoff = Date.now() - durationMs
* Month = 30 days, year = 365 days (windowing approximation, not calendar-accurate).
*/
function parsePeriod(input) {
if (input == null) return Object.assign({}, DEFAULT_PERIOD);
const s = String(input).trim().toLowerCase();
if (!s) return Object.assign({}, DEFAULT_PERIOD);
const match = s.match(/^(\d+)\s*(d|day|days|w|week|weeks|m|mo|month|months|y|year|years)?$/);
if (!match) return Object.assign({}, DEFAULT_PERIOD);
const n = parseInt(match[1], 10);
if (!n) return Object.assign({}, DEFAULT_PERIOD);
const unitStr = match[2];
let unit, durationMs;
if (!unitStr || unitStr === 'd' || unitStr === 'day' || unitStr === 'days') {
unit = 'days';
durationMs = n * MS.days;
} else if (unitStr === 'w' || unitStr === 'week' || unitStr === 'weeks') {
unit = 'weeks';
durationMs = n * MS.weeks;
} else if (unitStr === 'm' || unitStr === 'mo' || unitStr === 'month' || unitStr === 'months') {
unit = 'months';
durationMs = n * MS.months;
} else if (unitStr === 'y' || unitStr === 'year' || unitStr === 'years') {
unit = 'years';
durationMs = n * MS.years;
} else {
return Object.assign({}, DEFAULT_PERIOD);
}
const singular = unit.slice(0, -1);
const label = n === 1 ? `1 ${singular}` : `${n} ${unit}`;
return { durationMs, value: n, unit, label };
}
/**
* shapeStats(events, memberId, source) → counts object
*
* Pure aggregator over an array of StaffAction-shaped objects.
* source: 'all' | 'email' | 'discord' (default: 'all')
*
* Field keying:
* claims type 'claim', staffId === member
* claimsWhileEscalated above, tier > 0, grouped by numeric tier key
* closes type 'close', staffId === member
* resolved type 'close', resolverId === member (claimer credit)
* unclaimedAtClose type 'close', staffId === member, wasClaimed === false
* escalations type 'escalate', staffId === member, grouped by tier
* deescalations type 'deescalate', staffId === member, grouped by tier
* transfersIn type 'transfer', toId === member
* transfersOut type 'transfer', staffId === member (initiator)
* reopens type 'reopen', resolverId === member
*
* Tier labels (tier 1 → "Tier 2", tier 2 → "Tier 3") are NOT applied here;
* Phase 10 maps numeric tier keys to display labels.
*
* bySource breaks headline counts (claims, closes, resolved) by ticketType.
*/
function shapeStats(events, memberId, source) {
const src = source || 'all';
const pool = src === 'all'
? (events || [])
: (events || []).filter(e => e.ticketType === src);
const result = {
claims: 0,
claimsWhileEscalated: {},
closes: 0,
resolved: 0,
unclaimedAtClose: 0,
escalations: {},
deescalations: {},
transfersIn: 0,
transfersOut: 0,
reopens: 0,
bySource: {
email: { claims: 0, closes: 0, resolved: 0 },
discord: { claims: 0, closes: 0, resolved: 0 }
}
};
for (const e of pool) {
const tt = e.ticketType === 'discord' ? 'discord' : 'email';
if (e.type === 'claim' && e.staffId === memberId) {
result.claims++;
result.bySource[tt].claims++;
if (e.tier > 0) {
result.claimsWhileEscalated[e.tier] = (result.claimsWhileEscalated[e.tier] || 0) + 1;
}
}
if (e.type === 'close' && e.staffId === memberId) {
result.closes++;
result.bySource[tt].closes++;
if (e.wasClaimed === false) {
result.unclaimedAtClose++;
}
}
if (e.type === 'close' && e.resolverId === memberId) {
result.resolved++;
result.bySource[tt].resolved++;
}
if (e.type === 'escalate' && e.staffId === memberId) {
result.escalations[e.tier] = (result.escalations[e.tier] || 0) + 1;
}
if (e.type === 'deescalate' && e.staffId === memberId) {
result.deescalations[e.tier] = (result.deescalations[e.tier] || 0) + 1;
}
if (e.type === 'transfer' && e.toId === memberId) {
result.transfersIn++;
}
if (e.type === 'transfer' && e.staffId === memberId) {
result.transfersOut++;
}
if (e.type === 'reopen' && e.resolverId === memberId) {
result.reopens++;
}
}
return result;
}
module.exports = { parsePeriod, shapeStats };