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

350
tests/statsHandler.test.js Normal file
View File

@@ -0,0 +1,350 @@
/**
* Phase 10 — /stats command handler tests.
* Injectable deps — no vi.mock.
*/
import { describe, it, expect, vi } from 'vitest';
import { MessageFlags } from 'discord.js';
import { handleStats, handleStatsAutocomplete } from '../handlers/commands/stats.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const NOW_MS = 1_700_000_000_000; // fixed epoch for deterministic cutoff assertions
// ---------------------------------------------------------------------------
// Factories
// ---------------------------------------------------------------------------
function makeInteraction({ userId = 'caller-001', memberUserId, memberUsername, periodStr, source } = {}) {
return {
user: { id: userId, username: 'testuser' },
guildId: 'guild-001',
options: {
getUser: (name) => {
if (name === 'member' && memberUserId) {
return { id: memberUserId, username: memberUsername || 'member-user' };
}
return null;
},
getString: (name) => {
if (name === 'period') return periodStr || null;
if (name === 'source') return source || null;
return null;
}
},
reply: vi.fn().mockResolvedValue(undefined)
};
}
function makeStaffAction(events = []) {
return {
find: vi.fn().mockReturnValue({ lean: () => Promise.resolve(events) })
};
}
function captureStaffAction() {
let capturedFilter;
return {
sa: {
find: (filter) => {
capturedFilter = filter;
return { lean: () => Promise.resolve([]) };
}
},
getFilter: () => capturedFilter
};
}
function deps(overrides = {}) {
return {
StaffAction: makeStaffAction(),
now: () => NOW_MS,
adminIds: [],
...overrides
};
}
// ---------------------------------------------------------------------------
// handleStatsAutocomplete
// ---------------------------------------------------------------------------
describe('handleStatsAutocomplete', () => {
function makeAutoInteraction(focusedValue = '') {
return {
options: { getFocused: () => focusedValue },
respond: vi.fn().mockResolvedValue(undefined)
};
}
it('returns all 5 presets when focused input is empty', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions).toHaveLength(5);
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).toContain('3 months');
expect(values).toContain('6 months');
expect(values).toContain('1 year');
});
it('filters to presets matching the typed substring', async () => {
const i = makeAutoInteraction('days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
const values = suggestions.map(s => s.value);
expect(values).toContain('7 days');
expect(values).toContain('30 days');
expect(values).not.toContain('3 months');
expect(values).not.toContain('1 year');
});
it('echoes typed input as first suggestion when it does not exactly match a preset', async () => {
const i = makeAutoInteraction('14d');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions[0].value).toBe('14d');
expect(suggestions[0].name).toBe('14d');
});
it('does not duplicate a preset when typed input exactly matches one', async () => {
const i = makeAutoInteraction('30 days');
await handleStatsAutocomplete(i);
const [[suggestions]] = i.respond.mock.calls;
expect(suggestions.filter(s => s.value === '30 days')).toHaveLength(1);
});
it('calls interaction.respond exactly once', async () => {
const i = makeAutoInteraction('');
await handleStatsAutocomplete(i);
expect(i.respond).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// handleStats — gating (STATS_ADMIN_IDS)
// ---------------------------------------------------------------------------
describe('handleStats — gating', () => {
it('caller views own stats when no member option is provided', async () => {
const interaction = makeInteraction({ userId: 'caller-001' });
await handleStats(interaction, deps());
expect(interaction.reply).toHaveBeenCalledTimes(1);
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('admin can view another member\'s stats', async () => {
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: ['admin-001'] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toBeDefined();
expect(replyArg.content).toBeUndefined();
});
it('non-admin is blocked with the exact error message when member option is set', async () => {
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBe('You can only view your own stats.');
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
expect(replyArg.embeds).toBeUndefined();
});
it('non-admin can view their own stats (no member option)', async () => {
const interaction = makeInteraction({ userId: 'plain-001' });
await handleStats(interaction, deps({ adminIds: [] }));
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.content).toBeUndefined();
expect(replyArg.embeds).toBeDefined();
});
it('StaffAction is never queried when the non-admin gate fires', async () => {
const sa = makeStaffAction();
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
await handleStats(interaction, deps({ StaffAction: sa, adminIds: [] }));
expect(sa.find).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// handleStats — period default (no option → 30 days)
// ---------------------------------------------------------------------------
describe('handleStats — period default', () => {
it('uses a 30-day cutoff when no period option is given', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const expectedCutoff = new Date(NOW_MS - 30 * MS_PER_DAY);
expect(getFilter().createdAt.$gte.getTime()).toBe(expectedCutoff.getTime());
});
it('embed title includes "30 days" when no period option', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('30 days');
});
it('embed title includes the user-supplied period label', async () => {
const interaction = makeInteraction({ periodStr: '7 days' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('7 days');
});
});
// ---------------------------------------------------------------------------
// handleStats — source filter passthrough
// ---------------------------------------------------------------------------
describe('handleStats — source filter', () => {
it('source="email" counts only email events', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction({ source: 'email' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
// Only 1 email claim should be counted
expect(claimsField.value).toMatch(/^1/);
});
it('source label appears in embed description', async () => {
const interaction = makeInteraction({ source: 'discord' });
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('discord');
});
it('omitted source defaults to "all" and shows "all sources" in description', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.description).toContain('all sources');
});
});
// ---------------------------------------------------------------------------
// handleStats — StaffAction query filter shape
// ---------------------------------------------------------------------------
describe('handleStats — query filter shape', () => {
it('$or includes all 4 target fields', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'user-001' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().$or).toHaveLength(4);
expect(getFilter().$or).toContainEqual({ staffId: 'user-001' });
expect(getFilter().$or).toContainEqual({ resolverId: 'user-001' });
expect(getFilter().$or).toContainEqual({ toId: 'user-001' });
expect(getFilter().$or).toContainEqual({ fromId: 'user-001' });
});
it('createdAt.$gte is a Date instance', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
expect(getFilter().createdAt.$gte).toBeInstanceOf(Date);
});
it('admin querying another member uses member id (not admin id) in $or', async () => {
const { sa, getFilter } = captureStaffAction();
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: ['admin-001'] });
expect(getFilter().$or).toContainEqual({ staffId: 'other-002' });
expect(getFilter().$or).not.toContainEqual({ staffId: 'admin-001' });
});
});
// ---------------------------------------------------------------------------
// handleStats — embed output reflects shapeStats with tier labels
// ---------------------------------------------------------------------------
describe('handleStats — embed content', () => {
it('reply is ephemeral', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
});
it('embed title contains the target username', async () => {
const interaction = makeInteraction({ userId: 'caller-001' }); // username = 'testuser'
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds[0].data.title).toContain('testuser');
});
it('escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'escalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'escalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('de-escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
const events = [
{ staffId: 'caller-001', type: 'deescalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'deescalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'De-escalations');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('claims-while-escalated sub-breakdown uses tier labels in Claims field', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(field.value).toContain('Tier 2');
expect(field.value).toContain('Tier 3');
});
it('empty stats returns a valid zero-filled embed without throwing', async () => {
const interaction = makeInteraction();
await handleStats(interaction, deps());
const [[replyArg]] = interaction.reply.mock.calls;
expect(replyArg.embeds).toHaveLength(1);
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
expect(claimsField.value).toMatch(/^0/);
const escalField = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
expect(escalField.value).toBe('0');
});
it('Email / Discord split field reflects bySource from shapeStats', async () => {
const events = [
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
];
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
const interaction = makeInteraction();
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
const [[replyArg]] = interaction.reply.mock.calls;
const splitField = replyArg.embeds[0].data.fields.find(f => f.name === 'Email / Discord split');
expect(splitField.value).toContain('Email');
expect(splitField.value).toContain('Discord');
});
});