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:
@@ -26,6 +26,7 @@ const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDis
|
||||
const { logError } = require('./services/debugLog');
|
||||
const { enqueueSend } = require('./services/channelQueue');
|
||||
const { getTicketActionRow } = require('./utils/ticketComponents');
|
||||
const { recordAction } = require('./services/staffStats');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -250,6 +251,54 @@ function oauthSuspendIfPermanent(err, client) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email ticket persistence (Part A: game; Part B: reopen recording)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Upsert the email ticket record and, when wasReopened is true, fire-and-forget
|
||||
* a 'reopen' StaffAction with resolverId = the prior claimerId from the
|
||||
* returned doc (claimerId is never cleared by any close path).
|
||||
*
|
||||
* Injectables: _Ticket (Ticket model), _recordAction (staffStats.recordAction).
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
async function persistEmailTicket(fields, guildId, wasReopened, _Ticket, _recordAction) {
|
||||
const {
|
||||
threadId, discordThreadId, senderEmail, subject, createdAt,
|
||||
ticketNumber, priority, parentCategoryId, game
|
||||
} = fields;
|
||||
|
||||
const doc = await withRetry(() => _Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: threadId },
|
||||
{
|
||||
$set: {
|
||||
discordThreadId,
|
||||
senderEmail,
|
||||
subject,
|
||||
createdAt,
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority,
|
||||
lastActivity: createdAt,
|
||||
parentCategoryId,
|
||||
game
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
));
|
||||
|
||||
if (wasReopened && doc) {
|
||||
_recordAction('system', 'reopen', {
|
||||
ticket: doc,
|
||||
guildId,
|
||||
resolverId: doc.claimerId ?? null
|
||||
});
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Orchestrator
|
||||
// ============================================================
|
||||
@@ -287,6 +336,7 @@ async function poll(client) {
|
||||
.select('gmailThreadId discordThreadId status')
|
||||
.lean();
|
||||
|
||||
const wasClosedTicket = !!existing && existing.status === 'closed';
|
||||
let ticketChan = null;
|
||||
let parentCategoryIdForTicket = null;
|
||||
let isReopened = false;
|
||||
@@ -368,23 +418,23 @@ async function poll(client) {
|
||||
|
||||
const now = new Date();
|
||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
await withRetry(() => Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: parsed.threadId },
|
||||
await persistEmailTicket(
|
||||
{
|
||||
$set: {
|
||||
discordThreadId: ticketChan.id,
|
||||
senderEmail: parsed.senderEmail,
|
||||
subject: parsed.subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber: number,
|
||||
priority: defaultPriority,
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
}
|
||||
threadId: parsed.threadId,
|
||||
discordThreadId: ticketChan.id,
|
||||
senderEmail: parsed.senderEmail,
|
||||
subject: parsed.subject,
|
||||
createdAt: now,
|
||||
ticketNumber: number,
|
||||
priority: defaultPriority,
|
||||
parentCategoryId: parentCategoryIdForTicket,
|
||||
game: detectedGame
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
));
|
||||
guild.id,
|
||||
wasClosedTicket,
|
||||
Ticket,
|
||||
recordAction
|
||||
);
|
||||
|
||||
// New (or reopened) ticket: file the email thread into Triage — out of
|
||||
// the inbox, marked read, awaiting staff action. The threads.modify also
|
||||
@@ -404,4 +454,4 @@ async function poll(client) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { poll, setPollSuspended };
|
||||
module.exports = { poll, setPollSuspended, persistEmailTicket };
|
||||
|
||||
Reference in New Issue
Block a user