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

187
tests/staffStats.test.js Normal file
View File

@@ -0,0 +1,187 @@
import { describe, it, expect, vi } from 'vitest';
// Stub debugLog so the import chain doesn't pull in discord.js / config.
vi.mock('../services/debugLog.js', () => ({
logError: vi.fn()
}));
import { recordAction, denormalizeTicket, deriveTicketType } from '../services/staffStats.js';
// ---------------------------------------------------------------------------
// deriveTicketType
// ---------------------------------------------------------------------------
describe('deriveTicketType', () => {
it('returns "discord" for discord- prefix', () => {
expect(deriveTicketType('discord-abc123')).toBe('discord');
});
it('returns "discord" for discord-msg- prefix', () => {
expect(deriveTicketType('discord-msg-abc123')).toBe('discord');
});
it('returns "email" for a Gmail thread ID', () => {
expect(deriveTicketType('18f3a2b1c0d4e5f6')).toBe('email');
});
it('returns "email" for null / undefined / empty gmailThreadId', () => {
expect(deriveTicketType(null)).toBe('email');
expect(deriveTicketType(undefined)).toBe('email');
expect(deriveTicketType('')).toBe('email');
});
});
// ---------------------------------------------------------------------------
// denormalizeTicket — field extraction
// ---------------------------------------------------------------------------
describe('denormalizeTicket', () => {
const emailTicket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 1,
priority: 'high',
game: 'Minecraft',
senderEmail: 'user@example.com',
creatorId: '111222333444555666',
claimerId: '999888777666555444'
};
const discordTicket = {
gmailThreadId: 'discord-msg-xyz789',
escalationTier: 0,
priority: 'normal',
game: null,
senderEmail: 'noreply@discord',
creatorId: '777666555444333222'
};
it('derives ticketType "email" for a Gmail thread', () => {
expect(denormalizeTicket(emailTicket).ticketType).toBe('email');
});
it('derives ticketType "discord" for a discord-msg- thread', () => {
expect(denormalizeTicket(discordTicket).ticketType).toBe('discord');
});
it('copies all standard event fields from the ticket', () => {
const f = denormalizeTicket(emailTicket);
expect(f.tier).toBe(1);
expect(f.priority).toBe('high');
expect(f.game).toBe('Minecraft');
expect(f.senderEmail).toBe('user@example.com');
expect(f.creatorId).toBe('111222333444555666');
expect(f.gmailThreadId).toBe('18f3a2b1c0d4e5f6');
});
it('defaults tier to 0 when escalationTier is absent', () => {
expect(denormalizeTicket({ gmailThreadId: 'abc' }).tier).toBe(0);
});
it('does NOT include guildId (must come from call site)', () => {
const f = denormalizeTicket(emailTicket);
expect(Object.prototype.hasOwnProperty.call(f, 'guildId')).toBe(false);
});
it('returns {} for a null ticket', () => {
expect(denormalizeTicket(null)).toEqual({});
});
});
// ---------------------------------------------------------------------------
// recordAction — payload merging / override precedence
// ---------------------------------------------------------------------------
describe('recordAction payload merging', () => {
const ticket = {
gmailThreadId: '18f3a2b1c0d4e5f6',
escalationTier: 2,
priority: 'medium',
game: 'Rust',
senderEmail: 'player@example.com',
creatorId: '100200300400500600'
};
it('payload fields override denormalized ticket fields', () => {
const { ticket: t, ...overrides } = {
ticket,
guildId: '555666777888999000',
game: 'OverriddenGame',
priority: 'low'
};
const merged = { ...denormalizeTicket(t), ...overrides };
expect(merged.game).toBe('OverriddenGame');
expect(merged.priority).toBe('low');
expect(merged.guildId).toBe('555666777888999000');
});
it('guildId (call-site only) passes through from payload', () => {
const { ticket: t, ...rest } = { ticket, guildId: '123456789012345678' };
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.guildId).toBe('123456789012345678');
});
it('close-only fields pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.closerType).toBe('staff');
expect(merged.resolverId).toBe('123456789012345678');
expect(merged.wasClaimed).toBe(true);
});
it('transfer-only fields (fromId/toId) pass through from payload', () => {
const { ticket: t, ...rest } = {
ticket,
fromId: '111111111111111111',
toId: '222222222222222222'
};
const merged = { ...denormalizeTicket(t), ...rest };
expect(merged.fromId).toBe('111111111111111111');
expect(merged.toId).toBe('222222222222222222');
});
});
// ---------------------------------------------------------------------------
// recordAction — fire-and-forget discipline
//
// In the test environment there is no real MongoDB connection and the
// StaffAction model schema is not registered, so mongoose.model('StaffAction')
// throws MissingSchemaError synchronously. This is exactly the kind of error
// recordAction must swallow — it proves the outer try/catch works. The async
// .catch() path is exercised transitively: any callers in later phases that
// succeed with a real DB connection will hit that path.
// ---------------------------------------------------------------------------
describe('recordAction fire-and-forget', () => {
it('returns undefined (callers do not await)', () => {
expect(recordAction('staff1', 'claim', {})).toBeUndefined();
});
it('does not throw when called with null / undefined / empty payload', () => {
expect(() => recordAction('staff1', 'reopen', undefined)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', null)).not.toThrow();
expect(() => recordAction('staff1', 'reopen', {})).not.toThrow();
});
it('does not throw even when the model layer errors (model not registered)', () => {
// mongoose.model('StaffAction') throws MissingSchemaError synchronously in
// this environment — recordAction must absorb it and never rethrow.
expect(() => recordAction('staff1', 'close', {
ticket: {
gmailThreadId: 'discord-abc',
escalationTier: 0,
priority: 'normal',
senderEmail: 'a@b.com',
creatorId: '123'
},
guildId: '999',
closerType: 'staff',
resolverId: '123456789012345678',
wasClaimed: true
})).not.toThrow();
});
});