From 03a0a7e6e78a9a5813e0e9f3e5c138d9bdad1238 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:36:03 +0200 Subject: [PATCH 01/10] feat(deleteaccount): add /deleteaccount command for GDPR-friendly self-service data removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new slash command that lets users permanently delete all their stored data from the bot with a confirmation step. Changes - src/bot/commands/deleteaccount.ts (new): slash command with a 30-second Yes/Cancel button confirmation. On confirm deletes credentials, all redeemed code records (reports count), and audit log entries. Handles no-account, timeout, cancel, and error cases with ephemeral embeds. - src/bot/bot.ts: import and register deleteaccount command. - src/bot/commands/help.ts: add /deleteaccount entry to help embed. - src/bot/database/codeManager.ts: add deleteUserRedeemedCodes(discordId) — deletes all redeemed_codes rows for a user, returns the count removed. - src/bot/database/auditManager.ts: add deleteUserAuditLog(discordId) — purges all audit_log rows belonging to a user. - IDEAS/TODO.md (new): feature ideas file with end-user priority ratings; /deleteaccount marked as implemented. Tests (125 pass, 0 fail) - src/bot/database/auditManager.test.ts (new): 23 tests covering logAction, getUserAuditLog, getAllAuditLog, getAuditLogSince, getAuditLogByAction, and deleteUserAuditLog. auditManager.ts now at 100% line coverage. - src/bot/database/codeManager.test.ts: 4 new tests for deleteUserRedeemedCodes (empty user, count returned, user isolation, shared-code safety). Signed-off-by: Michael Cramer --- IDEAS/TODO.md | 53 +++++++ src/bot/bot.ts | 2 + src/bot/commands/deleteaccount.ts | 140 ++++++++++++++++ src/bot/commands/help.ts | 6 + src/bot/database/auditManager.test.ts | 219 ++++++++++++++++++++++++++ src/bot/database/auditManager.ts | 4 + src/bot/database/codeManager.test.ts | 39 +++++ src/bot/database/codeManager.ts | 10 ++ 8 files changed, 473 insertions(+) create mode 100644 IDEAS/TODO.md create mode 100644 src/bot/commands/deleteaccount.ts create mode 100644 src/bot/database/auditManager.test.ts diff --git a/IDEAS/TODO.md b/IDEAS/TODO.md new file mode 100644 index 0000000..0a0ac16 --- /dev/null +++ b/IDEAS/TODO.md @@ -0,0 +1,53 @@ +# Ideas & TODO + +Feature ideas and potential improvements for the bot. + +Priority is rated from an end-user perspective: +- 🔴 **High** — directly improves core daily usage; most users would benefit immediately +- 🟡 **Medium** — noticeable improvement but not critical to the main workflow +- 🟢 **Low** — nice to have, niche use case, or primarily an admin/backend concern + +--- + +## Code & Redemption + +- 🟡 **`/stats` command** — Server-wide stats: total codes found, redemption counts, registered user count, aggregate loot earned. Data already exists in DB. +- 🟡 **Loot summary in `/codes`** — Show aggregate totals (gold, rubies, equipment) across all redeemed codes. `loot_detail` JSON is already stored per row. +- 🟢 **Code source tracking** — Store which channel/message ID a code was found in. Useful for auditing and showing users where codes originated. +- 🔴 **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. +- 🟢 **Auto-purge expired pending codes** — Pending codes that fail globally should be cleaned up automatically; right now they stay in `pending_codes` forever. +- 🔴 **Scheduled catchup** — Run catchup automatically for all autoredeem-enabled users on a configurable timer (e.g. daily) as a safety net for missed codes. Prevents silently missing free loot. + +--- + +## Notifications & UX + +- 🔴 **DM notifications for new codes** — 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** — 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. + +--- + +## Admin & Operations + +- 🟢 **`/admin` subcommand group** — Consolidate admin actions under one command: + - View recent audit log entries + - Force-expire or remove a specific code + - Remove a specific pending code + - View registered user count +- 🟢 **Config command** — Set bot config (scan channels, log level, autoredeem default) at runtime without restarting. + +--- + +## API / Game Features + +- 🟡 **`/buy` command** — `purchaseChests()` is already implemented in `idleChampionsApi.ts`. Just needs a command wired up. Convenient to do from Discord without opening the game. +- 🔴 **`/heroes` command** — List the user's champions with levels and upgrade costs from the player data response. Directly unblocks the blacksmith UX problem above. +- 🟡 **`/export` command** — Export redeemed code history as a CSV file attachment (`AttachmentBuilder`). Useful for personal tracking. + +--- + +## Quality of Life + +- ✅ **`/deleteaccount` command** *(implemented)* — Let users remove their credentials and history from the DB (GDPR-friendly self-service). diff --git a/src/bot/bot.ts b/src/bot/bot.ts index 6bb16bf..9eac49e 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -11,6 +11,7 @@ import logger from './utils/logger'; import { apiRequestLogger } from './utils/apiRequestLogger'; import * as backfillCommand from './commands/backfill'; 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 * as autoredeemCommand from './commands/autoredeem'; @@ -54,6 +55,7 @@ const commands = [ blacksmithCommand, catchupCommand, codesCommand, + deleteaccountCommand, helpCommand, inventoryCommand, makepublicCommand, diff --git a/src/bot/commands/deleteaccount.ts b/src/bot/commands/deleteaccount.ts new file mode 100644 index 0000000..323eb41 --- /dev/null +++ b/src/bot/commands/deleteaccount.ts @@ -0,0 +1,140 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + MessageFlags, +} from 'discord.js'; +import { userManager } from '../database/userManager'; +import { codeManager } from '../database/codeManager'; +import { auditManager } from '../database/auditManager'; +import logger from '../utils/logger'; + +export const data = new SlashCommandBuilder() + .setName('deleteaccount') + .setDescription('Permanently delete all your data from this bot (credentials, code history, audit log)'); + +export async function execute(interaction: ChatInputCommandInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const hasCredentials = await userManager.hasCredentials(interaction.user.id); + + if (!hasCredentials) { + const embed = new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('⚠️ No Account Found') + .setDescription('You have no stored data in this bot — nothing to delete.'); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + const confirmEmbed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('⚠️ Delete Account — Are you sure?') + .setDescription( + 'This will **permanently and irreversibly** delete all of your data:\n\n' + + '• Your Idle Champions credentials\n' + + '• Your full code redemption history\n' + + '• Your audit log entries\n\n' + + 'You will need to run `/setup` again to use the bot after this.' + ) + .setFooter({ text: 'This action cannot be undone. Confirmation expires in 30 seconds.' }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('deleteaccount_confirm') + .setLabel('Yes, delete everything') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('deleteaccount_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + const reply = await interaction.editReply({ embeds: [confirmEmbed], components: [row] }); + + let buttonInteraction; + try { + buttonInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === interaction.user.id, + time: 30_000, + }); + } catch { + // Timed out — disable the buttons + const disabledRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('deleteaccount_confirm') + .setLabel('Yes, delete everything') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('deleteaccount_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + .setDisabled(true) + ); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x888888) + .setTitle('⏱️ Confirmation Timed Out') + .setDescription('No response received within 30 seconds. Account deletion cancelled.'), + ], + components: [disabledRow], + }); + return; + } + + await buttonInteraction.deferUpdate(); + + if (buttonInteraction.customId === 'deleteaccount_cancel') { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00aa00) + .setTitle('✅ Cancelled') + .setDescription('Account deletion cancelled. Your data has not been changed.'), + ], + components: [], + }); + return; + } + + // Perform deletion + const deletedCodesCount = await codeManager.deleteUserRedeemedCodes(interaction.user.id); + await auditManager.deleteUserAuditLog(interaction.user.id); + await userManager.deleteCredentials(interaction.user.id); + + logger.info( + `[DELETE ACCOUNT] User ${interaction.user.tag} (${interaction.user.id}) deleted their account. ` + + `Removed ${deletedCodesCount} redeemed code record(s).` + ); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00aa00) + .setTitle('✅ Account Deleted') + .setDescription( + 'All your data has been permanently removed:\n\n' + + `• Credentials deleted\n` + + `• ${deletedCodesCount} code record(s) deleted\n` + + '• Audit log entries deleted\n\n' + + 'If you want to use the bot again in the future, simply run `/setup`.' + ), + ], + components: [], + }); + } catch (error) { + logger.error('[DELETE ACCOUNT] Error:', error); + await interaction.editReply({ + content: '❌ An error occurred while deleting your account. Please try again later.', + }); + } +} diff --git a/src/bot/commands/help.ts b/src/bot/commands/help.ts index 53e0fa1..dec16b5 100644 --- a/src/bot/commands/help.ts +++ b/src/bot/commands/help.ts @@ -69,6 +69,12 @@ export async function execute(interaction: ChatInputCommandInteraction) { name: '⏮️ Backfill', value: '`/backfill [channel:]`\nRecover missed codes from message history (admin only).', inline: false, + }, + { + name: '🗑️ Delete Account', + value: + '`/deleteaccount`\nPermanently delete all your stored data (credentials, code history, audit log). Requires confirmation.', + inline: false, } ) .addFields( diff --git a/src/bot/database/auditManager.test.ts b/src/bot/database/auditManager.test.ts new file mode 100644 index 0000000..c81671e --- /dev/null +++ b/src/bot/database/auditManager.test.ts @@ -0,0 +1,219 @@ +import { describe, test, expect, beforeAll, beforeEach } from 'bun:test'; +import { db, initializeDatabase } from './db'; +import { auditManager } from './auditManager'; +import { users, redeemedCodes, pendingCodes, auditLog } from './schema/index'; + +const USER_A = 'audit-user-a'; +const USER_B = 'audit-user-b'; + +beforeAll(() => { + initializeDatabase(); +}); + +beforeEach(() => { + // Clear in FK-safe order + db.delete(pendingCodes).run(); + db.delete(auditLog).run(); + db.delete(redeemedCodes).run(); + db.delete(users).run(); + // Seed users required by FK on audit_log.discord_id + db.insert(users) + .values([ + { discordId: USER_A, userId: '111', userHash: 'hash-a' }, + { discordId: USER_B, userId: '222', userHash: 'hash-b' }, + ]) + .run(); +}); + +// --------------------------------------------------------------------------- +// logAction +// --------------------------------------------------------------------------- +describe('logAction', () => { + test('inserts an audit log entry for a user', async () => { + await auditManager.logAction(USER_A, 'TEST_ACTION'); + const rows = db.select().from(auditLog).all(); + expect(rows).toHaveLength(1); + expect(rows[0].discordId).toBe(USER_A); + expect(rows[0].action).toBe('TEST_ACTION'); + expect(rows[0].details).toBeNull(); + }); + + test('stores JSON-serialised details', async () => { + await auditManager.logAction(USER_A, 'REDEEM', { code: 'ABCD1234EFGH', status: 'Success' }); + const rows = db.select().from(auditLog).all(); + expect(rows[0].details).toBe(JSON.stringify({ code: 'ABCD1234EFGH', status: 'Success' })); + }); + + test('accepts null discordId for system-level actions', async () => { + await auditManager.logAction(null, 'SYSTEM_START'); + const rows = db.select().from(auditLog).all(); + expect(rows[0].discordId).toBeNull(); + expect(rows[0].action).toBe('SYSTEM_START'); + }); + + test('inserts multiple entries independently', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.logAction(USER_A, 'ACTION_2'); + await auditManager.logAction(USER_B, 'ACTION_3'); + expect(db.select().from(auditLog).all()).toHaveLength(3); + }); +}); + +// --------------------------------------------------------------------------- +// getUserAuditLog +// --------------------------------------------------------------------------- +describe('getUserAuditLog', () => { + test('returns empty array when user has no log entries', async () => { + expect(await auditManager.getUserAuditLog(USER_A)).toEqual([]); + }); + + test('returns only entries for the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + const log = await auditManager.getUserAuditLog(USER_A); + expect(log).toHaveLength(1); + expect(log[0].action).toBe('ACTION_A'); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getUserAuditLog(USER_A, 3); + expect(log).toHaveLength(3); + }); + + test('returns both entries when two actions are logged', async () => { + await auditManager.logAction(USER_A, 'FIRST'); + await auditManager.logAction(USER_A, 'SECOND'); + const log = await auditManager.getUserAuditLog(USER_A); + expect(log).toHaveLength(2); + const actions = log.map((e) => e.action); + expect(actions).toContain('FIRST'); + expect(actions).toContain('SECOND'); + }); +}); + +// --------------------------------------------------------------------------- +// getAllAuditLog +// --------------------------------------------------------------------------- +describe('getAllAuditLog', () => { + test('returns empty array when no entries exist', async () => { + expect(await auditManager.getAllAuditLog()).toEqual([]); + }); + + test('returns entries from all users', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + await auditManager.logAction(null, 'SYSTEM'); + const log = await auditManager.getAllAuditLog(); + expect(log).toHaveLength(3); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getAllAuditLog(2); + expect(log).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// getAuditLogSince +// --------------------------------------------------------------------------- +describe('getAuditLogSince', () => { + test('returns empty array when no entries exist', async () => { + expect(await auditManager.getAuditLogSince('2000-01-01 00:00:00')).toEqual([]); + }); + + test('returns entries at or after the given timestamp', async () => { + await auditManager.logAction(USER_A, 'RECENT_ACTION'); + const log = await auditManager.getAuditLogSince('2000-01-01 00:00:00'); + expect(log).toHaveLength(1); + expect(log[0].action).toBe('RECENT_ACTION'); + }); + + test('excludes entries before the given timestamp', async () => { + await auditManager.logAction(USER_A, 'OLD_ACTION'); + // A future timestamp means nothing should be returned + const log = await auditManager.getAuditLogSince('2099-01-01 00:00:00'); + expect(log).toHaveLength(0); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getAuditLogSince('2000-01-01 00:00:00', 2); + expect(log).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// getAuditLogByAction +// --------------------------------------------------------------------------- +describe('getAuditLogByAction', () => { + test('returns empty array when no matching entries exist', async () => { + await auditManager.logAction(USER_A, 'OTHER_ACTION'); + expect(await auditManager.getAuditLogByAction('REDEEM')).toEqual([]); + }); + + test('returns only entries with the specified action', async () => { + await auditManager.logAction(USER_A, 'REDEEM'); + await auditManager.logAction(USER_B, 'REDEEM'); + await auditManager.logAction(USER_A, 'VIEWED_CODES'); + const log = await auditManager.getAuditLogByAction('REDEEM'); + expect(log).toHaveLength(2); + expect(log.every((e) => e.action === 'REDEEM')).toBe(true); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, 'REDEEM'); + } + const log = await auditManager.getAuditLogByAction('REDEEM', 3); + expect(log).toHaveLength(3); + }); +}); + +// --------------------------------------------------------------------------- +// deleteUserAuditLog +// --------------------------------------------------------------------------- +describe('deleteUserAuditLog', () => { + test('is a no-op when the user has no log entries', async () => { + await auditManager.deleteUserAuditLog(USER_A); + expect(db.select().from(auditLog).all()).toHaveLength(0); + }); + + test('removes all audit log entries for the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.logAction(USER_A, 'ACTION_2'); + await auditManager.deleteUserAuditLog(USER_A); + expect(db.select().from(auditLog).all()).toHaveLength(0); + }); + + test('only removes entries belonging to the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + await auditManager.deleteUserAuditLog(USER_A); + const remaining = db.select().from(auditLog).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0].discordId).toBe(USER_B); + }); + + test('does not delete system-level (null discordId) entries', async () => { + await auditManager.logAction(null, 'SYSTEM_ACTION'); + await auditManager.logAction(USER_A, 'USER_ACTION'); + await auditManager.deleteUserAuditLog(USER_A); + const remaining = db.select().from(auditLog).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0].discordId).toBeNull(); + }); + + test('after deletion getUserAuditLog returns empty for that user', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.deleteUserAuditLog(USER_A); + expect(await auditManager.getUserAuditLog(USER_A)).toEqual([]); + }); +}); diff --git a/src/bot/database/auditManager.ts b/src/bot/database/auditManager.ts index b6654bf..e5ef6c6 100644 --- a/src/bot/database/auditManager.ts +++ b/src/bot/database/auditManager.ts @@ -48,6 +48,10 @@ class AuditManager { .limit(limit) .all(); } + + async deleteUserAuditLog(discordId: string): Promise { + db.delete(auditLog).where(eq(auditLog.discordId, discordId)).run(); + } } export const auditManager = new AuditManager(); diff --git a/src/bot/database/codeManager.test.ts b/src/bot/database/codeManager.test.ts index 5aad76b..9d0f85c 100644 --- a/src/bot/database/codeManager.test.ts +++ b/src/bot/database/codeManager.test.ts @@ -328,3 +328,42 @@ describe('getRedeemedCodeDetails', () => { expect(details.every((r) => r.discordId === USER_A)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// deleteUserRedeemedCodes +// --------------------------------------------------------------------------- +describe('deleteUserRedeemedCodes', () => { + test('returns 0 and does nothing when user has no codes', async () => { + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(0); + expect(db.select().from(redeemedCodes).all()).toHaveLength(0); + }); + + test('deletes all redeemed code rows for the user and returns the count', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired'); + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(2); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(0); + }); + + test('only deletes rows belonging to the specified user', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Success'); + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(1); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0].discordId).toBe(USER_B); + }); + + test('does not affect the other user\'s records when both have redeemed the same code', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE1111AAAA', USER_B, 'Success'); + await codeManager.deleteUserRedeemedCodes(USER_A); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0].discordId).toBe(USER_B); + }); +}); diff --git a/src/bot/database/codeManager.ts b/src/bot/database/codeManager.ts index faa5168..18bd21c 100644 --- a/src/bot/database/codeManager.ts +++ b/src/bot/database/codeManager.ts @@ -233,6 +233,16 @@ class CodeManager { db.delete(pendingCodes).run(); } } + + async deleteUserRedeemedCodes(discordId: string): Promise { + const before = db + .select({ count: sql`COUNT(*)` }) + .from(redeemedCodes) + .where(eq(redeemedCodes.discordId, discordId)) + .get(); + db.delete(redeemedCodes).where(eq(redeemedCodes.discordId, discordId)).run(); + return before?.count ?? 0; + } } export const codeManager = new CodeManager(); From 16e3ae624e758e88b421028aa04d789824c14181 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:40:00 +0200 Subject: [PATCH 02/10] docs: document /deleteaccount command across all reference docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated files: - README.md: add /deleteaccount to slash commands list, add GDPR bullet - docs/full-documentation.md: add to features list, command table, and new full command section under Account Management - docs/api-reference.md: bump total commands 11→12, add full command 12 section with flow/error codes/example, add to /help embed example, add to throttling table, update OSPS compliance count - docs/structure.md: bump command count 11→12, add deleteaccount.ts entry - docs/system-design.md: add to user actions list and command reference table Signed-off-by: Michael Cramer --- README.md | 3 ++- docs/api-reference.md | 52 +++++++++++++++++++++++++++++++++++--- docs/full-documentation.md | 19 +++++++++++++- docs/structure.md | 5 ++-- docs/system-design.md | 3 ++- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bad242f..19b99c1 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ See [docker-compose.example.yml](docker-compose.example.yml) for all available c ## ✨ Features -- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help` +- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help` - 🔄 **Auto Code Detection** - Scans Discord messages for codes automatically - ⏮️ **Message History Backfill** - Recover missed codes from message history with built-in rate limiting - 🔁 **Catch Up** - Redeem all known valid codes in one command (great for new members) @@ -93,6 +93,7 @@ See [docker-compose.example.yml](docker-compose.example.yml) for all available c - 📦 **Chest Management** - Open chests and view loot - ⚒️ **Blacksmith** - Upgrade heroes with contracts - 📊 **Inventory** - View gold, rubies, equipment, and progress +- 🗑️ **Account Deletion** - GDPR-friendly self-service data removal (`/deleteaccount`) - 💾 **Secure Storage** - SQLite database keeps credentials safe and local - 👥 **Multi-User** - Each user manages their own account - ⚡ **Fast** - Built on Bun for 3-4x performance vs Node.js diff --git a/docs/api-reference.md b/docs/api-reference.md index 153dd32..e6de657 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -29,7 +29,7 @@ The Discord bot responds to slash commands in Discord channels. All commands ret **Response Format**: Discord Embeds (rich message format) or Ephemeral Text -**Total commands**: 11 (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help`) +**Total commands**: 12 (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help`) --- @@ -708,7 +708,50 @@ Bot (ephemeral): ⏸️ Auto-Redeem Disabled --- -### 11. `/help` +### 11. `/deleteaccount` + +Permanently delete all data the bot holds about the invoking user. + +**Invocation**: +``` +/deleteaccount +``` + +**Parameters**: None + +**Flow**: +1. Bot checks whether credentials exist for the user. If none are found, responds with a warning and exits. +2. Bot sends an ephemeral embed with Yes / Cancel buttons (30-second timeout). +3. If the user clicks **Yes, delete everything**: credentials, all redeemed code records, and audit log entries are permanently removed. A summary is shown. +4. If the user clicks **Cancel** or the timeout elapses: no data is changed. + +**Data removed on confirmation**: +- `users` row (credentials, server, autoredeem preference) +- All `redeemed_codes` rows for the user +- All `audit_log` rows for the user + +**Error Codes**: + +| Code | Meaning | Resolution | +|------|---------|------------| +| `NO_ACCOUNT` | No credentials stored for this user | Nothing to delete | + +**Example**: +``` +User: /deleteaccount +Bot (ephemeral): ⚠️ Delete Account — Are you sure? + [Yes, delete everything] [Cancel] + +User: clicks Yes +Bot (ephemeral): ✅ Account Deleted + • Credentials deleted + • 42 code record(s) deleted + • Audit log entries deleted +``` + +--- + +### 12. `/help` Display command reference and usage instructions. @@ -752,6 +795,8 @@ Display command reference and usage instructions. │ Toggle automatic code redemption │ │ /backfill [channel:] │ │ Recover codes from message history │ +│ /deleteaccount │ +│ Permanently delete all your stored data │ │ /help │ │ Show this message │ │ │ @@ -948,6 +993,7 @@ The bot implements request throttling: | `/open` | 1 per 3 seconds | Per user | | `/blacksmith` | 1 per 3 seconds | Per user | | `/backfill` | 1 concurrent | Per guild | +| `/deleteaccount` | Unlimited | Per user | | Code detection | Per message | Automatic | --- @@ -1195,7 +1241,7 @@ Bot (embed): ## OSPS-SA-02.01 Compliance ✅ **Software Interfaces Documented**: -- 11 slash commands with parameters, responses, error codes +- 12 slash commands with parameters, responses, error codes - Message event detection with pattern matching - Response formats and data structures - Error handling and recovery procedures diff --git a/docs/full-documentation.md b/docs/full-documentation.md index b7e8446..aa6bb0a 100644 --- a/docs/full-documentation.md +++ b/docs/full-documentation.md @@ -4,7 +4,7 @@ A Discord bot that automatically scans for and redeems Idle Champions promo code ## Features -- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help` +- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help` - 🔄 **Auto Code Detection** - Scans Discord messages for codes automatically - 🤖 **Auto-Redeem Toggle** - Enable or disable automatic code redemption per user (`/autoredeem`) - ⏮️ **Message History Backfill** - Recover missed codes from message history (protected with rate limiting) @@ -13,6 +13,7 @@ A Discord bot that automatically scans for and redeems Idle Champions promo code - ⚒️ **Blacksmith** - Upgrade heroes with contracts - 📊 **Inventory** - View gold, rubies, equipment, and progress - 💾 **Secure Storage** - SQLite database keeps credentials safe and local +- 🗑️ **Account Deletion** - GDPR-friendly self-service removal of all stored data (`/deleteaccount`) - 👥 **Multi-User** - Each user manages their own account - ⚡ **Fast** - Built on Bun for 3-4x performance vs Node.js @@ -71,6 +72,7 @@ brew install mise | `/codes [count:]` | Show your redeemed codes history (last 10) | | `/makepublic code:` | Share one of your redeemed codes with other users | | `/backfill [channel:]` | Recover missed codes from message history | +| `/deleteaccount` | Permanently delete all your stored data (GDPR) | | `/help` | Show all commands | ### Setup & Authentication @@ -202,6 +204,21 @@ Toggle whether the bot automatically redeems new codes when they are detected in - **Behaviour when disabled:** Codes are still detected and stored, but you must use `/redeem` or `/catchup` to claim them manually - **Example:** `/autoredeem enabled:off` +### Account Management + +#### `/deleteaccount` + +Permanently and irreversibly delete all data the bot holds about you. Requires an explicit confirmation step to prevent accidents. + +- **No parameters required** +- **What is deleted:** + - Your Idle Champions credentials (user ID + hash) + - Your full code redemption history + - Your audit log entries +- **Confirmation:** A Yes / Cancel button prompt appears with a 30-second timeout — no action is taken unless you click **Yes, delete everything** +- **After deletion:** You will need to run `/setup` again to use the bot +- **Example:** `/deleteaccount` + ### Help #### `/help` diff --git a/docs/structure.md b/docs/structure.md index 8b553a3..ac2c669 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -28,7 +28,7 @@ idle-code-redeemer/ │ ├── bot/ ← Discord bot (ACTIVE) │ │ ├── bot.ts ← Main Discord client & event handlers │ │ ├── api/ ← Game server API client -│ │ ├── commands/ ← Slash command handlers (11 commands) +│ │ ├── commands/ ← Slash command handlers (12 commands) │ │ ├── database/ ← Database managers & Drizzle schema │ │ │ ├── db.ts ← Drizzle connection & migrate() │ │ │ ├── userManager.ts @@ -73,7 +73,7 @@ idle-code-redeemer/ - **[src/bot/bot.ts](../src/bot/bot.ts)** - Discord client initialization, event handlers, command routing - **[src/bot/api/idleChampionsApi.ts](../src/bot/api/idleChampionsApi.ts)** - Game server API client with query-parameter format -### Commands (11 slash commands) +### Commands (12 slash commands) - **[src/bot/commands/setup.ts](../src/bot/commands/setup.ts)** - `/setup user_id: user_hash:` - **[src/bot/commands/redeem.ts](../src/bot/commands/redeem.ts)** - `/redeem code:` @@ -85,6 +85,7 @@ idle-code-redeemer/ - **[src/bot/commands/codes.ts](../src/bot/commands/codes.ts)** - `/codes [count:]` (view redeemed codes history) - **[src/bot/commands/makepublic.ts](../src/bot/commands/makepublic.ts)** - `/makepublic code:` (share codes with other users) - **[src/bot/commands/backfill.ts](../src/bot/commands/backfill.ts)** - `/backfill [channel:]` (recover missed codes) +- **[src/bot/commands/deleteaccount.ts](../src/bot/commands/deleteaccount.ts)** - `/deleteaccount` (permanently delete all stored user data, GDPR-friendly) - **[src/bot/commands/help.ts](../src/bot/commands/help.ts)** - `/help` ### Database diff --git a/docs/system-design.md b/docs/system-design.md index e068c94..241fa0e 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -92,7 +92,7 @@ The bot reads promo codes from Discord messages, redeems them via the Idle Champ **Role**: End-user interacting with the bot **Actions**: -- Submit slash commands (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help`) +- Submit slash commands (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help`) - Send messages containing promo codes in the monitored channel - Receive responses and error messages from the bot @@ -508,6 +508,7 @@ All handlers follow pattern: | `/codes` | codes.ts | View code history | Embed | | `/makepublic` | makepublic.ts | Share codes with users | Message | | `/backfill` | backfill.ts | Scan message history | Embed | +| `/deleteaccount` | deleteaccount.ts | Delete all user data (GDPR) | Ephemeral | | `/help` | help.ts | Command reference | Embed | --- From 8316ba6a01d0724360261b902d97d2d3fa4da49a Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:45:50 +0200 Subject: [PATCH 03/10] ci: add test & coverage workflow with per-PR diff comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs on push to main and PRs targeting main: - Sets up Bun 1.3.14 and installs frozen lockfile deps - Type-checks with tsc - Runs tests with LCOV coverage output - Uploads coverage/lcov.info as an artifact (retained 90 days) On pull requests: - Downloads the latest coverage artifact from main - Posts a per-file coverage diff comment via romeovs/lcov-reporter-action (shows +/- change vs main, or full report if no base artifact exists yet) - Old coverage comments are deleted and replaced on each new push No secrets required — ENCRYPTION_KEY is set by src/test/setup.ts Signed-off-by: Michael Cramer --- .github/workflows/test.yml | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..54bc2c8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,84 @@ +name: Tests & Coverage + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Test & Coverage + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # needed to post/delete coverage comments on PRs + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bun run typecheck + + - name: Run tests with coverage + run: bun test --coverage --coverage-reporter=lcov + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/lcov.info + retention-days: 90 + + # Download the most recent coverage artifact from the base branch (main). + # Used for the per-PR coverage diff comment below. + # continue-on-error so the job does not fail when no artifact exists yet + # (e.g. the very first push to main, or a fork PR). + - name: Download base branch coverage + if: github.event_name == 'pull_request' + id: base-coverage + uses: dawidd6/action-download-artifact@v9 + continue-on-error: true + with: + branch: ${{ github.base_ref }} + name: coverage + path: coverage-base + workflow: test.yml + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post coverage diff comment (base available) + if: github.event_name == 'pull_request' && steps.base-coverage.outcome == 'success' + uses: romeovs/lcov-reporter-action@v0.4.3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: ./coverage/lcov.info + lcov-base: ./coverage-base/lcov.info + delete-old-comments: true + + - name: Post coverage comment (no base available) + if: github.event_name == 'pull_request' && steps.base-coverage.outcome != 'success' + uses: romeovs/lcov-reporter-action@v0.4.3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: ./coverage/lcov.info + delete-old-comments: true From e597abf2fe39b44720be9829a51adc3da6fead7b Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:49:33 +0200 Subject: [PATCH 04/10] ci: use bin/mise instead of oven-sh/setup-bun to set up Bun Signed-off-by: Michael Cramer --- .github/workflows/test.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54bc2c8..83071e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,19 +28,17 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.14" + - name: Install tools (Bun + Gitleaks via mise) + run: ./bin/mise install - name: Install dependencies - run: bun install --frozen-lockfile + run: ./bin/mise run install - name: Type check - run: bun run typecheck + run: ./bin/mise run typecheck - name: Run tests with coverage - run: bun test --coverage --coverage-reporter=lcov + run: ./bin/mise exec -- bun test --coverage --coverage-reporter=lcov - name: Upload coverage artifact uses: actions/upload-artifact@v4 From 96f26b3b20e07d1948afb9ae6b4170ca704ca432 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:56:06 +0200 Subject: [PATCH 05/10] ci: replace romeovs/lcov-reporter-action with pento/lcov-coverage-check romeovs/lcov-reporter-action carries a maintenance warning and has not been updated since 2024. Replace with pento/lcov-coverage-check v3.1.0 which is actively maintained and handles baseline artifact management internally, eliminating the need for the separate dawidd6/action-download-artifact step. Changes: - Add actions: read permission (needed to download baseline artifacts) - Drop the upload-artifact, download-artifact, and two conditional romeovs steps (4 steps -> 1 step) - pento/lcov-coverage-check automatically: * stores lcov-baseline artifact on main-branch pushes * retrieves baseline and posts a per-file diff comment on PRs * writes a summary to GITHUB_STEP_SUMMARY * falls back to summary-only mode on first run or fork PRs Signed-off-by: Michael Cramer --- .github/workflows/test.yml | 46 ++++++-------------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83071e6..0cc16ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write # needed to post/delete coverage comments on PRs + actions: read # pento/lcov-coverage-check: list workflow runs and download baseline artifact + pull-requests: write # pento/lcov-coverage-check: post/update coverage comment on PRs steps: - name: Harden the runner (Audit all outbound calls) @@ -40,43 +41,10 @@ jobs: - name: Run tests with coverage run: ./bin/mise exec -- bun test --coverage --coverage-reporter=lcov - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage - path: coverage/lcov.info - retention-days: 90 - - # Download the most recent coverage artifact from the base branch (main). - # Used for the per-PR coverage diff comment below. - # continue-on-error so the job does not fail when no artifact exists yet - # (e.g. the very first push to main, or a fork PR). - - name: Download base branch coverage - if: github.event_name == 'pull_request' - id: base-coverage - uses: dawidd6/action-download-artifact@v9 - continue-on-error: true - with: - branch: ${{ github.base_ref }} - name: coverage - path: coverage-base - workflow: test.yml - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Post coverage diff comment (base available) - if: github.event_name == 'pull_request' && steps.base-coverage.outcome == 'success' - uses: romeovs/lcov-reporter-action@v0.4.3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - lcov-file: ./coverage/lcov.info - lcov-base: ./coverage-base/lcov.info - delete-old-comments: true - - - name: Post coverage comment (no base available) - if: github.event_name == 'pull_request' && steps.base-coverage.outcome != 'success' - uses: romeovs/lcov-reporter-action@v0.4.3 + # Stores lcov-baseline artifact on main; retrieves it and posts a coverage + # diff comment on PRs. Falls back to summary-only on first run or fork PRs. + - name: Report coverage + uses: pento/lcov-coverage-check@v3.1.0 with: + path: "src/" github-token: ${{ secrets.GITHUB_TOKEN }} - lcov-file: ./coverage/lcov.info - delete-old-comments: true From 9c070e216b86ad55412762dfa4c4a2f94fa8890b Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 19:57:54 +0200 Subject: [PATCH 06/10] chore: add mise test and test:coverage tasks, use in CI Signed-off-by: Michael Cramer --- .github/workflows/test.yml | 2 +- .mise.toml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cc16ce..db47b0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: run: ./bin/mise run typecheck - name: Run tests with coverage - run: ./bin/mise exec -- bun test --coverage --coverage-reporter=lcov + run: ./bin/mise run test:coverage # Stores lcov-baseline artifact on main; retrieves it and posts a coverage # diff comment on PRs. Falls back to summary-only on first run or fork PRs. diff --git a/.mise.toml b/.mise.toml index 0dd9db9..8b6d96a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -30,6 +30,14 @@ run = "bun run build" description = "Type-check TypeScript source without emitting files" run = "bun run typecheck" +[tasks.test] +description = "Run all tests" +run = "bun test" + +[tasks."test:coverage"] +description = "Run all tests with LCOV coverage report (output: coverage/lcov.info)" +run = "bun test --coverage --coverage-reporter=lcov" + [tasks."prod:build"] description = "Build minified production executable" run = "bun run build" From 63f8189675a005b0806059d9b85bc6bffa1e6466 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 20:04:20 +0200 Subject: [PATCH 07/10] fix: address Copilot PR review findings - deleteaccount: clear pending_codes before deleting the users row pending_codes.discord_id has a FK to users.discord_id with no ON DELETE CASCADE; any pending row would cause deleteCredentials() to fail and leave the account in a partially-deleted state. clearPendingCodes() is now called first, matching the FK dependency order. - deleteaccount: remove PII from post-deletion log message The logger previously emitted interaction.user.tag and interaction.user.id after all database records had been erased, which re-persists the user's identity in log files. The message now only records the count of deleted code records \u2014 no user-identifying data. - ci: pin pento/lcov-coverage-check to full commit SHA (v3.1.0) Matches the repository convention of pinning all GitHub Actions to a specific commit hash for supply-chain integrity. Signed-off-by: Michael Cramer --- .github/workflows/test.yml | 2 +- src/bot/commands/deleteaccount.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db47b0f..978fcd2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: # Stores lcov-baseline artifact on main; retrieves it and posts a coverage # diff comment on PRs. Falls back to summary-only on first run or fork PRs. - name: Report coverage - uses: pento/lcov-coverage-check@v3.1.0 + uses: pento/lcov-coverage-check@972428b8d5b3fbd8230df23ca4190ecce29979cd # v3.1.0 with: path: "src/" github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/bot/commands/deleteaccount.ts b/src/bot/commands/deleteaccount.ts index 323eb41..3f89457 100644 --- a/src/bot/commands/deleteaccount.ts +++ b/src/bot/commands/deleteaccount.ts @@ -106,14 +106,16 @@ export async function execute(interaction: ChatInputCommandInteraction) { return; } - // Perform deletion + // Perform deletion — order matters for FK constraints: + // pending_codes.discord_id → users.discord_id (no cascade), so clear it first + await codeManager.clearPendingCodes(interaction.user.id); const deletedCodesCount = await codeManager.deleteUserRedeemedCodes(interaction.user.id); await auditManager.deleteUserAuditLog(interaction.user.id); await userManager.deleteCredentials(interaction.user.id); + // Log a non-identifying event — the user's credentials and ID are now gone logger.info( - `[DELETE ACCOUNT] User ${interaction.user.tag} (${interaction.user.id}) deleted their account. ` + - `Removed ${deletedCodesCount} redeemed code record(s).` + `[DELETE ACCOUNT] Account deletion completed. Removed ${deletedCodesCount} redeemed code record(s).` ); await interaction.editReply({ From e362e57063b3de0126ab606d59b090973f102d94 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 20:23:08 +0200 Subject: [PATCH 08/10] fix: resolve TypeScript 6 strict type errors - crypto.ts: type-assert array destructuring after parts.length guard - autoRedeemer.ts: fix Promise by wrapping catch in block, cast newServer/generic.newServer as string after guards, add non-null assertion on allUsers index access - autoRedeemer.test.ts: fix setTimeout mock type with unknown cast, add non-null assertion on mock.calls index access - auditManager.test.ts, codeManager.test.ts, userManager.test.ts: add non-null assertions on all direct array index accesses Signed-off-by: Michael Cramer --- src/bot/database/auditManager.test.ts | 20 ++++++++++---------- src/bot/database/codeManager.test.ts | 18 +++++++++--------- src/bot/database/userManager.test.ts | 8 ++++---- src/bot/handlers/autoRedeemer.test.ts | 12 +++++++----- src/bot/handlers/autoRedeemer.ts | 8 ++++---- src/bot/utils/crypto.ts | 2 +- 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/bot/database/auditManager.test.ts b/src/bot/database/auditManager.test.ts index c81671e..02d5d2b 100644 --- a/src/bot/database/auditManager.test.ts +++ b/src/bot/database/auditManager.test.ts @@ -33,22 +33,22 @@ describe('logAction', () => { await auditManager.logAction(USER_A, 'TEST_ACTION'); const rows = db.select().from(auditLog).all(); expect(rows).toHaveLength(1); - expect(rows[0].discordId).toBe(USER_A); - expect(rows[0].action).toBe('TEST_ACTION'); - expect(rows[0].details).toBeNull(); + expect(rows[0]!.discordId).toBe(USER_A); + expect(rows[0]!.action).toBe('TEST_ACTION'); + expect(rows[0]!.details).toBeNull(); }); test('stores JSON-serialised details', async () => { await auditManager.logAction(USER_A, 'REDEEM', { code: 'ABCD1234EFGH', status: 'Success' }); const rows = db.select().from(auditLog).all(); - expect(rows[0].details).toBe(JSON.stringify({ code: 'ABCD1234EFGH', status: 'Success' })); + expect(rows[0]!.details).toBe(JSON.stringify({ code: 'ABCD1234EFGH', status: 'Success' })); }); test('accepts null discordId for system-level actions', async () => { await auditManager.logAction(null, 'SYSTEM_START'); const rows = db.select().from(auditLog).all(); - expect(rows[0].discordId).toBeNull(); - expect(rows[0].action).toBe('SYSTEM_START'); + expect(rows[0]!.discordId).toBeNull(); + expect(rows[0]!.action).toBe('SYSTEM_START'); }); test('inserts multiple entries independently', async () => { @@ -72,7 +72,7 @@ describe('getUserAuditLog', () => { await auditManager.logAction(USER_B, 'ACTION_B'); const log = await auditManager.getUserAuditLog(USER_A); expect(log).toHaveLength(1); - expect(log[0].action).toBe('ACTION_A'); + expect(log[0]!.action).toBe('ACTION_A'); }); test('respects the limit parameter', async () => { @@ -131,7 +131,7 @@ describe('getAuditLogSince', () => { await auditManager.logAction(USER_A, 'RECENT_ACTION'); const log = await auditManager.getAuditLogSince('2000-01-01 00:00:00'); expect(log).toHaveLength(1); - expect(log[0].action).toBe('RECENT_ACTION'); + expect(log[0]!.action).toBe('RECENT_ACTION'); }); test('excludes entries before the given timestamp', async () => { @@ -199,7 +199,7 @@ describe('deleteUserAuditLog', () => { await auditManager.deleteUserAuditLog(USER_A); const remaining = db.select().from(auditLog).all(); expect(remaining).toHaveLength(1); - expect(remaining[0].discordId).toBe(USER_B); + expect(remaining[0]!.discordId).toBe(USER_B); }); test('does not delete system-level (null discordId) entries', async () => { @@ -208,7 +208,7 @@ describe('deleteUserAuditLog', () => { await auditManager.deleteUserAuditLog(USER_A); const remaining = db.select().from(auditLog).all(); expect(remaining).toHaveLength(1); - expect(remaining[0].discordId).toBeNull(); + expect(remaining[0]!.discordId).toBeNull(); }); test('after deletion getUserAuditLog returns empty for that user', async () => { diff --git a/src/bot/database/codeManager.test.ts b/src/bot/database/codeManager.test.ts index 9d0f85c..c266a60 100644 --- a/src/bot/database/codeManager.test.ts +++ b/src/bot/database/codeManager.test.ts @@ -68,23 +68,23 @@ describe('addRedeemedCode', () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success'); const rows = db.select().from(redeemedCodes).all(); expect(rows).toHaveLength(1); - expect(rows[0].code).toBe('CODE1234ABCD'); - expect(rows[0].status).toBe('Success'); - expect(rows[0].discordId).toBe(USER_A); - expect(rows[0].isPublic).toBe(0); + expect(rows[0]!.code).toBe('CODE1234ABCD'); + expect(rows[0]!.status).toBe('Success'); + expect(rows[0]!.discordId).toBe(USER_A); + expect(rows[0]!.isPublic).toBe(0); }); test('normalizes numeric status 0 to Success', async () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 0); const rows = db.select().from(redeemedCodes).all(); - expect(rows[0].status).toBe('Success'); + expect(rows[0]!.status).toBe('Success'); expect(await codeManager.isCodeRedeemedByUser('CODE1234ABCD', USER_A)).toBe(true); }); test('normalizes numeric string status "0" to Success', async () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, '0'); const rows = db.select().from(redeemedCodes).all(); - expect(rows[0].status).toBe('Success'); + expect(rows[0]!.status).toBe('Success'); expect(await codeManager.isCodeRedeemedByUser('CODE1234ABCD', USER_A)).toBe(true); }); @@ -100,7 +100,7 @@ describe('addRedeemedCode', () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Code Expired'); const rows = db.select().from(redeemedCodes).all(); expect(rows).toHaveLength(1); - expect(rows[0].status).toBe('Code Expired'); + expect(rows[0]!.status).toBe('Code Expired'); }); test('propagates isPublic=true to all existing rows for the code', async () => { @@ -355,7 +355,7 @@ describe('deleteUserRedeemedCodes', () => { expect(count).toBe(1); const remaining = db.select().from(redeemedCodes).all(); expect(remaining).toHaveLength(1); - expect(remaining[0].discordId).toBe(USER_B); + expect(remaining[0]!.discordId).toBe(USER_B); }); test('does not affect the other user\'s records when both have redeemed the same code', async () => { @@ -364,6 +364,6 @@ describe('deleteUserRedeemedCodes', () => { await codeManager.deleteUserRedeemedCodes(USER_A); const remaining = db.select().from(redeemedCodes).all(); expect(remaining).toHaveLength(1); - expect(remaining[0].discordId).toBe(USER_B); + expect(remaining[0]!.discordId).toBe(USER_B); }); }); diff --git a/src/bot/database/userManager.test.ts b/src/bot/database/userManager.test.ts index 5f7018c..f39e9f3 100644 --- a/src/bot/database/userManager.test.ts +++ b/src/bot/database/userManager.test.ts @@ -25,10 +25,10 @@ describe('saveCredentials', () => { await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); const rows = db.select().from(users).all(); expect(rows).toHaveLength(1); - expect(rows[0].discordId).toBe('user-1'); + expect(rows[0]!.discordId).toBe('user-1'); // Credentials are encrypted at rest — raw DB values must not be plaintext - expect(rows[0].userId).not.toBe('111'); - expect(rows[0].userHash).not.toBe('hash-a'); + expect(rows[0]!.userId).not.toBe('111'); + expect(rows[0]!.userHash).not.toBe('hash-a'); // Decrypted values match originals const creds = await userManager.getCredentials('user-1'); expect(creds?.userId).toBe('111'); @@ -54,7 +54,7 @@ describe('saveCredentials', () => { server: 'server1', }); const rows = db.select().from(users).all(); - expect(rows[0].server).toBe('server1'); + expect(rows[0]!.server).toBe('server1'); }); }); diff --git a/src/bot/handlers/autoRedeemer.test.ts b/src/bot/handlers/autoRedeemer.test.ts index 64f4bea..b327418 100644 --- a/src/bot/handlers/autoRedeemer.test.ts +++ b/src/bot/handlers/autoRedeemer.test.ts @@ -49,10 +49,12 @@ let setTimeoutSpy: ReturnType; beforeAll(() => { initializeDatabase(); // Make randomDelay a no-op so tests don't wait 2–5 s per transition. - setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation((fn: TimerHandler) => { - if (typeof fn === 'function') fn(); - return 0 as unknown as ReturnType; - }); + setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation( + ((fn: (...args: unknown[]) => void) => { + if (typeof fn === 'function') fn(); + return 0 as unknown as ReturnType; + }) as unknown as typeof setTimeout + ); }); beforeEach(() => { @@ -312,7 +314,7 @@ describe('autoRedeemForAllUsers – OutdatedInstanceId retry', () => { expect(getUserDetailsSpy).toHaveBeenCalledTimes(2); expect(submitCodeSpy).toHaveBeenCalledTimes(2); // Second submitCode call must use the fresh instance_id - expect(submitCodeSpy.mock.calls[1][0]).toMatchObject({ instanceId: freshInstanceId }); + expect(submitCodeSpy.mock.calls[1]![0]).toMatchObject({ instanceId: freshInstanceId }); expect(await codeManager.isCodeRedeemedByUser(CODE, USER_A)).toBe(true); }); diff --git a/src/bot/handlers/autoRedeemer.ts b/src/bot/handlers/autoRedeemer.ts index 8b228d3..bbeea34 100644 --- a/src/bot/handlers/autoRedeemer.ts +++ b/src/bot/handlers/autoRedeemer.ts @@ -29,7 +29,7 @@ let redeemQueue: Promise = Promise.resolve(); export function enqueueAutoRedeem(codes: string[]): void { redeemQueue = redeemQueue .then(() => autoRedeemForAllUsers(codes)) - .catch((error) => logger.error('[AUTO REDEEMER] Unhandled error during auto-redeem:', error)); + .catch((error) => { logger.error('[AUTO REDEEMER] Unhandled error during auto-redeem:', error); }); } function randomDelay(): Promise { @@ -87,7 +87,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr logger.error(`[AUTO REDEEMER] Server switch failed for user ${discordId}`); return; } - server = newServer; + server = newServer as string; await userManager.updateServer(discordId, server); userResult = await IdleChampionsApi.getUserDetails({ server, @@ -124,7 +124,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr if (generic.status === 4 && generic.newServer) { // Server switched mid-session - server = generic.newServer; + server = generic.newServer as string; await userManager.updateServer(discordId, server); logger.info(`[AUTO REDEEMER] Server switched for user ${discordId}, retrying submitCode`); submitResponse = await IdleChampionsApi.submitCode({ @@ -250,7 +250,7 @@ export async function autoRedeemForAllUsers(codes: string[]): Promise { ); for (let i = 0; i < allUsers.length; i++) { - const user = allUsers[i]; + const user = allUsers[i]!; logger.info(`[AUTO REDEEMER] Processing user ${user.discordId}`); for (const code of codes) { diff --git a/src/bot/utils/crypto.ts b/src/bot/utils/crypto.ts index 943c09c..26ce5ef 100644 --- a/src/bot/utils/crypto.ts +++ b/src/bot/utils/crypto.ts @@ -61,7 +61,7 @@ export function decrypt(ciphertext: string): string { if (parts.length !== 3) { throw new Error('Invalid encrypted value format'); } - const [ivHex, authTagHex, encryptedHex] = parts; + const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string]; const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const encrypted = Buffer.from(encryptedHex, 'hex'); From eca25c794221c1a731de1d93f763f829a502f9b3 Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Thu, 14 May 2026 21:40:56 +0200 Subject: [PATCH 09/10] fix: include backfill_operations in deleteaccount existence check and deletion - Add hasUserBackfillOperations() and deleteUserBackfillOperations() to BackfillManager so callers can detect and remove backfill rows by user - Update /deleteaccount existence check to also detect users who only have backfill_operations rows (no credentials) and would otherwise be told 'nothing to delete' - Delete the user's backfill_operations rows as part of the deletion flow - Update confirmation and success embeds to list backfill history - Update log message to include backfill operation count Signed-off-by: Michael Cramer --- src/bot/commands/deleteaccount.ts | 13 +++++++++---- src/bot/database/backfillManager.ts | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/bot/commands/deleteaccount.ts b/src/bot/commands/deleteaccount.ts index 3f89457..04a38c1 100644 --- a/src/bot/commands/deleteaccount.ts +++ b/src/bot/commands/deleteaccount.ts @@ -11,6 +11,7 @@ import { import { userManager } from '../database/userManager'; import { codeManager } from '../database/codeManager'; import { auditManager } from '../database/auditManager'; +import { backfillManager } from '../database/backfillManager'; import logger from '../utils/logger'; export const data = new SlashCommandBuilder() @@ -22,8 +23,9 @@ export async function execute(interaction: ChatInputCommandInteraction) { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const hasCredentials = await userManager.hasCredentials(interaction.user.id); + const hasBackfillOps = await backfillManager.hasUserBackfillOperations(interaction.user.id); - if (!hasCredentials) { + if (!hasCredentials && !hasBackfillOps) { const embed = new EmbedBuilder() .setColor(0xffaa00) .setTitle('⚠️ No Account Found') @@ -40,7 +42,8 @@ export async function execute(interaction: ChatInputCommandInteraction) { 'This will **permanently and irreversibly** delete all of your data:\n\n' + '• Your Idle Champions credentials\n' + '• Your full code redemption history\n' + - '• Your audit log entries\n\n' + + '• Your audit log entries\n' + + '• Your backfill operation history\n\n' + 'You will need to run `/setup` again to use the bot after this.' ) .setFooter({ text: 'This action cannot be undone. Confirmation expires in 30 seconds.' }); @@ -111,11 +114,12 @@ export async function execute(interaction: ChatInputCommandInteraction) { await codeManager.clearPendingCodes(interaction.user.id); const deletedCodesCount = await codeManager.deleteUserRedeemedCodes(interaction.user.id); await auditManager.deleteUserAuditLog(interaction.user.id); + const deletedBackfillCount = await backfillManager.deleteUserBackfillOperations(interaction.user.id); await userManager.deleteCredentials(interaction.user.id); // Log a non-identifying event — the user's credentials and ID are now gone logger.info( - `[DELETE ACCOUNT] Account deletion completed. Removed ${deletedCodesCount} redeemed code record(s).` + `[DELETE ACCOUNT] Account deletion completed. Removed ${deletedCodesCount} redeemed code record(s) and ${deletedBackfillCount} backfill operation record(s).` ); await interaction.editReply({ @@ -127,7 +131,8 @@ export async function execute(interaction: ChatInputCommandInteraction) { 'All your data has been permanently removed:\n\n' + `• Credentials deleted\n` + `• ${deletedCodesCount} code record(s) deleted\n` + - '• Audit log entries deleted\n\n' + + '• Audit log entries deleted\n' + + `• ${deletedBackfillCount} backfill operation record(s) deleted\n\n` + 'If you want to use the bot again in the future, simply run `/setup`.' ), ], diff --git a/src/bot/database/backfillManager.ts b/src/bot/database/backfillManager.ts index 7820e0a..246cf50 100644 --- a/src/bot/database/backfillManager.ts +++ b/src/bot/database/backfillManager.ts @@ -98,6 +98,31 @@ class BackfillManager { .where(eq(backfillOperations.id, operationId)) .get() as BackfillOperation | undefined; } + + async hasUserBackfillOperations(discordId: string): Promise { + const row = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .limit(1) + .get(); + return row !== undefined; + } + + async deleteUserBackfillOperations(discordId: string): Promise { + const rows = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .all(); + const total = rows.length; + if (total > 0) { + db.delete(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .run(); + } + return total; + } } export const backfillManager = new BackfillManager(); From 5b21c5e7c8053b14a03ab25b8db4d0c9bb1a730c Mon Sep 17 00:00:00 2001 From: Michael Cramer Date: Fri, 15 May 2026 08:11:59 +0200 Subject: [PATCH 10/10] fix: include backfill_operations in deleteaccount existence check and deletion - Add hasUserBackfillOperations() and hasUserActiveBackfill() to BackfillManager - Refuse /deleteaccount while an in_progress backfill exists for the user - Delete backfill_operations rows as part of account deletion flow - Update confirmation and success embeds to list backfill history - Update api-reference.md and full-documentation.md to document the deletion Signed-off-by: Michael Cramer --- docs/api-reference.md | 13 ++++++++----- docs/full-documentation.md | 1 + src/bot/commands/deleteaccount.ts | 18 ++++++++++++++++++ src/bot/database/backfillManager.ts | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e6de657..ae1c36a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -720,21 +720,24 @@ Permanently delete all data the bot holds about the invoking user. **Parameters**: None **Flow**: -1. Bot checks whether credentials exist for the user. If none are found, responds with a warning and exits. -2. Bot sends an ephemeral embed with Yes / Cancel buttons (30-second timeout). -3. If the user clicks **Yes, delete everything**: credentials, all redeemed code records, and audit log entries are permanently removed. A summary is shown. -4. If the user clicks **Cancel** or the timeout elapses: no data is changed. +1. Bot checks whether credentials or backfill history exist for the user. If none are found, responds with a warning and exits. +2. Bot checks whether a backfill the user initiated is currently in progress; if so, refuses deletion until it completes. +3. Bot sends an ephemeral embed with Yes / Cancel buttons (30-second timeout). +4. If the user clicks **Yes, delete everything**: credentials, all redeemed code records, audit log entries, and backfill operation history are permanently removed. A summary is shown. +5. If the user clicks **Cancel** or the timeout elapses: no data is changed. **Data removed on confirmation**: - `users` row (credentials, server, autoredeem preference) - All `redeemed_codes` rows for the user - All `audit_log` rows for the user +- All `backfill_operations` rows for the user **Error Codes**: | Code | Meaning | Resolution | |------|---------|------------| -| `NO_ACCOUNT` | No credentials stored for this user | Nothing to delete | +| `NO_ACCOUNT` | No credentials or backfill history stored for this user | Nothing to delete | +| `BACKFILL_IN_PROGRESS` | A backfill the user initiated is still running | Wait for the backfill to complete, then retry | **Example**: ``` diff --git a/docs/full-documentation.md b/docs/full-documentation.md index aa6bb0a..b1e327e 100644 --- a/docs/full-documentation.md +++ b/docs/full-documentation.md @@ -215,6 +215,7 @@ Permanently and irreversibly delete all data the bot holds about you. Requires a - Your Idle Champions credentials (user ID + hash) - Your full code redemption history - Your audit log entries + - Your backfill operation history - **Confirmation:** A Yes / Cancel button prompt appears with a 30-second timeout — no action is taken unless you click **Yes, delete everything** - **After deletion:** You will need to run `/setup` again to use the bot - **Example:** `/deleteaccount` diff --git a/src/bot/commands/deleteaccount.ts b/src/bot/commands/deleteaccount.ts index 04a38c1..b22c50d 100644 --- a/src/bot/commands/deleteaccount.ts +++ b/src/bot/commands/deleteaccount.ts @@ -109,6 +109,24 @@ export async function execute(interaction: ChatInputCommandInteraction) { return; } + // Refuse deletion if the user has an active backfill — completing it would + // try to write to rows that no longer exist after account deletion. + if (await backfillManager.hasUserActiveBackfill(interaction.user.id)) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('⚠️ Backfill In Progress') + .setDescription( + 'A backfill operation you initiated is currently running. ' + + 'Please wait for it to complete before deleting your account.' + ), + ], + components: [], + }); + return; + } + // Perform deletion — order matters for FK constraints: // pending_codes.discord_id → users.discord_id (no cascade), so clear it first await codeManager.clearPendingCodes(interaction.user.id); diff --git a/src/bot/database/backfillManager.ts b/src/bot/database/backfillManager.ts index 246cf50..4c72183 100644 --- a/src/bot/database/backfillManager.ts +++ b/src/bot/database/backfillManager.ts @@ -109,6 +109,21 @@ class BackfillManager { return row !== undefined; } + async hasUserActiveBackfill(discordId: string): Promise { + const row = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where( + and( + eq(backfillOperations.initiatedBy, discordId), + eq(backfillOperations.status, 'in_progress') + ) + ) + .limit(1) + .get(); + return row !== undefined; + } + async deleteUserBackfillOperations(discordId: string): Promise { const rows = db .select({ id: backfillOperations.id })