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 6bae3e79b1
commit e77be9a3e4
28 changed files with 3447 additions and 124 deletions

View File

@@ -20,7 +20,9 @@ const ticketSchema = new mongoose.Schema({
claimerId: String,
creatorId: String,
parentCategoryId: String,
pendingDelete: { type: Boolean, default: false }
pendingDelete: { type: Boolean, default: false },
game: String,
closedAt: Date
});
ticketSchema.index({ status: 1, lastActivity: 1 });
ticketSchema.index({ senderEmail: 1, status: 1 });
@@ -61,3 +63,29 @@ mongoose.model('StaffSignature', new mongoose.Schema({
tagline: { type: String, default: '' },
updatedAt: { type: Date, default: Date.now }
}));
const staffActionSchema = new mongoose.Schema({
staffId: { type: String, required: true },
type: { type: String, required: true },
tier: { type: Number, default: 0 },
ticketType: String,
priority: String,
game: String,
senderEmail: String,
creatorId: String,
gmailThreadId: String,
guildId: String,
createdAt: { type: Date, default: Date.now },
// close-only
closerType: String,
resolverId: String,
wasClaimed: Boolean,
// transfer-only
fromId: String,
toId: String
});
staffActionSchema.index({ staffId: 1, createdAt: -1 });
staffActionSchema.index({ gmailThreadId: 1, createdAt: 1 });
mongoose.model('StaffAction', staffActionSchema);