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 0fcffe8d33
commit cdb5db0082
28 changed files with 3447 additions and 124 deletions

171
tests/claimEvents.test.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* Phase 5a — claim event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) fresh claim — modifiedCount 1 → exactly one 'claim' event
* (b) no-op re-claim — modifiedCount 0 (same user) → no event
* (c) conditional filter — filter must exclude tickets already claimed by actor
* (d) tier captured — escalationTier from ticket at claim time
*/
import { describe, it, expect, vi } from 'vitest';
import { applyClaim } from '../handlers/buttons.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeInteraction(userId = 'staff-001') {
return {
user: {
id: userId,
username: 'staffuser',
toString: () => `<@${userId}>`
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001' },
channel: { id: 'chan-001' },
update: vi.fn().mockResolvedValue(undefined),
followUp: vi.fn().mockResolvedValue(undefined)
};
}
function makeGuild() {
return {
members: {
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
}
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
claimedBy: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
ticketNumber: 42,
...overrides
};
}
function makeBtn() {
const btn = {};
const chain = () => btn;
btn.setCustomId = chain;
btn.setLabel = chain;
btn.setEmoji = chain;
btn.setStyle = chain;
btn.setDisabled = chain;
return btn;
}
// ---------------------------------------------------------------------------
// (a) Fresh claim — real transition
// ---------------------------------------------------------------------------
describe('applyClaim — fresh claim emits one event', () => {
it('emits exactly one "claim" event with the correct staffId', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('claim');
});
it('passes the ticket doc so the recorder can denormalize fields', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (b) No-op re-claim — same user double-click, modifiedCount 0
// ---------------------------------------------------------------------------
describe('applyClaim — no-op re-claim emits no event', () => {
it('emits no event when modifiedCount is 0', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: 'staff-001' });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (c) Conditional filter — DB write must exclude same-user claims
// ---------------------------------------------------------------------------
describe('applyClaim — conditional filter', () => {
it('includes claimerId $ne the acting user in the updateOne filter', async () => {
const ticket = makeTicket({ claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, vi.fn());
const [filter] = mockUpdateOne.mock.calls[0];
expect(filter).toMatchObject({
gmailThreadId: 'discord-test-001',
claimerId: { $ne: 'staff-001' }
});
});
});
// ---------------------------------------------------------------------------
// (d) Tier captured at claim time
// ---------------------------------------------------------------------------
describe('applyClaim — tier captured at claim time', () => {
it('passes the ticket with escalationTier=1 when the ticket is escalated', async () => {
const ticket = makeTicket({ escalationTier: 1, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(1);
});
it('passes the ticket with escalationTier=0 for a non-escalated ticket', async () => {
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
const mockRecord = vi.fn();
const interaction = makeInteraction('staff-001');
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.ticket.escalationTier).toBe(0);
});
});