From 7e986fae5aff06aac65b43ab4c6a249973c0e812 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 15 Dec 2025 22:14:17 +0100 Subject: [PATCH] feat: Implement custom error classes, a Drizzle transaction utility, and update Discord.js ephemeral message flags. --- src/commands/economy/daily.ts | 6 +-- src/commands/economy/pay.ts | 15 +++--- src/commands/economy/trade.ts | 10 ++-- src/commands/quest/quests.ts | 4 +- src/commands/system/reload.ts | 4 +- src/commands/system/webhook.ts | 4 +- src/lib/db.ts | 15 ++++++ src/lib/errors.ts | 18 +++++++ src/lib/types.ts | 5 ++ src/modules/economy/economy.service.ts | 60 +++++++--------------- src/modules/inventory/inventory.service.ts | 27 +++++----- src/modules/leveling/leveling.service.ts | 31 +++-------- src/modules/quest/quest.service.ts | 24 ++++----- src/modules/trade/trade.service.ts | 3 +- 14 files changed, 112 insertions(+), 114 deletions(-) create mode 100644 src/lib/db.ts create mode 100644 src/lib/errors.ts diff --git a/src/commands/economy/daily.ts b/src/commands/economy/daily.ts index f1d10af..a8da200 100644 --- a/src/commands/economy/daily.ts +++ b/src/commands/economy/daily.ts @@ -1,5 +1,5 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js"; import { economyService } from "@/modules/economy/economy.service"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; @@ -25,12 +25,12 @@ export const daily = createCommand({ } catch (error: any) { if (error.message.includes("Daily already claimed")) { - await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], ephemeral: true }); + await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], flags: MessageFlags.Ephemeral }); return; } console.error(error); - await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], ephemeral: true }); + await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], flags: MessageFlags.Ephemeral }); } } }); diff --git a/src/commands/economy/pay.ts b/src/commands/economy/pay.ts index 3b68480..10668b4 100644 --- a/src/commands/economy/pay.ts +++ b/src/commands/economy/pay.ts @@ -1,9 +1,10 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js"; import { economyService } from "@/modules/economy/economy.service"; import { userService } from "@/modules/user/user.service"; import { config } from "@/lib/config"; -import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; +import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds"; +import { UserError } from "@/lib/errors"; export const pay = createCommand({ data: new SlashCommandBuilder() @@ -27,12 +28,12 @@ export const pay = createCommand({ const receiverId = targetUser.id; if (amount < config.economy.transfers.minAmount) { - await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], ephemeral: true }); + await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral }); return; } if (senderId === receiverId) { - await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], ephemeral: true }); + await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral }); return; } @@ -48,12 +49,12 @@ export const pay = createCommand({ await interaction.reply({ embeds: [embed] }); } catch (error: any) { - if (error.message.includes("Insufficient funds")) { - await interaction.reply({ embeds: [createWarningEmbed("Insufficient funds.")], ephemeral: true }); + if (error instanceof UserError) { + await interaction.reply({ embeds: [createWarningEmbed(error.message)], flags: MessageFlags.Ephemeral }); return; } console.error(error); - await interaction.reply({ embeds: [createErrorEmbed("Transfer failed.")], ephemeral: true }); + await interaction.reply({ embeds: [createErrorEmbed("Transfer failed due to an unexpected error.")], flags: MessageFlags.Ephemeral }); } } }); diff --git a/src/commands/economy/trade.ts b/src/commands/economy/trade.ts index 6a42824..ead0ba0 100644 --- a/src/commands/economy/trade.ts +++ b/src/commands/economy/trade.ts @@ -1,5 +1,5 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration, MessageFlags } from "discord.js"; import { TradeService } from "@/modules/trade/trade.service"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; @@ -16,19 +16,19 @@ export const trade = createCommand({ const targetUser = interaction.options.getUser("user", true); if (targetUser.id === interaction.user.id) { - await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true }); + await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral }); return; } if (targetUser.bot) { - await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true }); + await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral }); return; } // Create Thread const channel = interaction.channel; if (!channel || channel.type === ChannelType.DM) { - await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true }); + await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral }); return; } @@ -53,7 +53,7 @@ export const trade = createCommand({ } catch (err) { console.error("Failed to delete setup message", err); } - await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], ephemeral: true }); + await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral }); return; } diff --git a/src/commands/quest/quests.ts b/src/commands/quest/quests.ts index 4962640..1a9e736 100644 --- a/src/commands/quest/quests.ts +++ b/src/commands/quest/quests.ts @@ -1,5 +1,5 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js"; import { questService } from "@/modules/quest/quest.service"; import { createWarningEmbed } from "@lib/embeds"; @@ -8,7 +8,7 @@ export const quests = createCommand({ .setName("quests") .setDescription("View your active quests"), execute: async (interaction) => { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const userQuests = await questService.getUserQuests(interaction.user.id); diff --git a/src/commands/system/reload.ts b/src/commands/system/reload.ts index a286229..1391c1d 100644 --- a/src/commands/system/reload.ts +++ b/src/commands/system/reload.ts @@ -1,6 +1,6 @@ import { createCommand } from "@lib/utils"; import { KyokoClient } from "@/lib/BotClient"; -import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js"; +import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { createErrorEmbed } from "@lib/embeds"; export const reload = createCommand({ @@ -9,7 +9,7 @@ export const reload = createCommand({ .setDescription("Reloads all commands") .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { await KyokoClient.loadCommands(true); diff --git a/src/commands/system/webhook.ts b/src/commands/system/webhook.ts index d6d58bc..3c89e0e 100644 --- a/src/commands/system/webhook.ts +++ b/src/commands/system/webhook.ts @@ -1,5 +1,5 @@ import { createCommand } from "@/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js"; +import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel, MessageFlags } from "discord.js"; import { createErrorEmbed } from "@/lib/embeds"; export const webhook = createCommand({ @@ -13,7 +13,7 @@ export const webhook = createCommand({ .setRequired(true) ), execute: async (interaction) => { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const payloadString = interaction.options.getString("payload", true); let payload; diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..e3b856b --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,15 @@ +import { DrizzleClient } from "./DrizzleClient"; +import type { Transaction } from "./types"; + +export const withTransaction = async ( + callback: (tx: Transaction) => Promise, + tx?: Transaction +): Promise => { + if (tx) { + return await callback(tx); + } else { + return await DrizzleClient.transaction(async (newTx) => { + return await callback(newTx); + }); + } +}; diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..32bce8c --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,18 @@ +export class ApplicationError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class UserError extends ApplicationError { + constructor(message: string) { + super(message); + } +} + +export class SystemError extends ApplicationError { + constructor(message: string) { + super(message); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 502db2a..c1f9e5a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -11,3 +11,8 @@ export interface Event { once?: boolean; execute: (...args: ClientEvents[K]) => Promise | void; } + +import { DrizzleClient } from "./DrizzleClient"; + +export type DbClient = typeof DrizzleClient; +export type Transaction = Parameters[0]>[0]; diff --git a/src/modules/economy/economy.service.ts b/src/modules/economy/economy.service.ts index e48ae3b..95c1238 100644 --- a/src/modules/economy/economy.service.ts +++ b/src/modules/economy/economy.service.ts @@ -1,30 +1,32 @@ import { users, transactions, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; -import { DrizzleClient } from "@/lib/DrizzleClient"; import { config } from "@/lib/config"; +import { withTransaction } from "@/lib/db"; +import type { Transaction } from "@/lib/types"; +import { UserError } from "@/lib/errors"; export const economyService = { - transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => { + transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => { if (amount <= 0n) { - throw new Error("Amount must be positive"); + throw new UserError("Amount must be positive"); } if (fromUserId === toUserId) { - throw new Error("Cannot transfer to self"); + throw new UserError("Cannot transfer to self"); } - const execute = async (txFn: any) => { + return await withTransaction(async (txFn) => { // Check sender balance const sender = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(fromUserId)), }); if (!sender) { - throw new Error("Sender not found"); + throw new UserError("Sender not found"); } if ((sender.balance ?? 0n) < amount) { - throw new Error("Insufficient funds"); + throw new UserError("Insufficient funds"); } // Deduct from sender @@ -59,19 +61,11 @@ export const economyService = { }); return { success: true, amount }; - }; - - if (tx) { - return await execute(tx); - } else { - return await DrizzleClient.transaction(async (t) => { - return await execute(t); - }); - } + }, tx); }, - claimDaily: async (userId: string, tx?: any) => { - const execute = async (txFn: any) => { + claimDaily: async (userId: string, tx?: Transaction) => { + return await withTransaction(async (txFn) => { const now = new Date(); const startOfDay = new Date(now); startOfDay.setHours(0, 0, 0, 0); @@ -86,7 +80,7 @@ export const economyService = { }); if (cooldown && cooldown.expiresAt > now) { - throw new Error(`Daily already claimed. Ready at ${cooldown.expiresAt}`); + throw new UserError(`Daily already claimed. Ready at ${cooldown.expiresAt}`); } // Get user for streak logic @@ -95,7 +89,7 @@ export const economyService = { }); if (!user) { - throw new Error("User not found"); + throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now } let streak = (user.dailyStreak || 0) + 1; @@ -145,26 +139,18 @@ export const economyService = { }); return { claimed: true, amount: totalReward, streak, nextReadyAt }; - }; - - if (tx) { - return await execute(tx); - } else { - return await DrizzleClient.transaction(async (t) => { - return await execute(t); - }); - } + }, tx); }, - modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: any) => { - const execute = async (txFn: any) => { + modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => { + return await withTransaction(async (txFn) => { if (amount < 0n) { // Check sufficient funds if removing const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(id)) }); if (!user || (user.balance ?? 0n) < -amount) { - throw new Error("Insufficient funds"); + throw new UserError("Insufficient funds"); } } @@ -184,14 +170,6 @@ export const economyService = { }); return user; - }; - - if (tx) { - return await execute(tx); - } else { - return await DrizzleClient.transaction(async (t) => { - return await execute(t); - }); - } + }, tx); }, }; diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index 200ffc3..8878f4f 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -1,13 +1,14 @@ - import { inventory, items, users } from "@/db/schema"; import { eq, and, sql, count } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { economyService } from "@/modules/economy/economy.service"; import { config } from "@/lib/config"; +import { withTransaction } from "@/lib/db"; +import type { Transaction } from "@/lib/types"; export const inventoryService = { - addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { - const execute = async (txFn: any) => { + addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => { + return await withTransaction(async (txFn) => { // Check if item exists in inventory const existing = await txFn.query.inventory.findFirst({ where: and( @@ -39,7 +40,7 @@ export const inventoryService = { .from(inventory) .where(eq(inventory.userId, BigInt(userId))); - if (inventoryCount.count >= config.inventory.maxSlots) { + if (inventoryCount && inventoryCount.count >= config.inventory.maxSlots) { throw new Error(`Inventory full (Max ${config.inventory.maxSlots} slots)`); } @@ -56,12 +57,11 @@ export const inventoryService = { .returning(); return entry; } - }; - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, - removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { - const execute = async (txFn: any) => { + removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => { + return await withTransaction(async (txFn) => { const existing = await txFn.query.inventory.findFirst({ where: and( eq(inventory.userId, BigInt(userId)), @@ -93,8 +93,7 @@ export const inventoryService = { .returning(); return entry; } - }; - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, getInventory: async (userId: string) => { @@ -106,8 +105,8 @@ export const inventoryService = { }); }, - buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { - const execute = async (txFn: any) => { + buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => { + return await withTransaction(async (txFn) => { const item = await txFn.query.items.findFirst({ where: eq(items.id, itemId), }); @@ -123,9 +122,7 @@ export const inventoryService = { await inventoryService.addItem(userId, itemId, quantity, txFn); return { success: true, item, totalPrice }; - }; - - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, getItem: async (itemId: number) => { diff --git a/src/modules/leveling/leveling.service.ts b/src/modules/leveling/leveling.service.ts index dafc950..d112bbd 100644 --- a/src/modules/leveling/leveling.service.ts +++ b/src/modules/leveling/leveling.service.ts @@ -1,7 +1,8 @@ import { users, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; -import { DrizzleClient } from "@/lib/DrizzleClient"; +import { withTransaction } from "@/lib/db"; import { config } from "@/lib/config"; +import type { Transaction } from "@/lib/types"; export const levelingService = { // Calculate XP required for a specific level @@ -10,8 +11,8 @@ export const levelingService = { }, // Pure XP addition - No cooldown checks - addXp: async (id: string, amount: bigint, tx?: any) => { - const execute = async (txFn: any) => { + addXp: async (id: string, amount: bigint, tx?: Transaction) => { + return await withTransaction(async (txFn) => { // Get current state const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(id)), @@ -43,20 +44,12 @@ export const levelingService = { .returning(); return { user: updatedUser, levelUp, currentLevel }; - }; - - if (tx) { - return await execute(tx); - } else { - return await DrizzleClient.transaction(async (t) => { - return await execute(t); - }) - } + }, tx); }, // Handle chat XP with cooldowns - processChatXp: async (id: string, tx?: any) => { - const execute = async (txFn: any) => { + processChatXp: async (id: string, tx?: Transaction) => { + return await withTransaction(async (txFn) => { // check if an xp cooldown is in place const cooldown = await txFn.query.userTimers.findFirst({ where: and( @@ -93,14 +86,6 @@ export const levelingService = { }); return { awarded: true, amount, ...result }; - }; - - if (tx) { - return await execute(tx); - } else { - return await DrizzleClient.transaction(async (t) => { - return await execute(t); - }) - } + }, tx); } }; diff --git a/src/modules/quest/quest.service.ts b/src/modules/quest/quest.service.ts index 9128533..3e163c5 100644 --- a/src/modules/quest/quest.service.ts +++ b/src/modules/quest/quest.service.ts @@ -4,10 +4,12 @@ import { eq, and, sql } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { economyService } from "@/modules/economy/economy.service"; import { levelingService } from "@/modules/leveling/leveling.service"; +import { withTransaction } from "@/lib/db"; +import type { Transaction } from "@/lib/types"; export const questService = { - assignQuest: async (userId: string, questId: number, tx?: any) => { - const execute = async (txFn: any) => { + assignQuest: async (userId: string, questId: number, tx?: Transaction) => { + return await withTransaction(async (txFn) => { return await txFn.insert(userQuests) .values({ userId: BigInt(userId), @@ -16,12 +18,11 @@ export const questService = { }) .onConflictDoNothing() // Ignore if already assigned .returning(); - }; - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, - updateProgress: async (userId: string, questId: number, progress: number, tx?: any) => { - const execute = async (txFn: any) => { + updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => { + return await withTransaction(async (txFn) => { return await txFn.update(userQuests) .set({ progress: progress }) .where(and( @@ -29,12 +30,11 @@ export const questService = { eq(userQuests.questId, questId) )) .returning(); - }; - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, - completeQuest: async (userId: string, questId: number, tx?: any) => { - const execute = async (txFn: any) => { + completeQuest: async (userId: string, questId: number, tx?: Transaction) => { + return await withTransaction(async (txFn) => { const userQuest = await txFn.query.userQuests.findFirst({ where: and( eq(userQuests.userId, BigInt(userId)), @@ -73,9 +73,7 @@ export const questService = { } return { success: true, rewards: results }; - }; - - return tx ? await execute(tx) : await DrizzleClient.transaction(execute); + }, tx); }, getUserQuests: async (userId: string) => { diff --git a/src/modules/trade/trade.service.ts b/src/modules/trade/trade.service.ts index 93056e1..33b4f89 100644 --- a/src/modules/trade/trade.service.ts +++ b/src/modules/trade/trade.service.ts @@ -3,6 +3,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient"; import { economyService } from "@/modules/economy/economy.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; import { itemTransactions } from "@/db/schema"; +import type { Transaction } from "@/lib/types"; export class TradeService { private static sessions = new Map(); @@ -136,7 +137,7 @@ export class TradeService { this.endSession(threadId); } - private static async processTransfer(tx: any, from: TradeParticipant, to: TradeParticipant, threadId: string) { + private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) { // 1. Money if (from.offer.money > 0n) { await economyService.modifyUserBalance(