feat: persistent lootbox states, update command now runs db migrations

This commit is contained in:
syntaxbullet
2025-12-18 17:02:21 +01:00
parent 83cd33e439
commit 7cf8d68d39
4 changed files with 79 additions and 38 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map();
private activeLootdrops: Map<string, Lootdrop> = 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." };
}
}