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:
@@ -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
54
services/staffStats.js
Normal 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
154
services/statsShaping.js
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user