diff --git a/package.json b/package.json index 0510246..d3f7fb6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "generate": "docker compose run --rm app drizzle-kit generate", "migrate": "docker compose run --rm app drizzle-kit migrate", "db:push": "docker compose run --rm app drizzle-kit push", + "db:push:local": "drizzle-kit push", "dev": "bun --watch src/index.ts", "db:studio": "drizzle-kit studio --host 0.0.0.0" }, diff --git a/src/commands/admin/update.ts b/src/commands/admin/update.ts index 678d5c7..d33412b 100644 --- a/src/commands/admin/update.ts +++ b/src/commands/admin/update.ts @@ -76,9 +76,27 @@ export const update = createCommand({ const { stdout } = await execAsync(`git reset --hard origin/${branch}`); + // Run DB Migrations + await confirmation.editReply({ + embeds: [createWarningEmbed("Updating database schema...", "Running Migrations")], + components: [] + }); + + let migrationOutput = ""; + try { + const { stdout: dbOut } = await execAsync("bun run db:push:local"); + migrationOutput = dbOut; + } catch (dbErr: any) { + migrationOutput = `Migration Failed: ${dbErr.message}`; + console.error("Migration Error:", dbErr); + // We continue with restart even if migration fails? + // Probably safer to try, or should we abort? + // For now, let's log it and proceed, as code might depend on it but maybe it was a no-op partial fail. + } + await interaction.followUp({ flags: MessageFlags.Ephemeral, - embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nRestarting process...`, "Update Successful")] + embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nDB Migration Output:\n\`\`\`\n${migrationOutput}\n\`\`\`\nRestarting process...`, "Update Successful")] }); // Write context for post-restart notification diff --git a/src/db/schema.ts b/src/db/schema.ts index 007f874..da53f55 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -130,8 +130,17 @@ export const userTimers = pgTable('user_timers', { }, (table) => [ primaryKey({ columns: [table.userId, table.type, table.key] }) ]); +// 9. Lootdrops +export const lootdrops = pgTable('lootdrops', { + messageId: varchar('message_id', { length: 255 }).primaryKey(), + channelId: varchar('channel_id', { length: 255 }).notNull(), + rewardAmount: integer('reward_amount').notNull(), + currency: varchar('currency', { length: 50 }).notNull(), + claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }), +}); -// --- RELATIONS --- export const classesRelations = relations(classes, ({ many }) => ({ users: many(users), diff --git a/src/modules/economy/lootdrop.service.ts b/src/modules/economy/lootdrop.service.ts index b670b5b..bdaa903 100644 --- a/src/modules/economy/lootdrop.service.ts +++ b/src/modules/economy/lootdrop.service.ts @@ -3,6 +3,10 @@ import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, Bu import { config } from "@/lib/config"; import { economyService } from "./economy.service"; +import { lootdrops } from "@/db/schema"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import { eq, and, isNull, lt } from "drizzle-orm"; + interface Lootdrop { messageId: string; channelId: string; @@ -15,11 +19,13 @@ interface Lootdrop { class LootdropService { private channelActivity: Map = new Map(); private channelCooldowns: Map = new Map(); - private activeLootdrops: Map = new Map(); // key: messageId constructor() { - // Cleanup interval for activity tracking - setInterval(() => this.cleanupActivity(), 60000); + // Cleanup interval for activity tracking and expired lootdrops + setInterval(() => { + this.cleanupActivity(); + this.cleanupExpiredLootdrops(); + }, 60000); } private cleanupActivity() { @@ -36,6 +42,19 @@ class LootdropService { } } + private async cleanupExpiredLootdrops() { + try { + const now = new Date(); + await DrizzleClient.delete(lootdrops) + .where(and( + isNull(lootdrops.claimedBy), + lt(lootdrops.expiresAt, now) + )); + } catch (error) { + console.error("Failed to cleanup lootdrops:", error); + } + } + public async processMessage(message: Message) { if (message.author.bot || !message.guild) return; @@ -61,8 +80,6 @@ class LootdropService { await this.spawnLootdrop(message.channel as TextChannel); // Set cooldown this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs); - // Reset activity for this channel to prevent immediate double spawn? - // Maybe not strictly necessary if cooldown handles it, but good practice. this.channelActivity.set(channelId, []); } } @@ -92,44 +109,45 @@ class LootdropService { try { const message = await channel.send({ embeds: [embed], components: [row] }); - this.activeLootdrops.set(message.id, { + // Persist to DB + await DrizzleClient.insert(lootdrops).values({ messageId: message.id, channelId: channel.id, rewardAmount: reward, currency: currency, - createdAt: new Date() + createdAt: new Date(), + // Expire after 10 mins + expiresAt: new Date(Date.now() + 600000) }); - // Auto-cleanup unclaimable drops after 10 minutes (optional, prevents memory leak) - setTimeout(() => { - const drop = this.activeLootdrops.get(message.id); - if (drop && !drop.claimedBy) { - this.activeLootdrops.delete(message.id); - // Optionally edit message to say expired - } - }, 600000); - } catch (error) { console.error("Failed to spawn lootdrop:", error); } } public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> { - const drop = this.activeLootdrops.get(messageId); - - if (!drop) { - return { success: false, error: "This lootdrop has expired or already been fully processed." }; - } - - if (drop.claimedBy) { - return { success: false, error: "This lootdrop has already been claimed." }; - } - - // Lock it - drop.claimedBy = userId; - this.activeLootdrops.set(messageId, drop); - try { + // Atomic update: Try to set claimedBy where it is currently null + // This acts as a lock and check in one query + const result = await DrizzleClient.update(lootdrops) + .set({ claimedBy: BigInt(userId) }) + .where(and( + eq(lootdrops.messageId, messageId), + isNull(lootdrops.claimedBy) + )) + .returning(); + + if (result.length === 0 || !result[0]) { + // If update affected 0 rows, check if it was because it doesn't exist or is already claimed + const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId)); + if (check.length === 0) { + return { success: false, error: "This lootdrop has expired." }; + } + return { success: false, error: "This lootdrop has already been claimed." }; + } + + const drop = result[0]; + await economyService.modifyUserBalance( userId, BigInt(drop.rewardAmount), @@ -137,15 +155,10 @@ class LootdropService { `Claimed lootdrop in channel ${drop.channelId}` ); - // Clean up from memory - this.activeLootdrops.delete(messageId); - return { success: true, amount: drop.rewardAmount, currency: drop.currency }; + } catch (error) { console.error("Error claiming lootdrop:", error); - // Unlock if failed? - drop.claimedBy = undefined; - this.activeLootdrops.set(messageId, drop); return { success: false, error: "An error occurred while processing the reward." }; } }