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

View File

@@ -25,7 +25,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff
'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID',
'ADMIN_ID', 'STATS_ADMIN_IDS',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
// Messages and labels

54
services/staffStats.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const mongoose = require('mongoose');
const { logError } = require('./debugLog');
// Derives ticketType from gmailThreadId prefix.
// discord-* and discord-msg-* → 'discord'; everything else → 'email'.
function deriveTicketType(gmailThreadId) {
if (!gmailThreadId) return 'email';
if (gmailThreadId.startsWith('discord-')) return 'discord';
return 'email';
}
// Extracts the standard event fields from a ticket document.
// Returns a plain object — does NOT include guildId (call-site only).
function denormalizeTicket(ticket) {
if (!ticket) return {};
return {
ticketType: deriveTicketType(ticket.gmailThreadId),
tier: ticket.escalationTier ?? 0,
priority: ticket.priority,
game: ticket.game,
senderEmail: ticket.senderEmail,
creatorId: ticket.creatorId,
gmailThreadId: ticket.gmailThreadId
};
}
// recordAction(staffId, type, payload)
//
// payload may carry:
// ticket — a Ticket doc to denormalize standard fields from
// guildId — must come from the call site (not on the Ticket schema)
// any other StaffAction field — these override denormalized values
//
// Fire-and-forget: never throws, never blocks the caller.
// The outer try/catch catches synchronous errors (e.g. model not registered
// during early boot); the inner .catch handles async DB rejections.
function recordAction(staffId, type, payload) {
try {
const { ticket, ...overrides } = payload || {};
const base = denormalizeTicket(ticket);
const doc = { staffId, type, ...base, ...overrides };
const StaffAction = mongoose.model('StaffAction');
StaffAction.create(doc).catch(err => {
logError('staffStats.recordAction', err);
});
} catch (err) {
logError('staffStats.recordAction', err);
}
}
module.exports = { recordAction, denormalizeTicket, deriveTicketType };

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 };

View File

@@ -6,6 +6,7 @@ const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { enqueueSend, enqueueDelete } = require('./channelQueue');
const { recordAction } = require('./staffStats');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
@@ -269,15 +270,43 @@ async function checkTicketLimits(senderEmail) {
return { ok: true };
}
// --- CLOSE TRANSITION ---
/**
* Atomic conditional close: updates the ticket only when status is 'open'.
* Sets status:'closed', closedAt, and any caller-supplied extra $set/$unset
* fields in ONE update so all side-writes land atomically.
* Returns { transitioned: true, ticket } when an open ticket was just closed,
* { transitioned: false, ticket: null } when the ticket was already closed
* (modifiedCount was 0 — the status filter did not match).
*/
async function attemptCloseTransition(gmailThreadId, extraSet = {}, extraUnset = {}, _TicketModel) {
const T = _TicketModel || Ticket;
const closedAt = new Date();
const update = { $set: { status: 'closed', closedAt, ...extraSet } };
if (Object.keys(extraUnset).length > 0) {
update.$unset = extraUnset;
}
const result = await T.updateOne({ gmailThreadId, status: 'open' }, update);
const transitioned = result.modifiedCount === 1;
const ticket = transitioned ? await T.findOne({ gmailThreadId }).lean() : null;
return { transitioned, ticket };
}
// --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
async function checkAutoClose(client, sendTicketClosedEmail) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _recordAction, _deps) {
const cfg = (_deps && _deps.config) || CONFIG;
if (!cfg.AUTO_CLOSE_ENABLED) return;
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const _withRetry = (_deps && _deps.withRetry) || withRetry;
const _enqueueSend = (_deps && _deps.enqueueSend) || enqueueSend;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
const cutoffTime = new Date(Date.now() - (cfg.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({
const staleTickets = await _withRetry(() => T.find({
status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean());
@@ -289,28 +318,39 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
try {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
await _enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', pendingDelete: true } }
));
const { transitioned: autoTransitioned, ticket: autoClosedTicket } =
await _withRetry(() => attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, {}, T));
if (autoTransitioned) {
record('system', 'close', {
ticket: autoClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: autoClosedTicket.claimerId ?? null,
wasClaimed: Boolean(autoClosedTicket.claimerId)
});
}
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000));
if (_deps && _deps.scheduleDelete) {
_deps.scheduleDelete(channel, ticket);
} else {
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000));
}
}
} catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
@@ -352,12 +392,14 @@ async function checkAutoUnclaim(client) {
}
}
async function reconcileDeletedTicketChannels(client) {
async function reconcileDeletedTicketChannels(client, _TicketModel, _recordAction) {
const T = _TicketModel || Ticket;
const record = _recordAction || recordAction;
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({
const openTickets = await T.find({
status: 'open',
discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
@@ -369,10 +411,17 @@ async function reconcileDeletedTicketChannels(client) {
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
}
if (!channel) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } }
);
const { transitioned: reconTransitioned, ticket: reconClosedTicket } =
await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, {}, T);
if (reconTransitioned) {
record('system', 'close', {
ticket: reconClosedTicket,
guildId: guild.id,
closerType: 'system',
resolverId: reconClosedTicket.claimerId ?? null,
wasClaimed: Boolean(reconClosedTicket.claimerId)
});
}
}
} catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
@@ -417,6 +466,7 @@ module.exports = {
makeTicketName,
checkTicketCreationRateLimit,
checkTicketLimits,
attemptCloseTransition,
checkAutoClose,
checkAutoUnclaim,
reconcileDeletedTicketChannels,