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,143 +1,25 @@
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>();
/** /**
* Creates a new trade session * Unlocks both participants in a trade session
*/ */
static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession { const unlockAll = (session: TradeSession) => {
const session: TradeSession = {
threadId,
userA: {
id: userA.id,
username: userA.username,
locked: false,
offer: { money: 0n, items: [] }
},
userB: {
id: userB.id,
username: userB.username,
locked: false,
offer: { money: 0n, items: [] }
},
state: 'NEGOTIATING',
lastInteraction: Date.now()
};
this.sessions.set(threadId, session);
return session;
}
static getSession(threadId: string): TradeSession | undefined {
return this.sessions.get(threadId);
}
static endSession(threadId: string) {
this.sessions.delete(threadId);
}
/**
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
* unlocking logic is handled here (if offer changes, unlock both).
*/
static updateMoney(threadId: string, userId: string, amount: bigint) {
const session = this.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.money = amount;
this.unlockAll(session);
session.lastInteraction = Date.now();
}
static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) {
const session = this.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
const existing = participant.offer.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += quantity;
} else {
participant.offer.items.push({ id: item.id, name: item.name, quantity });
}
this.unlockAll(session);
session.lastInteraction = Date.now();
}
static removeItem(threadId: string, userId: string, itemId: number) {
const session = this.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
this.unlockAll(session);
session.lastInteraction = Date.now();
}
static toggleLock(threadId: string, userId: string): boolean {
const session = this.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.locked = !participant.locked;
session.lastInteraction = Date.now();
return participant.locked;
}
private static unlockAll(session: TradeSession) {
session.userA.locked = false; session.userA.locked = false;
session.userB.locked = false; session.userB.locked = false;
} };
/** /**
* Executes the trade atomically. * Processes a one-way transfer from one participant to another
* 1. Validates balances/inventory for both users.
* 2. Swaps money.
* 3. Swaps items.
* 4. Logs transactions.
*/ */
static async executeTrade(threadId: string): Promise<void> { const processTransfer = async (tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) => {
const session = this.getSession(threadId);
if (!session) throw new Error("Session not found");
if (!session.userA.locked || !session.userB.locked) {
throw new Error("Both players must accept the trade first.");
}
session.state = 'COMPLETED'; // Prevent double execution
await DrizzleClient.transaction(async (tx) => {
// -- Validate & Execute User A -> User B --
await this.processTransfer(tx, session.userA, session.userB, session.threadId);
// -- Validate & Execute User B -> User A --
await this.processTransfer(tx, session.userB, session.userA, session.threadId);
});
this.endSession(threadId);
}
private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
// 1. Money // 1. Money
if (from.offer.money > 0n) { if (from.offer.money > 0n) {
await economyService.modifyUserBalance( await economyService.modifyUserBalance(
@@ -186,5 +68,130 @@ export class TradeService {
description: `Received from ${from.username}`, description: `Received from ${from.username}`,
}); });
} }
};
export const tradeService = {
/**
* Creates a new trade session
*/
createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
const session: TradeSession = {
threadId,
userA: {
id: userA.id,
username: userA.username,
locked: false,
offer: { money: 0n, items: [] }
},
userB: {
id: userB.id,
username: userB.username,
locked: false,
offer: { money: 0n, items: [] }
},
state: 'NEGOTIATING',
lastInteraction: Date.now()
};
sessions.set(threadId, session);
return session;
},
getSession: (threadId: string): TradeSession | undefined => {
return sessions.get(threadId);
},
endSession: (threadId: string) => {
sessions.delete(threadId);
},
/**
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
* unlocking logic is handled here (if offer changes, unlock both).
*/
updateMoney: (threadId: string, userId: string, amount: bigint) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.money = amount;
unlockAll(session);
session.lastInteraction = Date.now();
},
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
const existing = participant.offer.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += quantity;
} else {
participant.offer.items.push({ id: item.id, name: item.name, quantity });
} }
unlockAll(session);
session.lastInteraction = Date.now();
},
removeItem: (threadId: string, userId: string, itemId: number) => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
unlockAll(session);
session.lastInteraction = Date.now();
},
toggleLock: (threadId: string, userId: string): boolean => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
if (!participant) throw new Error("User not in trade");
participant.locked = !participant.locked;
session.lastInteraction = Date.now();
return participant.locked;
},
/**
* Executes the trade atomically.
* 1. Validates balances/inventory for both users.
* 2. Swaps money.
* 3. Swaps items.
* 4. Logs transactions.
*/
executeTrade: async (threadId: string): Promise<void> => {
const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found");
if (!session.userA.locked || !session.userB.locked) {
throw new Error("Both players must accept the trade first.");
} }
session.state = 'COMPLETED'; // Prevent double execution
await withTransaction(async (tx) => {
// -- Validate & Execute User A -> User B --
await processTransfer(tx, session.userA, session.userB, session.threadId);
// -- Validate & Execute User B -> User A --
await processTransfer(tx, session.userB, session.userA, session.threadId);
});
tradeService.endSession(threadId);
}
};