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

310
tests/closeEvents.test.js Normal file
View File

@@ -0,0 +1,310 @@
/**
* Phase 4 — close event recording tests.
*
* Follows the same injectable-parameter pattern as closeTransition.test.js:
* _TicketModel — controls the DB layer (updateOne / findOne / find)
* _recordAction — captures recording calls without any module mocking
*
* No vi.mock needed; all dependencies injected directly.
*
* Covers:
* (a) staff force-close — finalizeForceClose, closerId present in pendingCloses
* (b) system auto-close — reconcileDeletedTicketChannels, channel absent
* (c) no-op close — transitioned=false → no event
*/
import { describe, it, expect, vi } from 'vitest';
import { finalizeForceClose } from '../handlers/commands/close.js';
import { reconcileDeletedTicketChannels, checkAutoClose } from '../services/tickets.js';
// ---------------------------------------------------------------------------
// Shared factories
// ---------------------------------------------------------------------------
function makeOpenTicket(overrides = {}) {
return {
gmailThreadId: 'discord-test-001',
discordThreadId: 'chan-test-001',
claimerId: 'claimer-001',
claimedBy: 'ClaimerName',
status: 'open',
createdAt: new Date('2026-01-01'),
escalationTier: 0,
priority: 'normal',
game: 'TestGame',
senderEmail: 'user@example.com',
creatorId: 'creator-001',
...overrides
};
}
function makeClosedTicket(openTicket) {
return { ...openTicket, status: 'closed', closedAt: new Date() };
}
/**
* Minimal mock model for reconcile tests (only needs find / updateOne / findOne).
*
* @param {object[]} openTickets — rows returned by find()
* @param {object} closedTicket — doc returned by findOne after transition
* @param {number} modifiedCount — 1 = transition succeeded, 0 = no-op
*/
function makeReconcileModel(openTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(openTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
/**
* Minimal mock model for finalizeForceClose.
* findOne is called twice:
* 1st — freshTicket lookup in finalizeForceClose itself
* 2nd — post-transition fetch inside attemptCloseTransition
*/
function makeForceCloseModel(freshTicket, closedTicket, modifiedCount = 1) {
return {
find: vi.fn(),
findOne: vi.fn()
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(freshTicket) })
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
function makeGuild(id = 'guild-001') {
return {
id,
channels: {
cache: { get: vi.fn().mockReturnValue(null) },
fetch: vi.fn().mockResolvedValue(null)
}
};
}
function makeClient(guild) {
return {
guilds: {
cache: {
get: vi.fn().mockReturnValue(null),
first: vi.fn().mockReturnValue(guild)
}
}
};
}
/** Channel mock with send() so enqueueSend doesn't reject immediately. */
function makeChannelRef(id = 'chan-test-001', guildId = 'guild-001') {
return {
id,
name: `ticket-${id}`,
guild: { id: guildId },
send: vi.fn().mockResolvedValue({ id: 'sent-msg' }),
delete: vi.fn().mockResolvedValue(undefined),
messages: undefined // triggers transcript error (caught internally)
};
}
function makeClientRef() {
return {
channels: { fetch: vi.fn().mockResolvedValue(null) }
};
}
/**
* Minimal mock model for checkAutoClose tests.
* Mirrors makeReconcileModel — same shape, renamed for clarity.
*/
function makeAutoCloseModel(staleTickets, closedTicket, modifiedCount = 1) {
const chain = {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockResolvedValue(staleTickets)
};
return {
find: vi.fn().mockReturnValue(chain),
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
};
}
// ===========================================================================
// (a) Staff force-close
// ===========================================================================
describe('finalizeForceClose — staff close', () => {
it('emits one "close" event with closerType "staff", correct staffId / resolverId / wasClaimed', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-001', discordThreadId: 'chan-staff-001' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map([['chan-staff-001', { closerId: 'staff-user-001', username: 'Staff#0001' }]]);
await finalizeForceClose(makeChannelRef('chan-staff-001', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('staff-user-001');
expect(type).toBe('close');
expect(payload.closerType).toBe('staff');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('uses closerType "system" and staffId "system" when no closerId in pendingCloses', async () => {
const open = makeOpenTicket({ gmailThreadId: 'discord-test-002', discordThreadId: 'chan-sys-002' });
const closed = makeClosedTicket(open);
const model = makeForceCloseModel(open, closed, 1);
const mockRecord = vi.fn();
const pc = new Map(); // no entry for this channel
await finalizeForceClose(makeChannelRef('chan-sys-002', 'guild-001'), makeClientRef(), model, mockRecord, pc);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, , payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(payload.closerType).toBe('system');
});
});
// ===========================================================================
// (b) System auto-close: reconcileDeletedTicketChannels
// ===========================================================================
describe('reconcileDeletedTicketChannels — system close', () => {
it('emits one "close" event with closerType "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('reflects wasClaimed=false and resolverId=null for an unclaimed ticket', async () => {
const open = makeOpenTicket({ claimerId: null, claimedBy: null });
const closed = makeClosedTicket(open);
const model = makeReconcileModel([open], closed, 1);
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [, , payload] = mockRecord.mock.calls[0];
expect(payload.resolverId).toBeNull();
expect(payload.wasClaimed).toBe(false);
});
});
// ===========================================================================
// (c) No-op close — idempotency
// ===========================================================================
describe('no-op close — idempotency', () => {
it('emits no event when the ticket channel still exists (reconcile skips close path)', async () => {
const open = makeOpenTicket();
const model = {
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue([open]) }),
findOne: vi.fn(),
updateOne: vi.fn()
};
const mockRecord = vi.fn();
const guild = {
id: 'guild-001',
channels: {
cache: { get: vi.fn().mockReturnValue({ id: open.discordThreadId }) }, // channel IS present
fetch: vi.fn()
}
};
await reconcileDeletedTicketChannels(makeClient(guild), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
expect(model.updateOne).not.toHaveBeenCalled();
});
it('emits no event when attemptCloseTransition reports transitioned=false (ticket was already closed)', async () => {
const open = makeOpenTicket();
const model = makeReconcileModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
expect(mockRecord).not.toHaveBeenCalled();
});
});
// ===========================================================================
// (d) System auto-close: checkAutoClose
// ===========================================================================
const TEST_CONFIG = { AUTO_CLOSE_ENABLED: true, AUTO_CLOSE_AFTER_HOURS: 72, DISCORD_AUTO_CLOSE_MESSAGE: 'closing' };
describe('checkAutoClose — system close', () => {
it('emits one "close" event with closerType "system", staffId "system", correct resolverId and wasClaimed', async () => {
const open = makeOpenTicket();
const closed = makeClosedTicket(open);
const model = makeAutoCloseModel([open], closed, 1);
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).toHaveBeenCalledTimes(1);
const [staffId, type, payload] = mockRecord.mock.calls[0];
expect(staffId).toBe('system');
expect(type).toBe('close');
expect(payload.closerType).toBe('system');
expect(payload.resolverId).toBe('claimer-001');
expect(payload.wasClaimed).toBe(true);
expect(payload.guildId).toBe('guild-001');
expect(payload.ticket).toBe(closed);
});
it('emits no event when attemptCloseTransition reports transitioned=false', async () => {
const open = makeOpenTicket();
const model = makeAutoCloseModel([open], null, 0); // modifiedCount 0 → no transition
const mockRecord = vi.fn();
const deps = {
config: TEST_CONFIG,
withRetry: fn => fn(),
enqueueSend: vi.fn().mockResolvedValue(undefined),
scheduleDelete: vi.fn()
};
const channel = { id: open.discordThreadId, send: vi.fn() };
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
expect(mockRecord).not.toHaveBeenCalled();
});
});