feat: implement scheduled cleanup job for expired data
This commit is contained in:
@@ -69,6 +69,12 @@ export interface GameConfigType {
|
|||||||
autoTimeoutThreshold?: number;
|
autoTimeoutThreshold?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
system: {
|
||||||
|
cleanup: {
|
||||||
|
intervalMs: number;
|
||||||
|
questArchiveDays: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial default config state
|
// Initial default config state
|
||||||
@@ -160,6 +166,17 @@ const configSchema = z.object({
|
|||||||
cases: {
|
cases: {
|
||||||
dmOnWarn: true
|
dmOnWarn: true
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
system: z.object({
|
||||||
|
cleanup: z.object({
|
||||||
|
intervalMs: z.number().default(24 * 60 * 60 * 1000), // Daily
|
||||||
|
questArchiveDays: z.number().default(30)
|
||||||
|
})
|
||||||
|
}).default({
|
||||||
|
cleanup: {
|
||||||
|
intervalMs: 24 * 60 * 60 * 1000,
|
||||||
|
questArchiveDays: 30
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
111
src/modules/system/cleanup.service.test.ts
Normal file
111
src/modules/system/cleanup.service.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/modules/system/cleanup.service.ts
Normal file
118
src/modules/system/cleanup.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,86 +1,32 @@
|
|||||||
import { userTimers } from "@/db/schema";
|
import { cleanupService } from "./cleanup.service";
|
||||||
import { eq, and, lt } from "drizzle-orm";
|
import { config } from "@/lib/config";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Janitor responsible for cleaning up expired ACCESS timers
|
* The Scheduler responsible for periodic tasks and system maintenance.
|
||||||
* and revoking privileges.
|
|
||||||
*/
|
*/
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
console.log("🕒 Scheduler started: Janitor loop running every 60s");
|
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||||
// Run immediately on start
|
|
||||||
schedulerService.runJanitor();
|
|
||||||
|
|
||||||
// Loop every 60 seconds
|
// 1. High-frequency timer cleanup (every 60s)
|
||||||
|
// This handles role revocations and cooldown expirations
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
schedulerService.runJanitor();
|
cleanupService.cleanupTimers();
|
||||||
}, 60 * 1000);
|
}, 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");
|
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
},
|
|
||||||
|
|
||||||
runJanitor: async () => {
|
// Run an initial cleanup on start for good measure
|
||||||
try {
|
cleanupService.runAll();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user