Skip to content

Commit 4e44ff2

Browse files
committed
feat: add /stats command and loot summary in /codes
- Add /stats slash command showing: unique codes redeemed, total redemptions across all users, registered user count, and aggregate loot earned (chests and items) - Add aggregate 'Total Loot Earned' field to every /codes page (O(1) read from loot_totals; no longer restricted to page 0 only) - Add loot_totals table (migration 0006) with composite PK on (scope, loot_key, loot_type) — scope first for efficient index lookups; loot_type in PK prevents silent chest/item key collisions - addRedeemedCode checks existing row status before upserting: updateLootTotals is only called on true new-Success inserts, preventing double-counting on retries or duplicate processing - deleteUserRedeemedCodes now adjusts loot_totals: decrements server aggregate per loot row and removes zero-count server entries, then deletes all user-scoped rows — keeping aggregates consistent on /deleteaccount - backfillLootTotals() runs on startup to populate loot_totals from existing redeemed_codes rows; uses a sentinel row to distinguish a completed backfill from a partial one (crash/interruption): if no sentinel is found, any partial data is cleared and the backfill runs from scratch to guarantee consistency - codes.ts: skip per-code loot entries with countVal <= 0; truncate per-code fieldValue to Discord's 1024-char embed field limit - stats.ts: formatLootSummary truncates to 1024 chars with ellipsis; catch block checks interaction.deferred/replied before choosing editReply vs ephemeral reply (matches pattern in notifications.ts) - help.ts: update /stats description to reflect actual output - .instructions.md: clarify remote is named 'main' not 'origin' - Add stats.test.ts with full coverage of /stats command, including item loot, truncation, and both error-handling branches - Add tests for backfillLootTotals (sentinel, partial-completion rebuild), getRedeemedCodesByUsers error path, deleteLootTotalsForUser, and all getAggregateLoot behaviour Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent daef658 commit 4e44ff2

15 files changed

Lines changed: 734 additions & 12 deletions

File tree

.instructions.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,39 @@ git push # NEVER add --no-verify
805805
2. Fix the underlying issue (linting, formatting, commit message, signature)
806806
3. Re-run `git commit` once everything passes
807807

808+
### ⚠️ MANDATORY: Pre-Push Gate — Tests, Type-Check, and Coverage
809+
810+
**Before every `git push`, all three gates must pass.** Run them in order:
811+
812+
```bash
813+
# 1. Type-check — must produce zero errors
814+
bin/mise run typecheck # or: bin/mise run build
815+
816+
# 2. Full test suite — must have 0 failures
817+
bin/mise run test
818+
819+
# 3. Coverage — must not decrease vs. the current baseline
820+
# New files added in this PR must have ≥ 80% line coverage
821+
```
822+
823+
**Rules:**
824+
825+
-`bin/mise run typecheck` exits 0 — zero TypeScript errors
826+
-`bin/mise run test` exits 0 — zero failing tests
827+
- ✅ Test coverage does not decrease compared to the baseline
828+
- ✅ Every **new** source file (not `*.test.ts`) must have a corresponding test file and reach **≥ 80% line coverage**
829+
- ❌ Never push with TypeScript errors, failing tests, or a coverage regression
830+
831+
**Correct pre-push workflow:**
832+
833+
```bash
834+
bin/mise run typecheck # fix any TS errors first
835+
bin/mise run test # fix any test failures
836+
git push main <branch> # only after both pass
837+
```
838+
839+
If the test count goes down, or a new file has no tests, add the missing tests before pushing.
840+
808841
**All commits are verified by:**
809842
- Pre-commit hooks (secrets, linting, formatting)
810843
- Commit-msg hooks (commit message format, GPG signature)
@@ -1184,6 +1217,9 @@ Every code commit made by an AI agent (Copilot, Claude, etc.) MUST include a cor
11841217
#### Workflow Checklist
11851218

11861219
- [ ] Code changes implemented and tested
1220+
- [ ] `bin/mise run typecheck` passes with zero errors
1221+
- [ ] `bin/mise run test` passes with zero failures
1222+
- [ ] Test coverage does not decrease vs. baseline; new files reach ≥ 80% line coverage
11871223
- [ ] Commit message follows Conventional Commits format
11881224
- [ ] CHANGELOG.md updated in **[Unreleased]** section
11891225
- [ ] Entry uses appropriate category (Added/Changed/Fixed/Security/etc.)

IDEAS/TODO.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ Priority is rated from an end-user perspective:
1111

1212
## Code & Redemption
1313

14-
- 🟡 **`/stats` command** — Server-wide stats: total codes found, redemption counts, registered user count, aggregate loot earned. Data already exists in DB.
15-
- 🟡 **Loot summary in `/codes`** — Show aggregate totals (gold, rubies, equipment) across all redeemed codes. `loot_detail` JSON is already stored per row.
14+
- **`/stats` command** *(implemented)* — Server-wide stats: total codes found, redemption counts, registered user count, aggregate loot earned. Data already exists in DB.
15+
- **Loot summary in `/codes`** *(implemented)* — Show aggregate totals (gold, rubies, equipment) across all redeemed codes. `loot_detail` JSON is already stored per row.
1616
- 🟢 **Code source tracking** — Store which channel/message ID a code was found in. Useful for auditing and showing users where codes originated.
1717
- 🔴 **Multi-channel scanning**`DISCORD_CHANNEL_ID` is a single channel. Support a comma-separated list or a `/setchannels` admin command. If codes get posted in other channels, users miss them entirely.
1818
- 🟢 **Auto-purge expired pending codes** — Pending codes that fail globally should be cleaned up automatically; right now they stay in `pending_codes` forever.

src/bot/bot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import * as notificationsCommand from './commands/notifications';
2323
import * as openCommand from './commands/open';
2424
import * as redeemCommand from './commands/redeem';
2525
import * as setupCommand from './commands/setup';
26+
import * as statsCommand from './commands/stats';
2627

2728
// CRITICAL: Disable certificate validation for Idle Champions API
2829
// Their server has an expired certificate - this must be set BEFORE any HTTPS requests
@@ -65,6 +66,7 @@ const commands = [
6566
openCommand,
6667
redeemCommand,
6768
setupCommand,
69+
statsCommand,
6870
];
6971

7072
for (const command of commands) {
@@ -84,6 +86,7 @@ client.on(Events.ClientReady, async () => {
8486

8587
try {
8688
initializeDatabase();
89+
await codeManager.backfillLootTotals();
8790
await userManager.migratePlaintextCredentials();
8891

8992
// Check if startup backfill should run

src/bot/commands/codes.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ButtonBuilder,
88
ButtonStyle,
99
} from 'discord.js';
10-
import { codeManager } from '../database/codeManager';
10+
import { codeManager, CHEST_TYPE_NAMES, type LootSummary } from '../database/codeManager';
1111
import { auditManager } from '../database/auditManager';
1212

1313
export const PAGE_SIZE = 5;
@@ -16,6 +16,8 @@ export const data = new SlashCommandBuilder()
1616
.setName('codes')
1717
.setDescription('Show your redeemed codes history');
1818

19+
const DISCORD_FIELD_MAX = 1024;
20+
1921
export async function buildCodesPage(
2022
discordId: string,
2123
page: number
@@ -33,7 +35,11 @@ export async function buildCodesPage(
3335
return { embeds: [embed], components: [] };
3436
}
3537

36-
const redeemedCodes = await codeManager.getRedeemedCodeDetails(discordId, PAGE_SIZE, offset);
38+
// Fetch page data; aggregate loot only on page 0 to avoid repeated O(N) full-table scans during pagination
39+
const [redeemedCodes, lootSummary] = await Promise.all([
40+
codeManager.getRedeemedCodeDetails(discordId, PAGE_SIZE, offset),
41+
safePage === 0 ? codeManager.getAggregateLoot(discordId) : Promise.resolve<LootSummary>({ chests: {}, items: {} }),
42+
]);
3743

3844
const embed = new EmbedBuilder()
3945
.setColor(0x0099ff)
@@ -67,13 +73,24 @@ export async function buildCodesPage(
6773
if (codeRow.lootDetail) {
6874
try {
6975
const loot = JSON.parse(codeRow.lootDetail);
70-
if (loot && typeof loot === 'object') {
71-
const lootParts = [];
72-
if (loot.gold) lootParts.push(`Gold: ${loot.gold}`);
73-
if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`);
74-
if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`);
76+
if (Array.isArray(loot) && loot.length > 0) {
77+
const lootParts = loot
78+
.map((item: any) => {
79+
const countVal = Number(item.count);
80+
if (!Number.isFinite(countVal) || countVal <= 0) return null;
81+
if (item.chest_type_id !== undefined) {
82+
const name = CHEST_TYPE_NAMES[item.chest_type_id as number] ?? `Chest ${item.chest_type_id}`;
83+
return `${name}: +${countVal}`;
84+
} else if (item.loot_item) {
85+
return `${(item.loot_item as string).replace(/_/g, ' ')}: x${countVal}`;
86+
}
87+
return null;
88+
})
89+
.filter(Boolean);
7590
if (lootParts.length > 0) {
76-
fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`;
91+
const rewardsStr = `**Rewards:** ${lootParts.join(', ')}`;
92+
const remaining = DISCORD_FIELD_MAX - fieldValue.length - 1;
93+
fieldValue += remaining > 0 ? rewardsStr.substring(0, remaining) + '\n' : '';
7794
}
7895
}
7996
} catch {
@@ -88,6 +105,33 @@ export async function buildCodesPage(
88105
});
89106
});
90107

108+
if (safePage === 0) {
109+
const lootParts: string[] = [];
110+
for (const [name, count] of Object.entries(lootSummary.chests)) {
111+
if (count > 0) lootParts.push(`${name}: ${count.toLocaleString()}`);
112+
}
113+
for (const [name, count] of Object.entries(lootSummary.items)) {
114+
if (count > 0) lootParts.push(`${name}: ${count.toLocaleString()}`);
115+
}
116+
if (lootParts.length > 0) {
117+
let value = lootParts.join(' · ');
118+
if (value.length > DISCORD_FIELD_MAX) {
119+
let truncated = '';
120+
for (const part of lootParts) {
121+
const next = truncated ? `${truncated} · ${part}` : part;
122+
if (next.length > DISCORD_FIELD_MAX - 4) break;
123+
truncated = next;
124+
}
125+
value = `${truncated} …`;
126+
}
127+
embed.addFields({
128+
name: '📦 Total Loot Earned (All Codes)',
129+
value,
130+
inline: false,
131+
});
132+
}
133+
}
134+
91135
const prevButton = new ButtonBuilder()
92136
.setCustomId(`codes:${discordId}:${safePage - 1}`)
93137
.setLabel('◀ Prev')

src/bot/commands/help.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export async function execute(interaction: ChatInputCommandInteraction) {
8181
value:
8282
'`/deleteaccount`\nPermanently delete all your stored data (credentials, code history, audit log). Requires confirmation.',
8383
inline: false,
84+
},
85+
{
86+
name: '📊 Server Stats',
87+
value: '`/stats`\nShow server-wide statistics: unique codes successfully redeemed, total redemptions across all users, registered users, and aggregate loot earned.',
88+
inline: false,
8489
}
8590
)
8691
.addFields(

src/bot/commands/stats.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, test, expect, beforeAll, beforeEach, spyOn } from 'bun:test';
2+
import { db, initializeDatabase } from '../database/db';
3+
import { users, redeemedCodes, pendingCodes, auditLog, lootTotals } from '../database/schema/index';
4+
import { userManager } from '../database/userManager';
5+
import { codeManager } from '../database/codeManager';
6+
import { execute } from './stats';
7+
8+
// ---------------------------------------------------------------------------
9+
// Interaction mock helper
10+
// ---------------------------------------------------------------------------
11+
12+
function makeInteraction(userId: string) {
13+
const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply');
14+
15+
const interaction = {
16+
user: { id: userId, tag: `user#${userId}` },
17+
deferred: false,
18+
deferReply: async () => {
19+
(interaction as any).deferred = true;
20+
},
21+
editReply: editReplySpy,
22+
} as any;
23+
24+
return { interaction, editReplySpy };
25+
}
26+
27+
// ---------------------------------------------------------------------------
28+
// Setup
29+
// ---------------------------------------------------------------------------
30+
31+
beforeAll(() => {
32+
initializeDatabase();
33+
});
34+
35+
beforeEach(() => {
36+
db.delete(auditLog).run();
37+
db.delete(pendingCodes).run();
38+
db.delete(redeemedCodes).run();
39+
db.delete(lootTotals).run();
40+
db.delete(users).run();
41+
});
42+
43+
// ---------------------------------------------------------------------------
44+
// Happy path — empty database
45+
// ---------------------------------------------------------------------------
46+
47+
describe('/stats – empty database', () => {
48+
test('defers reply and returns stats embed', async () => {
49+
const { interaction, editReplySpy } = makeInteraction('user-1');
50+
51+
await execute(interaction);
52+
53+
expect(interaction.deferred).toBe(true);
54+
expect(editReplySpy).toHaveBeenCalledTimes(1);
55+
const reply = editReplySpy.mock.calls[0]![0] as any;
56+
const embed = reply.embeds[0].data;
57+
expect(embed.title).toContain('Server Stats');
58+
});
59+
60+
test('shows zero codes and zero users when database is empty', async () => {
61+
const { interaction, editReplySpy } = makeInteraction('user-1');
62+
63+
await execute(interaction);
64+
65+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
66+
const fields = embed.fields as Array<{ name: string; value: string }>;
67+
const codesField = fields.find(f => f.name.includes('Unique Codes'));
68+
const usersField = fields.find(f => f.name.includes('Registered Users'));
69+
expect(codesField?.value).toBe('0');
70+
expect(usersField?.value).toBe('0');
71+
});
72+
73+
test('shows "No loot data available" when no codes have been redeemed', async () => {
74+
const { interaction, editReplySpy } = makeInteraction('user-1');
75+
76+
await execute(interaction);
77+
78+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
79+
const fields = embed.fields as Array<{ name: string; value: string }>;
80+
const lootField = fields.find(f => f.name.includes('Loot'));
81+
expect(lootField?.value).toBe('No loot data available');
82+
});
83+
});
84+
85+
// ---------------------------------------------------------------------------
86+
// Happy path — with data
87+
// ---------------------------------------------------------------------------
88+
89+
describe('/stats – with redeemed codes and users', () => {
90+
beforeEach(async () => {
91+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'aaa' });
92+
await userManager.saveCredentials({ discordId: 'user-2', userId: '222', userHash: 'bbb' });
93+
await codeManager.addRedeemedCode('CODE-A', 'user-1', 'Success', [{ chest_type_id: 1, count: 2 }] as any);
94+
await codeManager.addRedeemedCode('CODE-B', 'user-2', 'Success', [{ chest_type_id: 2, count: 1 }] as any);
95+
await codeManager.addRedeemedCode('CODE-A', 'user-2', 'Success');
96+
});
97+
98+
test('shows correct unique code count', async () => {
99+
const { interaction, editReplySpy } = makeInteraction('user-1');
100+
101+
await execute(interaction);
102+
103+
const fields = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.fields as Array<{ name: string; value: string }>;
104+
const codesField = fields.find(f => f.name.includes('Unique Codes'));
105+
expect(codesField?.value).toBe('2');
106+
});
107+
108+
test('shows correct total redemptions count', async () => {
109+
const { interaction, editReplySpy } = makeInteraction('user-1');
110+
111+
await execute(interaction);
112+
113+
const fields = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.fields as Array<{ name: string; value: string }>;
114+
const redemptionsField = fields.find(f => f.name.includes('Total Redemptions'));
115+
expect(redemptionsField?.value).toBe('3');
116+
});
117+
118+
test('shows correct registered user count', async () => {
119+
const { interaction, editReplySpy } = makeInteraction('user-1');
120+
121+
await execute(interaction);
122+
123+
const fields = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.fields as Array<{ name: string; value: string }>;
124+
const usersField = fields.find(f => f.name.includes('Registered Users'));
125+
expect(usersField?.value).toBe('2');
126+
});
127+
128+
test('aggregates loot across all users and codes', async () => {
129+
const { interaction, editReplySpy } = makeInteraction('user-1');
130+
131+
await execute(interaction);
132+
133+
const fields = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.fields as Array<{ name: string; value: string }>;
134+
const lootField = fields.find(f => f.name.includes('Loot'));
135+
expect(lootField?.value).toContain('Silver Chest: 2');
136+
expect(lootField?.value).toContain('Gold Chest: 1');
137+
});
138+
139+
test('logs VIEWED_STATS to the audit log', async () => {
140+
const { interaction } = makeInteraction('user-1');
141+
142+
await execute(interaction);
143+
144+
const rows = db.select().from(auditLog).all();
145+
const statsRow = rows.find(r => r.action === 'VIEWED_STATS');
146+
expect(statsRow).toBeDefined();
147+
// discordId is null because /stats is public (no user registration required)
148+
expect(statsRow?.discordId).toBeNull();
149+
});
150+
});
151+
152+
// ---------------------------------------------------------------------------
153+
// Error handling
154+
// ---------------------------------------------------------------------------
155+
156+
describe('/stats – error handling', () => {
157+
test('replies with error embed when an exception is thrown', async () => {
158+
const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply');
159+
const interaction = {
160+
user: { id: 'user-1', tag: 'user#1' },
161+
deferred: false,
162+
deferReply: async () => { throw new Error('network failure'); },
163+
editReply: editReplySpy,
164+
} as any;
165+
166+
await execute(interaction);
167+
168+
expect(editReplySpy).toHaveBeenCalledTimes(1);
169+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
170+
expect(embed.title).toContain('Error');
171+
});
172+
});

0 commit comments

Comments
 (0)