feat: implement scheduled cleanup job for expired data

This commit is contained in:
syntaxbullet
2026-01-06 17:44:08 +01:00
parent 606d83a7ae
commit bc89ddf7c0
4 changed files with 262 additions and 70 deletions

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
import { cleanupService } from "./cleanup.service";
import { lootdrops, userTimers, userQuests } from "@/db/schema";
import { config } from "@/lib/config";
const mockDelete = mock();
const mockReturning = mock();
const mockWhere = mock();
const mockFindMany = mock();
mockDelete.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
delete: mockDelete,
query: {
userTimers: {
findMany: mockFindMany
}
}
}
}));
// Mock AuroraClient
mock.module("@/lib/BotClient", () => ({
AuroraClient: {
guilds: {
fetch: mock().mockResolvedValue({
members: {
fetch: mock().mockResolvedValue({
user: { tag: "TestUser#1234" },
roles: { remove: mock().mockResolvedValue({}) }
})
}
})
}
}
}));
// Mock Config
mock.module("@/lib/config", () => ({
config: {
system: {
cleanup: {
intervalMs: 86400000,
questArchiveDays: 30
}
}
}
}));
describe("cleanupService", () => {
beforeEach(() => {
mockDelete.mockClear();
mockWhere.mockClear();
mockReturning.mockClear();
mockFindMany.mockClear();
});
it("cleanupLootdrops should delete expired unclaimed lootdrops", async () => {
mockReturning.mockResolvedValue([{ id: "msg1" }, { id: "msg2" }]);
const count = await cleanupService.cleanupLootdrops();
expect(count).toBe(2);
expect(mockDelete).toHaveBeenCalledWith(lootdrops);
});
it("cleanupTimers should delete expired timers and handle roles", async () => {
// Mock findMany for expired ACCESS timers
mockFindMany.mockResolvedValue([
{ userId: 123n, type: 'ACCESS', key: 'role_456', metadata: { roleId: '456' } }
]);
// Mock returning for bulk delete
mockReturning.mockResolvedValue([{ userId: 789n }]); // One other timer
const count = await cleanupService.cleanupTimers();
// 1 from findMany + 1 from bulk delete (simplified mock behavior)
expect(count).toBe(2);
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("cleanupQuests should delete old completed quests", async () => {
mockReturning.mockResolvedValue([{ userId: 123n }]);
const count = await cleanupService.cleanupQuests();
expect(count).toBe(1);
expect(mockDelete).toHaveBeenCalledWith(userQuests);
});
it("runAll should run all cleanup tasks and log stats", async () => {
const spyLootdrops = spyOn(cleanupService, 'cleanupLootdrops').mockResolvedValue(1);
const spyTimers = spyOn(cleanupService, 'cleanupTimers').mockResolvedValue(2);
const spyQuests = spyOn(cleanupService, 'cleanupQuests').mockResolvedValue(3);
await cleanupService.runAll();
expect(spyLootdrops).toHaveBeenCalled();
expect(spyTimers).toHaveBeenCalled();
expect(spyQuests).toHaveBeenCalled();
spyLootdrops.mockRestore();
spyTimers.mockRestore();
spyQuests.mockRestore();
});
});

View File

@@ -0,0 +1,118 @@
import { lootdrops, userTimers, userQuests } from "@/db/schema";
import { eq, and, lt, isNull, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
import { config } from "@/lib/config";
export const cleanupService = {
/**
* Runs all cleanup tasks
*/
runAll: async () => {
console.log("🧹 Starting system cleanup...");
const stats = {
lootdrops: 0,
timers: 0,
quests: 0
};
try {
stats.lootdrops = await cleanupService.cleanupLootdrops();
stats.timers = await cleanupService.cleanupTimers();
stats.quests = await cleanupService.cleanupQuests();
console.log(`✅ Cleanup finished. Stats:
- Lootdrops: ${stats.lootdrops} removed
- Timers: ${stats.timers} removed
- Quests: ${stats.quests} cleaned`);
} catch (error) {
console.error("❌ Error during cleanup:", error);
}
},
/**
* Deletes unclaimed expired lootdrops
*/
cleanupLootdrops: async (): Promise<number> => {
const now = new Date();
const result = await DrizzleClient.delete(lootdrops)
.where(and(
lt(lootdrops.expiresAt, now),
isNull(lootdrops.claimedBy)
))
.returning({ id: lootdrops.messageId });
return result.length;
},
/**
* Cleans up expired user timers and handles side effects (like role removal)
*/
cleanupTimers: async (): Promise<number> => {
const now = new Date();
let deletedCount = 0;
// 1. Specific handling for ACCESS timers (revoking roles etc)
// This is migrated from scheduler.ts
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, 'ACCESS'),
lt(userTimers.expiresAt, now)
)
});
for (const timer of expiredAccess) {
const meta = timer.metadata as any;
const userIdStr = timer.userId.toString();
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr);
await member.roles.remove(roleId);
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
}
} catch (err) {
console.error(`Failed to remove role for user ${userIdStr}:`, err);
}
}
// Delete specifically this one
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
deletedCount++;
}
// 2. Bulk delete all other expired timers (COOLDOWN, EFFECT, etc)
const result = await DrizzleClient.delete(userTimers)
.where(lt(userTimers.expiresAt, now))
.returning({ userId: userTimers.userId });
deletedCount += result.length;
return deletedCount;
},
/**
* Deletes or archives old completed quests
*/
cleanupQuests: async (): Promise<number> => {
const archiveDays = config.system.cleanup.questArchiveDays;
const threshold = new Date();
threshold.setDate(threshold.getDate() - archiveDays);
const result = await DrizzleClient.delete(userQuests)
.where(lt(userQuests.completedAt, threshold))
.returning({ userId: userQuests.userId });
return result.length;
}
};

View File

@@ -1,86 +1,32 @@
import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
import { cleanupService } from "./cleanup.service";
import { config } from "@/lib/config";
/**
* The Janitor responsible for cleaning up expired ACCESS timers
* and revoking privileges.
* The Scheduler responsible for periodic tasks and system maintenance.
*/
export const schedulerService = {
start: () => {
console.log("🕒 Scheduler started: Janitor loop running every 60s");
// Run immediately on start
schedulerService.runJanitor();
console.log("🕒 Scheduler started: Maintenance loops initialized.");
// Loop every 60 seconds
// 1. High-frequency timer cleanup (every 60s)
// This handles role revocations and cooldown expirations
setInterval(() => {
schedulerService.runJanitor();
cleanupService.cleanupTimers();
}, 60 * 1000);
// Terminal Update Loop (every 60s)
// 2. Scheduled system cleanup (configurable, default daily)
// This handles lootdrops, quests, etc.
setInterval(() => {
cleanupService.runAll();
}, config.system.cleanup.intervalMs);
// 3. Terminal Update Loop (every 60s)
const { terminalService } = require("@/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);
},
runJanitor: async () => {
try {
// Find all expired ACCESS timers
// We do this in a transaction to ensure we read and delete atomically if possible,
// though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully.
const now = new Date();
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, 'ACCESS'),
lt(userTimers.expiresAt, now)
)
});
if (expiredAccess.length === 0) return;
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
for (const timer of expiredAccess) {
const meta = timer.metadata as any;
const userIdStr = timer.userId.toString();
// Specific Handling for Roles
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
// We try to fetch, if bot is not in guild or lacks perms, it will catch
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr);
await member.roles.remove(roleId);
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
}
} catch (err) {
console.error(`Failed to remove role for user ${userIdStr}:`, err);
// We still delete the timer so we don't loop forever on a left user
}
} else {
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
// TODO: Generic channel permission removal if needed
}
// Delete the timer row
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
}
} catch (error) {
console.error("Janitor Error:", error);
}
// Run an initial cleanup on start for good measure
cleanupService.runAll();
}
};