refactor: convert TradeService to object export pattern

Convert from class-based to object-based export for consistency with
other services (economy, inventory, quest, etc).

Changes:
- Move sessions Map and helper functions to module scope
- Convert static methods to object properties
- Update executeTrade to use withTransaction helper
- Update all imports from TradeService to tradeService

Updated files:
- trade.service.ts (main refactor)
- trade.interaction.ts (update usages)
- trade.ts command (update import and usage)

All tests passing with no breaking changes.
This commit is contained in:
syntaxbullet
2025-12-24 21:57:30 +01:00
parent 77d3fafdce
commit 2933eaeafc
3 changed files with 110 additions and 103 deletions

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js"; import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { TradeService } from "@/modules/trade/trade.service"; import { tradeService } from "@/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view"; import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
@@ -59,7 +59,7 @@ export const trade = createCommand({
} }
// Setup Session // Setup Session
const session = TradeService.createSession(thread.id, const session = tradeService.createSession(thread.id,
{ id: interaction.user.id, username: interaction.user.username }, { id: interaction.user.id, username: interaction.user.username },
{ id: targetUser.id, username: targetUser.username } { id: targetUser.id, username: targetUser.username }
); );

View File

@@ -7,7 +7,7 @@ import {
TextChannel, TextChannel,
EmbedBuilder EmbedBuilder
} from "discord.js"; } from "discord.js";
import { TradeService } from "./trade.service"; import { tradeService } from "./trade.service";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
@@ -55,10 +55,10 @@ export async function handleTradeInteraction(interaction: Interaction) {
} }
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
const user = interaction.user; const user = interaction.user;
TradeService.endSession(threadId); tradeService.endSession(threadId);
await interaction.deferUpdate(); await interaction.deferUpdate();
@@ -70,11 +70,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const isLocked = TradeService.toggleLock(threadId, interaction.user.id); const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
// Check if trade executed (both locked) // Check if trade executed (both locked)
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (session && session.state === 'COMPLETED') { if (session && session.state === 'COMPLETED') {
// Trade executed during updateTradeDashboard // Trade executed during updateTradeDashboard
return; return;
@@ -95,7 +95,7 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
if (amount < 0n) throw new Error("Amount must be positive"); if (amount < 0n) throw new Error("Amount must be positive");
TradeService.updateMoney(threadId, interaction.user.id, amount); tradeService.updateMoney(threadId, interaction.user.id, amount);
await interaction.deferUpdate(); // Acknowledge modal await interaction.deferUpdate(); // Acknowledge modal
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
} }
@@ -128,14 +128,14 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
const item = await inventoryService.getItem(itemId); const item = await inventoryService.getItem(itemId);
if (!item) throw new Error("Item not found"); if (!item) throw new Error("Item not found");
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n); tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
await interaction.update({ content: `Added ${item.name} x1`, components: [] }); await interaction.update({ content: `Added ${item.name} x1`, components: [] });
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
} }
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) { async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) return; if (!session) return;
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB; const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
@@ -158,7 +158,7 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
const value = interaction.values[0]; const value = interaction.values[0];
if (!value) return; if (!value) return;
const itemId = parseInt(value); const itemId = parseInt(value);
TradeService.removeItem(threadId, interaction.user.id, itemId); tradeService.removeItem(threadId, interaction.user.id, itemId);
await interaction.update({ content: `Removed item.`, components: [] }); await interaction.update({ content: `Removed item.`, components: [] });
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
@@ -168,14 +168,14 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
// --- DASHBOARD UPDATER --- // --- DASHBOARD UPDATER ---
export async function updateTradeDashboard(interaction: Interaction, threadId: string) { export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) return; if (!session) return;
// Check Auto-Execute (If both locked) // Check Auto-Execute (If both locked)
if (session.userA.locked && session.userB.locked) { if (session.userA.locked && session.userB.locked) {
// Execute Trade // Execute Trade
try { try {
await TradeService.executeTrade(threadId); await tradeService.executeTrade(threadId);
const embed = getTradeCompletedEmbed(session); const embed = getTradeCompletedEmbed(session);
await updateDashboardMessage(interaction, { embeds: [embed], components: [] }); await updateDashboardMessage(interaction, { embeds: [embed], components: [] });

View File

@@ -1,17 +1,80 @@
import type { TradeSession, TradeParticipant } from "./trade.types"; import type { TradeSession, TradeParticipant } from "./trade.types";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { itemTransactions } from "@/db/schema"; import { itemTransactions } from "@/db/schema";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
export class TradeService { // Module-level session storage
private static sessions = new Map<string, TradeSession>(); const sessions = new Map<string, TradeSession>();
/**
* Unlocks both participants in a trade session
*/
const unlockAll = (session: TradeSession) => {
session.userA.locked = false;
session.userB.locked = false;
};
/**
* Processes a one-way transfer from one participant to another
*/
const processTransfer = async (tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) => {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
'TRADE_OUT',
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
);
await economyService.modifyUserBalance(
to.id,
from.offer.money,
'TRADE_IN',
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
);
}
// 2. Items
for (const item of from.offer.items) {
// Remove from sender
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
// Add to receiver
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
// Log Item Transaction (Sender)
await tx.insert(itemTransactions).values({
userId: BigInt(from.id),
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: 'TRADE_OUT',
description: `Traded to ${to.username}`,
});
// Log Item Transaction (Receiver)
await tx.insert(itemTransactions).values({
userId: BigInt(to.id),
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: 'TRADE_IN',
description: `Received from ${from.username}`,
});
}
};
export const tradeService = {
/** /**
* Creates a new trade session * Creates a new trade session
*/ */
static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession { createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
const session: TradeSession = { const session: TradeSession = {
threadId, threadId,
userA: { userA: {
@@ -30,24 +93,24 @@ export class TradeService {
lastInteraction: Date.now() lastInteraction: Date.now()
}; };
this.sessions.set(threadId, session); sessions.set(threadId, session);
return session; return session;
} },
static getSession(threadId: string): TradeSession | undefined { getSession: (threadId: string): TradeSession | undefined => {
return this.sessions.get(threadId); return sessions.get(threadId);
} },
static endSession(threadId: string) { endSession: (threadId: string) => {
this.sessions.delete(threadId); sessions.delete(threadId);
} },
/** /**
* Updates an offer. If allowed, validation checks should be done BEFORE calling this. * Updates an offer. If allowed, validation checks should be done BEFORE calling this.
* unlocking logic is handled here (if offer changes, unlock both). * unlocking logic is handled here (if offer changes, unlock both).
*/ */
static updateMoney(threadId: string, userId: string, amount: bigint) { updateMoney: (threadId: string, userId: string, amount: bigint) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
@@ -55,12 +118,12 @@ export class TradeService {
if (!participant) throw new Error("User not in trade"); if (!participant) throw new Error("User not in trade");
participant.offer.money = amount; participant.offer.money = amount;
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) { addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
@@ -74,12 +137,12 @@ export class TradeService {
participant.offer.items.push({ id: item.id, name: item.name, quantity }); participant.offer.items.push({ id: item.id, name: item.name, quantity });
} }
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static removeItem(threadId: string, userId: string, itemId: number) { removeItem: (threadId: string, userId: string, itemId: number) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
@@ -87,12 +150,12 @@ export class TradeService {
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId); participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static toggleLock(threadId: string, userId: string): boolean { toggleLock: (threadId: string, userId: string): boolean => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
@@ -102,12 +165,7 @@ export class TradeService {
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
return participant.locked; return participant.locked;
} },
private static unlockAll(session: TradeSession) {
session.userA.locked = false;
session.userB.locked = false;
}
/** /**
* Executes the trade atomically. * Executes the trade atomically.
@@ -116,8 +174,8 @@ export class TradeService {
* 3. Swaps items. * 3. Swaps items.
* 4. Logs transactions. * 4. Logs transactions.
*/ */
static async executeTrade(threadId: string): Promise<void> { executeTrade: async (threadId: string): Promise<void> => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (!session.userA.locked || !session.userB.locked) { if (!session.userA.locked || !session.userB.locked) {
@@ -126,65 +184,14 @@ export class TradeService {
session.state = 'COMPLETED'; // Prevent double execution session.state = 'COMPLETED'; // Prevent double execution
await DrizzleClient.transaction(async (tx) => { await withTransaction(async (tx) => {
// -- Validate & Execute User A -> User B -- // -- Validate & Execute User A -> User B --
await this.processTransfer(tx, session.userA, session.userB, session.threadId); await processTransfer(tx, session.userA, session.userB, session.threadId);
// -- Validate & Execute User B -> User A -- // -- Validate & Execute User B -> User A --
await this.processTransfer(tx, session.userB, session.userA, session.threadId); await processTransfer(tx, session.userB, session.userA, session.threadId);
}); });
this.endSession(threadId); tradeService.endSession(threadId);
} }
};
private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
'TRADE_OUT',
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
);
await economyService.modifyUserBalance(
to.id,
from.offer.money,
'TRADE_IN',
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
);
}
// 2. Items
for (const item of from.offer.items) {
// Remove from sender
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
// Add to receiver
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
// Log Item Transaction (Sender)
await tx.insert(itemTransactions).values({
userId: BigInt(from.id),
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: 'TRADE_OUT',
description: `Traded to ${to.username}`,
});
// Log Item Transaction (Receiver)
await tx.insert(itemTransactions).values({
userId: BigInt(to.id),
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: 'TRADE_IN',
description: `Received from ${from.username}`,
});
}
}
}