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:
@@ -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