Skip to content

Commit 86fa59c

Browse files
committed
feat: paginate /codes with prev/next buttons
- Remove the count option from /codes (was capped at 20) - Add getRedeemedCodeCount() to codeManager for total page calculation - Add offset parameter to getRedeemedCodeDetails() for slicing - Build pages of 5 codes with disabled-aware ◀ Prev / Next ▶ buttons - Button custom IDs encode the owner's Discord ID so only they can page - Handle button interactions in bot.ts via a dedicated InteractionCreate listener - Add tests: limit, offset/pagination, count (8 new tests, 169 pass total) Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent b0903f7 commit 86fa59c

5 files changed

Lines changed: 201 additions & 79 deletions

File tree

IDEAS/TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Priority is rated from an end-user perspective:
2525
-**DM notifications for new codes** *(implemented)* — Opt-in per user to get a DM when a code is *detected*, independent of whether autoredeem is on. Users who want to redeem manually currently have no way to know a code appeared.
2626
-**Notification preferences command** *(implemented)* — Let users configure: DM on success, DM on failure, DM when a code they haven't claimed is about to expire. High value as DM spam is a common complaint with bots.
2727
- 🔴 **Better blacksmith UX**`/blacksmith` requires a raw hero ID. Inventory data from `getUserDetails` includes hero names — show a hero picker or list. Current UX is nearly unusable without looking up IDs externally.
28-
- 🟢 **Paginated `/codes`** — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience.
28+
- **Paginated `/codes`** *(implemented)* — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience.
2929

3030
---
3131

src/bot/bot.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as blacksmithCommand from './commands/blacksmith';
1414
import * as deleteaccountCommand from './commands/deleteaccount';
1515
import * as catchupCommand from './commands/catchup';
1616
import * as codesCommand from './commands/codes';
17+
import { buildCodesPage } from './commands/codes';
1718
import * as autoredeemCommand from './commands/autoredeem';
1819
import * as helpCommand from './commands/help';
1920
import * as inventoryCommand from './commands/inventory';
@@ -181,6 +182,39 @@ client.on(Events.InteractionCreate, async (interaction) => {
181182
}
182183
});
183184

185+
// Event: Button interactions (e.g. /codes pagination)
186+
client.on(Events.InteractionCreate, async (interaction) => {
187+
if (!interaction.isButton()) return;
188+
189+
if (interaction.customId.startsWith('codes:')) {
190+
const parts = interaction.customId.split(':');
191+
const ownerId = parts[1];
192+
const page = parseInt(parts[2] ?? '0', 10);
193+
194+
// Only the user who ran /codes can page through their results
195+
if (interaction.user.id !== ownerId) {
196+
await interaction.reply({
197+
content: '❌ These buttons are not for you.',
198+
flags: MessageFlags.Ephemeral,
199+
});
200+
return;
201+
}
202+
203+
try {
204+
await interaction.deferUpdate();
205+
const { embeds, components } = await buildCodesPage(ownerId, page);
206+
await interaction.editReply({ embeds, components });
207+
} catch (error) {
208+
logger.error('[CODES] Button pagination error:', error);
209+
await interaction.editReply({
210+
content: '❌ Failed to load page.',
211+
embeds: [],
212+
components: [],
213+
});
214+
}
215+
}
216+
});
217+
184218
// Event: Message (code scanning)
185219
client.on(Events.MessageCreate, async (message) => {
186220
// Only scan in the specified channel if configured

src/bot/commands/codes.ts

Lines changed: 95 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,98 +3,116 @@ import {
33
ChatInputCommandInteraction,
44
EmbedBuilder,
55
MessageFlags,
6+
ActionRowBuilder,
7+
ButtonBuilder,
8+
ButtonStyle,
69
} from 'discord.js';
710
import { codeManager } from '../database/codeManager';
811
import { auditManager } from '../database/auditManager';
912

13+
export const PAGE_SIZE = 5;
14+
1015
export const data = new SlashCommandBuilder()
1116
.setName('codes')
12-
.setDescription('Show your redeemed codes history (last 10)')
13-
.addNumberOption((option) =>
14-
option
15-
.setName('count')
16-
.setDescription('Number of codes to show (1-20)')
17-
.setMinValue(1)
18-
.setMaxValue(20)
19-
.setRequired(false)
20-
);
17+
.setDescription('Show your redeemed codes history');
18+
19+
export async function buildCodesPage(
20+
discordId: string,
21+
page: number
22+
): Promise<{ embeds: EmbedBuilder[]; components: ActionRowBuilder<ButtonBuilder>[] }> {
23+
const total = await codeManager.getRedeemedCodeCount(discordId);
24+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
25+
const safePage = Math.max(0, Math.min(page, totalPages - 1));
26+
const offset = safePage * PAGE_SIZE;
27+
28+
if (total === 0) {
29+
const embed = new EmbedBuilder()
30+
.setColor(0xffaa00)
31+
.setTitle('📝 Redeemed Codes History')
32+
.setDescription("You haven't redeemed any codes yet.");
33+
return { embeds: [embed], components: [] };
34+
}
2135

22-
export async function execute(interaction: ChatInputCommandInteraction) {
23-
try {
24-
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
36+
const redeemedCodes = await codeManager.getRedeemedCodeDetails(discordId, PAGE_SIZE, offset);
37+
38+
const embed = new EmbedBuilder()
39+
.setColor(0x0099ff)
40+
.setTitle('📝 Your Redeemed Codes')
41+
.setFooter({ text: `Page ${safePage + 1} of ${totalPages} · ${total} total` });
42+
43+
redeemedCodes.forEach((codeRow, index) => {
44+
const statusLower = (codeRow.status || 'unknown').toLowerCase();
45+
const statusEmoji =
46+
{
47+
success: '✅',
48+
'code expired': '❌',
49+
error: '⚠️',
50+
}[statusLower] || '❓';
51+
52+
const dateStr = codeRow.redeemedAt
53+
? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', {
54+
month: 'short',
55+
day: 'numeric',
56+
year: '2-digit',
57+
hour: '2-digit',
58+
minute: '2-digit',
59+
})
60+
: 'Unknown';
61+
62+
const publicBadge = codeRow.isPublic ? ' 🌐' : '';
63+
64+
let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`;
65+
fieldValue += `**Redeemed:** ${dateStr}\n`;
66+
67+
if (codeRow.lootDetail) {
68+
try {
69+
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}`);
75+
if (lootParts.length > 0) {
76+
fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`;
77+
}
78+
}
79+
} catch {
80+
// Skip if loot detail is not valid JSON
81+
}
82+
}
2583

26-
const count = interaction.options.getNumber('count', false) || 10;
84+
embed.addFields({
85+
name: `${offset + index + 1}. ${codeRow.code}${publicBadge}`,
86+
value: fieldValue,
87+
inline: false,
88+
});
89+
});
2790

28-
// Log action
29-
await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', { count });
91+
const prevButton = new ButtonBuilder()
92+
.setCustomId(`codes:${discordId}:${safePage - 1}`)
93+
.setLabel('◀ Prev')
94+
.setStyle(ButtonStyle.Secondary)
95+
.setDisabled(safePage === 0);
3096

31-
const redeemedCodes = await codeManager.getRedeemedCodeDetails(interaction.user.id, count);
97+
const nextButton = new ButtonBuilder()
98+
.setCustomId(`codes:${discordId}:${safePage + 1}`)
99+
.setLabel('Next ▶')
100+
.setStyle(ButtonStyle.Secondary)
101+
.setDisabled(safePage >= totalPages - 1);
32102

33-
if (redeemedCodes.length === 0) {
34-
const embed = new EmbedBuilder()
35-
.setColor(0xffaa00)
36-
.setTitle('📝 Redeemed Codes History')
37-
.setDescription("You haven't redeemed any codes yet.");
103+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(prevButton, nextButton);
38104

39-
await interaction.editReply({ embeds: [embed] });
40-
return;
41-
}
105+
return { embeds: [embed], components: [row] };
106+
}
42107

43-
const embed = new EmbedBuilder()
44-
.setColor(0x0099ff)
45-
.setTitle('📝 Your Redeemed Codes')
46-
.setDescription(`Showing your last ${redeemedCodes.length} redeemed codes`)
47-
.setFooter({ text: `Total shown: ${redeemedCodes.length}` });
48-
49-
redeemedCodes.forEach((codeRow, index) => {
50-
const statusLower = (codeRow.status || 'unknown').toLowerCase();
51-
const statusEmoji =
52-
{
53-
success: '✅',
54-
expired: '❌',
55-
error: '⚠️',
56-
}[statusLower] || '❓';
57-
58-
const dateStr = codeRow.redeemedAt
59-
? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', {
60-
month: 'short',
61-
day: 'numeric',
62-
year: '2-digit',
63-
hour: '2-digit',
64-
minute: '2-digit',
65-
})
66-
: 'Unknown';
67-
68-
const publicBadge = codeRow.isPublic ? ' 🌐 (Public)' : '';
69-
70-
let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`;
71-
fieldValue += `**Redeemed:** ${dateStr}\n`;
72-
73-
if (codeRow.lootDetail) {
74-
try {
75-
const loot = JSON.parse(codeRow.lootDetail);
76-
if (loot && typeof loot === 'object') {
77-
const lootParts = [];
78-
if (loot.gold) lootParts.push(`Gold: ${loot.gold}`);
79-
if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`);
80-
if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`);
81-
if (lootParts.length > 0) {
82-
fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`;
83-
}
84-
}
85-
} catch {
86-
// Skip if loot detail is not valid JSON
87-
}
88-
}
108+
export async function execute(interaction: ChatInputCommandInteraction) {
109+
try {
110+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
89111

90-
embed.addFields({
91-
name: `${index + 1}. ${codeRow.code}${publicBadge}`,
92-
value: fieldValue,
93-
inline: false,
94-
});
95-
});
112+
await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', {});
96113

97-
await interaction.editReply({ embeds: [embed] });
114+
const { embeds, components } = await buildCodesPage(interaction.user.id, 0);
115+
await interaction.editReply({ embeds, components });
98116
} catch (error) {
99117
console.error('[CODES] Error:', error);
100118

src/bot/database/codeManager.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,66 @@ describe('getRedeemedCodeDetails', () => {
345345
expect(details).toHaveLength(2);
346346
expect(details.every((r) => r.discordId === USER_A)).toBe(true);
347347
});
348+
349+
test('respects the limit parameter', async () => {
350+
for (let i = 1; i <= 7; i++) {
351+
await codeManager.addRedeemedCode(`CODE${String(i).padStart(4, '0')}ZZZZ`, USER_A, 'Success');
352+
}
353+
const details = await codeManager.getRedeemedCodeDetails(USER_A, 3);
354+
expect(details).toHaveLength(3);
355+
});
356+
357+
test('respects the offset parameter for page 2', async () => {
358+
// Insert 7 codes — SQLite preserves insertion order for equal timestamps,
359+
// but we want deterministic ordering so we verify via set membership.
360+
const allCodes = ['AAAAAAAAAAAA', 'BBBBBBBBBBBB', 'CCCCCCCCCCCC', 'DDDDDDDDDDDD', 'EEEEEEEEEEEE', 'FFFFFFFFFFFF', 'GGGGGGGGGGGG'];
361+
for (const code of allCodes) {
362+
await codeManager.addRedeemedCode(code, USER_A, 'Success');
363+
}
364+
const page1 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 0);
365+
const page2 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 5);
366+
expect(page1).toHaveLength(5);
367+
expect(page2).toHaveLength(2);
368+
// Pages must not overlap
369+
const page1Codes = new Set(page1.map((r) => r.code));
370+
for (const row of page2) {
371+
expect(page1Codes.has(row.code)).toBe(false);
372+
}
373+
});
374+
375+
test('returns empty array when offset exceeds total', async () => {
376+
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
377+
const details = await codeManager.getRedeemedCodeDetails(USER_A, 5, 100);
378+
expect(details).toHaveLength(0);
379+
});
380+
});
381+
382+
// ---------------------------------------------------------------------------
383+
// getRedeemedCodeCount
384+
// ---------------------------------------------------------------------------
385+
describe('getRedeemedCodeCount', () => {
386+
test('returns 0 when user has no codes', async () => {
387+
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(0);
388+
});
389+
390+
test('returns correct count for a single user', async () => {
391+
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
392+
await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired');
393+
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(2);
394+
});
395+
396+
test('does not count codes belonging to another user', async () => {
397+
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
398+
await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Success');
399+
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(1);
400+
});
401+
402+
test('counts all statuses (Success, Code Expired, etc.)', async () => {
403+
await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success');
404+
await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired');
405+
await codeManager.addRedeemedCode('CODE3333CCCC', USER_A, 'Already Redeemed');
406+
expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(3);
407+
});
348408
});
349409

350410
// ---------------------------------------------------------------------------

src/bot/database/codeManager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,23 @@ class CodeManager {
149149
return results.map((r) => r.code);
150150
}
151151

152-
async getRedeemedCodeDetails(discordId: string, limit: number = 10): Promise<RedeemedCodeRow[]> {
152+
async getRedeemedCodeCount(discordId: string): Promise<number> {
153+
const result = db
154+
.select({ count: sql<number>`COUNT(*)` })
155+
.from(redeemedCodes)
156+
.where(eq(redeemedCodes.discordId, discordId))
157+
.get();
158+
return result?.count ?? 0;
159+
}
160+
161+
async getRedeemedCodeDetails(discordId: string, limit: number = 10, offset: number = 0): Promise<RedeemedCodeRow[]> {
153162
return db
154163
.select()
155164
.from(redeemedCodes)
156165
.where(eq(redeemedCodes.discordId, discordId))
157166
.orderBy(sql`${redeemedCodes.redeemedAt} DESC`)
158167
.limit(limit)
168+
.offset(offset)
159169
.all();
160170
}
161171

0 commit comments

Comments
 (0)