From 72b05484907bc0d91095f3d707fafcb9ee397e08 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Sun, 17 May 2026 10:14:01 +0200 Subject: [PATCH] feat: paginate /codes with prev/next buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- IDEAS/TODO.md | 2 +- src/bot/bot.ts | 34 ++++++ src/bot/commands/codes.ts | 172 +++++++++++++++------------ src/bot/database/codeManager.test.ts | 60 ++++++++++ src/bot/database/codeManager.ts | 12 +- 5 files changed, 201 insertions(+), 79 deletions(-) diff --git a/IDEAS/TODO.md b/IDEAS/TODO.md index 243011c..d8473c4 100644 --- a/IDEAS/TODO.md +++ b/IDEAS/TODO.md @@ -25,7 +25,7 @@ Priority is rated from an end-user perspective: - ✅ **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. - ✅ **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. - 🔴 **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. -- 🟢 **Paginated `/codes`** — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience. +- ✅ **Paginated `/codes`** *(implemented)* — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience. --- diff --git a/src/bot/bot.ts b/src/bot/bot.ts index d86df10..32df802 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -14,6 +14,7 @@ import * as blacksmithCommand from './commands/blacksmith'; import * as deleteaccountCommand from './commands/deleteaccount'; import * as catchupCommand from './commands/catchup'; import * as codesCommand from './commands/codes'; +import { buildCodesPage } from './commands/codes'; import * as autoredeemCommand from './commands/autoredeem'; import * as helpCommand from './commands/help'; import * as inventoryCommand from './commands/inventory'; @@ -181,6 +182,39 @@ client.on(Events.InteractionCreate, async (interaction) => { } }); +// Event: Button interactions (e.g. /codes pagination) +client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + + if (interaction.customId.startsWith('codes:')) { + const parts = interaction.customId.split(':'); + const ownerId = parts[1]; + const page = parseInt(parts[2] ?? '0', 10); + + // Only the user who ran /codes can page through their results + if (interaction.user.id !== ownerId) { + await interaction.reply({ + content: '❌ These buttons are not for you.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + await interaction.deferUpdate(); + const { embeds, components } = await buildCodesPage(ownerId, page); + await interaction.editReply({ embeds, components }); + } catch (error) { + logger.error('[CODES] Button pagination error:', error); + await interaction.editReply({ + content: '❌ Failed to load page.', + embeds: [], + components: [], + }); + } + } +}); + // Event: Message (code scanning) client.on(Events.MessageCreate, async (message) => { // Only scan in the specified channel if configured diff --git a/src/bot/commands/codes.ts b/src/bot/commands/codes.ts index 2669dd2..7ae225b 100644 --- a/src/bot/commands/codes.ts +++ b/src/bot/commands/codes.ts @@ -3,98 +3,116 @@ import { ChatInputCommandInteraction, EmbedBuilder, MessageFlags, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, } from 'discord.js'; import { codeManager } from '../database/codeManager'; import { auditManager } from '../database/auditManager'; +export const PAGE_SIZE = 5; + export const data = new SlashCommandBuilder() .setName('codes') - .setDescription('Show your redeemed codes history (last 10)') - .addNumberOption((option) => - option - .setName('count') - .setDescription('Number of codes to show (1-20)') - .setMinValue(1) - .setMaxValue(20) - .setRequired(false) - ); + .setDescription('Show your redeemed codes history'); + +export async function buildCodesPage( + discordId: string, + page: number +): Promise<{ embeds: EmbedBuilder[]; components: ActionRowBuilder[] }> { + const total = await codeManager.getRedeemedCodeCount(discordId); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const safePage = Math.max(0, Math.min(page, totalPages - 1)); + const offset = safePage * PAGE_SIZE; + + if (total === 0) { + const embed = new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('📝 Redeemed Codes History') + .setDescription("You haven't redeemed any codes yet."); + return { embeds: [embed], components: [] }; + } -export async function execute(interaction: ChatInputCommandInteraction) { - try { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const redeemedCodes = await codeManager.getRedeemedCodeDetails(discordId, PAGE_SIZE, offset); + + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('📝 Your Redeemed Codes') + .setFooter({ text: `Page ${safePage + 1} of ${totalPages} · ${total} total` }); + + redeemedCodes.forEach((codeRow, index) => { + const statusLower = (codeRow.status || 'unknown').toLowerCase(); + const statusEmoji = + { + success: '✅', + 'code expired': '❌', + error: '⚠️', + }[statusLower] || '❓'; + + const dateStr = codeRow.redeemedAt + ? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : 'Unknown'; + + const publicBadge = codeRow.isPublic ? ' 🌐' : ''; + + let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`; + fieldValue += `**Redeemed:** ${dateStr}\n`; + + if (codeRow.lootDetail) { + try { + const loot = JSON.parse(codeRow.lootDetail); + if (loot && typeof loot === 'object') { + const lootParts = []; + if (loot.gold) lootParts.push(`Gold: ${loot.gold}`); + if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`); + if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`); + if (lootParts.length > 0) { + fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`; + } + } + } catch { + // Skip if loot detail is not valid JSON + } + } - const count = interaction.options.getNumber('count', false) || 10; + embed.addFields({ + name: `${offset + index + 1}. ${codeRow.code}${publicBadge}`, + value: fieldValue, + inline: false, + }); + }); - // Log action - await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', { count }); + const prevButton = new ButtonBuilder() + .setCustomId(`codes:${discordId}:${safePage - 1}`) + .setLabel('◀ Prev') + .setStyle(ButtonStyle.Secondary) + .setDisabled(safePage === 0); - const redeemedCodes = await codeManager.getRedeemedCodeDetails(interaction.user.id, count); + const nextButton = new ButtonBuilder() + .setCustomId(`codes:${discordId}:${safePage + 1}`) + .setLabel('Next ▶') + .setStyle(ButtonStyle.Secondary) + .setDisabled(safePage >= totalPages - 1); - if (redeemedCodes.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xffaa00) - .setTitle('📝 Redeemed Codes History') - .setDescription("You haven't redeemed any codes yet."); + const row = new ActionRowBuilder().addComponents(prevButton, nextButton); - await interaction.editReply({ embeds: [embed] }); - return; - } + return { embeds: [embed], components: [row] }; +} - const embed = new EmbedBuilder() - .setColor(0x0099ff) - .setTitle('📝 Your Redeemed Codes') - .setDescription(`Showing your last ${redeemedCodes.length} redeemed codes`) - .setFooter({ text: `Total shown: ${redeemedCodes.length}` }); - - redeemedCodes.forEach((codeRow, index) => { - const statusLower = (codeRow.status || 'unknown').toLowerCase(); - const statusEmoji = - { - success: '✅', - expired: '❌', - error: '⚠️', - }[statusLower] || '❓'; - - const dateStr = codeRow.redeemedAt - ? new Date(codeRow.redeemedAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - : 'Unknown'; - - const publicBadge = codeRow.isPublic ? ' 🌐 (Public)' : ''; - - let fieldValue = `**Status:** ${statusEmoji} ${statusLower}\n`; - fieldValue += `**Redeemed:** ${dateStr}\n`; - - if (codeRow.lootDetail) { - try { - const loot = JSON.parse(codeRow.lootDetail); - if (loot && typeof loot === 'object') { - const lootParts = []; - if (loot.gold) lootParts.push(`Gold: ${loot.gold}`); - if (loot.rubies) lootParts.push(`Rubies: ${loot.rubies}`); - if (loot.equipment) lootParts.push(`Equipment: ${loot.equipment}`); - if (lootParts.length > 0) { - fieldValue += `**Rewards:** ${lootParts.join(', ')}\n`; - } - } - } catch { - // Skip if loot detail is not valid JSON - } - } +export async function execute(interaction: ChatInputCommandInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - embed.addFields({ - name: `${index + 1}. ${codeRow.code}${publicBadge}`, - value: fieldValue, - inline: false, - }); - }); + await auditManager.logAction(interaction.user.id, 'VIEWED_CODES', {}); - await interaction.editReply({ embeds: [embed] }); + const { embeds, components } = await buildCodesPage(interaction.user.id, 0); + await interaction.editReply({ embeds, components }); } catch (error) { console.error('[CODES] Error:', error); diff --git a/src/bot/database/codeManager.test.ts b/src/bot/database/codeManager.test.ts index a8268e4..6ce88c1 100644 --- a/src/bot/database/codeManager.test.ts +++ b/src/bot/database/codeManager.test.ts @@ -345,6 +345,66 @@ describe('getRedeemedCodeDetails', () => { expect(details).toHaveLength(2); expect(details.every((r) => r.discordId === USER_A)).toBe(true); }); + + test('respects the limit parameter', async () => { + for (let i = 1; i <= 7; i++) { + await codeManager.addRedeemedCode(`CODE${String(i).padStart(4, '0')}ZZZZ`, USER_A, 'Success'); + } + const details = await codeManager.getRedeemedCodeDetails(USER_A, 3); + expect(details).toHaveLength(3); + }); + + test('respects the offset parameter for page 2', async () => { + // Insert 7 codes — SQLite preserves insertion order for equal timestamps, + // but we want deterministic ordering so we verify via set membership. + const allCodes = ['AAAAAAAAAAAA', 'BBBBBBBBBBBB', 'CCCCCCCCCCCC', 'DDDDDDDDDDDD', 'EEEEEEEEEEEE', 'FFFFFFFFFFFF', 'GGGGGGGGGGGG']; + for (const code of allCodes) { + await codeManager.addRedeemedCode(code, USER_A, 'Success'); + } + const page1 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 0); + const page2 = await codeManager.getRedeemedCodeDetails(USER_A, 5, 5); + expect(page1).toHaveLength(5); + expect(page2).toHaveLength(2); + // Pages must not overlap + const page1Codes = new Set(page1.map((r) => r.code)); + for (const row of page2) { + expect(page1Codes.has(row.code)).toBe(false); + } + }); + + test('returns empty array when offset exceeds total', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + const details = await codeManager.getRedeemedCodeDetails(USER_A, 5, 100); + expect(details).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// getRedeemedCodeCount +// --------------------------------------------------------------------------- +describe('getRedeemedCodeCount', () => { + test('returns 0 when user has no codes', async () => { + expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(0); + }); + + test('returns correct count for a single user', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired'); + expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(2); + }); + + test('does not count codes belonging to another user', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Success'); + expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(1); + }); + + test('counts all statuses (Success, Code Expired, etc.)', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired'); + await codeManager.addRedeemedCode('CODE3333CCCC', USER_A, 'Already Redeemed'); + expect(await codeManager.getRedeemedCodeCount(USER_A)).toBe(3); + }); }); // --------------------------------------------------------------------------- diff --git a/src/bot/database/codeManager.ts b/src/bot/database/codeManager.ts index 55ba41f..7179a0d 100644 --- a/src/bot/database/codeManager.ts +++ b/src/bot/database/codeManager.ts @@ -149,13 +149,23 @@ class CodeManager { return results.map((r) => r.code); } - async getRedeemedCodeDetails(discordId: string, limit: number = 10): Promise { + async getRedeemedCodeCount(discordId: string): Promise { + const result = db + .select({ count: sql`COUNT(*)` }) + .from(redeemedCodes) + .where(eq(redeemedCodes.discordId, discordId)) + .get(); + return result?.count ?? 0; + } + + async getRedeemedCodeDetails(discordId: string, limit: number = 10, offset: number = 0): Promise { return db .select() .from(redeemedCodes) .where(eq(redeemedCodes.discordId, discordId)) .orderBy(sql`${redeemedCodes.redeemedAt} DESC`) .limit(limit) + .offset(offset) .all(); }