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

View File

@@ -0,0 +1,245 @@
/**
* Phase 6 — staff response event recording tests.
*
* Follows the same injectable-parameter pattern as claimEvents.test.js:
* _TicketModel — controls Ticket DB layer (findOne, updateOne)
* _TagModel — controls Tag DB layer (findOne, updateOne) for /response send
* _recordAction — captures recording calls without any module mocking
* _isStaff — controls staff check result (messages.js path only)
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) handleDiscordReply — staff message in a discord ticket → one 'response' event
* (b) handleDiscordReply — staff message in an email ticket → one 'response' event
* (c) handleDiscordReply — bot message → no event
* (d) handleDiscordReply — non-staff message → no event
* (e) handleResponseSend — /response send in a ticket → one 'response' event
* (f) handleResponseSend — no ticket found → no event
* (g) handleResponseSend — tag not found → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { handleDiscordReply } from '../handlers/messages.js';
import { handleResponseSend } from '../handlers/commands/response.js';
// ---------------------------------------------------------------------------
// Shared factories — handleDiscordReply
// ---------------------------------------------------------------------------
function makeMessage(overrides = {}) {
return {
author: { bot: false, id: 'staff-001' },
interaction: null,
channel: { id: 'chan-001', name: 'ticket-chan-001' },
guild: {
id: 'guild-001',
members: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockRejectedValue(new Error('no members in test env'))
}
},
content: 'Hello customer',
id: 'msg-001',
...overrides
};
}
function makeTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
escalationTier: 0,
claimerId: null,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeMessageTicketModel(ticket) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 0 })
};
}
// ---------------------------------------------------------------------------
// (a) + (b) Staff message records one 'response' — discord + email tickets
// ---------------------------------------------------------------------------
describe('handleDiscordReply — staff message records response', () => {
it('records one "response" event for a discord ticket', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
it('records one "response" event for an email ticket (before the discord early-return)', async () => {
const ticket = makeTicket({ gmailThreadId: '18f3a2b1c0d4e5f6' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
// Gmail relay will fail but that's caught internally — record already fired.
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (c) Bot message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — bot message records nothing', () => {
it('records nothing when author.bot is true', async () => {
const mockModel = makeMessageTicketModel(makeTicket());
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
const m = makeMessage({ author: { bot: true, id: 'bot-001' } });
await handleDiscordReply(m, mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (d) Non-staff message → no event
// ---------------------------------------------------------------------------
describe('handleDiscordReply — non-staff message records nothing', () => {
it('records nothing when isStaff returns false', async () => {
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
const mockModel = makeMessageTicketModel(ticket);
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(false);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
it('records nothing when the message is not in a ticket channel', async () => {
const mockModel = makeMessageTicketModel(null); // no ticket
const mockRecord = vi.fn();
const stubStaff = vi.fn().mockReturnValue(true);
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Shared factories — handleResponseSend
// ---------------------------------------------------------------------------
function makeInteraction(overrides = {}) {
return {
user: {
id: 'staff-001',
username: 'staffuser',
toString: () => '<@staff-001>'
},
member: { displayName: 'Staff Member' },
guild: { id: 'guild-001', name: 'Test Guild', memberCount: 10 },
channel: { id: 'chan-001' },
options: { getString: vi.fn().mockReturnValue('my-tag') },
reply: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
function makeTagModel(tag) {
return {
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(tag) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 })
};
}
function makeResponseTicketModel(ticket) {
return { findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }) };
}
// ---------------------------------------------------------------------------
// (e) /response send in a ticket channel → one event
// ---------------------------------------------------------------------------
describe('handleResponseSend — records one "response" event', () => {
it('records staffId, guildId, and ticket when ticket is found', async () => {
const ticket = makeTicket();
const tag = { name: 'my-tag', content: 'Hello {ticket.user}', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(ticket),
mockRecord
);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-001');
expect(type).toBe('response');
expect(payload.ticket).toBe(ticket);
expect(payload.guildId).toBe('guild-001');
});
});
// ---------------------------------------------------------------------------
// (f) No ticket found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — no ticket records nothing', () => {
it('records nothing when no ticket exists for the channel', async () => {
const tag = { name: 'my-tag', content: 'Hello', useCount: 0 };
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(tag),
makeResponseTicketModel(null),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// (g) Tag not found → no event
// ---------------------------------------------------------------------------
describe('handleResponseSend — tag not found records nothing', () => {
it('records nothing when the tag does not exist', async () => {
const mockRecord = vi.fn();
await handleResponseSend(
makeInteraction(),
makeTagModel(null),
makeResponseTicketModel(makeTicket()),
mockRecord
);
expect(mockRecord).not.toHaveBeenCalled();
});
});