refactor: convert ModerationService and PruneService from classes to singleton objects

- Convert ModerationService class to moderationService singleton
- Convert PruneService class to pruneService singleton
- Update all command files to use new singleton imports
- Update web routes to use new singleton imports
- Update tests for singleton pattern
- Remove getNextCaseId from tests (now private module function)
This commit is contained in:
syntaxbullet
2026-02-13 13:33:58 +01:00
parent 55d2376ca1
commit 099601ce6d
13 changed files with 176 additions and 184 deletions

1
.gitignore vendored
View File

@@ -46,5 +46,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/ scratchpad/
tickets/
bot/assets/graphics/items bot/assets/graphics/items

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const moderationCase = createCommand({ export const moderationCase = createCommand({
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
} }
// Get the case // Get the case
const moderationCase = await ModerationService.getCaseById(caseId); const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) { if (!moderationCase) {
await interaction.editReply({ await interaction.editReply({

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const cases = createCommand({ export const cases = createCommand({
@@ -29,7 +29,7 @@ export const cases = createCommand({
const activeOnly = interaction.options.getBoolean("active_only") || false; const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user // Get cases for the user
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly); const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
const title = activeOnly const title = activeOnly
? `⚠️ Active Cases for ${targetUser.username}` ? `⚠️ Active Cases for ${targetUser.username}`

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const clearwarning = createCommand({ export const clearwarning = createCommand({
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
} }
// Check if case exists and is active // Check if case exists and is active
const existingCase = await ModerationService.getCaseById(caseId); const existingCase = await moderationService.getCaseById(caseId);
if (!existingCase) { if (!existingCase) {
await interaction.editReply({ await interaction.editReply({
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
} }
// Clear the warning // Clear the warning
await ModerationService.clearCase({ await moderationService.clearCase({
caseId, caseId,
clearedBy: interaction.user.id, clearedBy: interaction.user.id,
clearedByName: interaction.user.username, clearedByName: interaction.user.username,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@shared/lib/constants"; import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
@@ -31,7 +31,7 @@ export const note = createCommand({
const noteText = interaction.options.getString("note", true); const noteText = interaction.options.getString("note", true);
// Create the note case // Create the note case
const moderationCase = await ModerationService.createCase({ const moderationCase = await moderationService.createCase({
type: CaseType.NOTE, type: CaseType.NOTE,
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const notes = createCommand({ export const notes = createCommand({
@@ -22,7 +22,7 @@ export const notes = createCommand({
const targetUser = interaction.options.getUser("user", true); const targetUser = interaction.options.getUser("user", true);
// Get all notes for the user // Get all notes for the user
const userNotes = await ModerationService.getUserNotes(targetUser.id); const userNotes = await moderationService.getUserNotes(targetUser.id);
// Display the notes // Display the notes
await interaction.editReply({ await interaction.editReply({

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { PruneService } from "@shared/modules/moderation/prune.service"; import { pruneService } from "@shared/modules/moderation/prune.service";
import { import {
getConfirmationMessage, getConfirmationMessage,
getProgressEmbed, getProgressEmbed,
@@ -66,7 +66,7 @@ export const prune = createCommand({
let estimatedCount: number | undefined; let estimatedCount: number | undefined;
if (all) { if (all) {
try { try {
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!); estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
} catch { } catch {
estimatedCount = undefined; estimatedCount = undefined;
} }
@@ -97,7 +97,7 @@ export const prune = createCommand({
}); });
// Execute deletion with progress callback for 'all' mode // Execute deletion with progress callback for 'all' mode
const result = await PruneService.deleteMessages( const result = await pruneService.deleteMessages(
interaction.channel!, interaction.channel!,
{ {
amount: typeof finalAmount === 'number' ? finalAmount : undefined, amount: typeof finalAmount === 'number' ? finalAmount : undefined,
@@ -129,7 +129,7 @@ export const prune = createCommand({
} }
} else { } else {
// No confirmation needed, proceed directly // No confirmation needed, proceed directly
const result = await PruneService.deleteMessages( const result = await pruneService.deleteMessages(
interaction.channel!, interaction.channel!,
{ {
amount: finalAmount as number, amount: finalAmount as number,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { import {
getWarnSuccessEmbed, getWarnSuccessEmbed,
getModerationErrorEmbed, getModerationErrorEmbed,
@@ -54,7 +54,7 @@ export const warn = createCommand({
const guildConfig = await getGuildConfig(interaction.guildId!); const guildConfig = await getGuildConfig(interaction.guildId!);
// Issue the warning via service // Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({ const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const warnings = createCommand({ export const warnings = createCommand({
@@ -22,7 +22,7 @@ export const warnings = createCommand({
const targetUser = interaction.options.getUser("user", true); const targetUser = interaction.options.getUser("user", true);
// Get active warnings for the user // Get active warnings for the user
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id); const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
// Display the warnings // Display the warnings
await interaction.editReply({ await interaction.editReply({

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"; import { describe, it, expect, mock, beforeEach } from "bun:test";
import { ModerationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { moderationCases } from "@db/schema"; import { moderationCases } from "@db/schema";
import { CaseType } from "@shared/lib/constants"; import { CaseType } from "@shared/lib/constants";
@@ -43,7 +43,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere }); mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning }); mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => { describe("moderationService", () => {
beforeEach(() => { beforeEach(() => {
mockFindFirst.mockReset(); mockFindFirst.mockReset();
mockFindMany.mockReset(); mockFindMany.mockReset();
@@ -73,7 +73,7 @@ describe("ModerationService", () => {
const mockDmTarget = { send: mock() }; const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({ const result = await moderationService.issueWarning({
...defaultOptions, ...defaultOptions,
dmTarget: mockDmTarget dmTarget: mockDmTarget
}); });
@@ -91,7 +91,7 @@ describe("ModerationService", () => {
const mockDmTarget = { send: mock() }; const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({ await moderationService.issueWarning({
...defaultOptions, ...defaultOptions,
dmTarget: mockDmTarget, dmTarget: mockDmTarget,
config: { dmOnWarn: false } config: { dmOnWarn: false }
@@ -108,7 +108,7 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() }; const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({ const result = await moderationService.issueWarning({
...defaultOptions, ...defaultOptions,
timeoutTarget: mockTimeoutTarget, timeoutTarget: mockTimeoutTarget,
config: { autoTimeoutThreshold: 3 } config: { autoTimeoutThreshold: 3 }
@@ -128,7 +128,7 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() }; const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({ const result = await moderationService.issueWarning({
...defaultOptions, ...defaultOptions,
timeoutTarget: mockTimeoutTarget timeoutTarget: mockTimeoutTarget
}); });
@@ -139,27 +139,6 @@ describe("ModerationService", () => {
}); });
}); });
describe("getNextCaseId", () => {
it("should return CASE-0001 if no cases exist", async () => {
mockFindFirst.mockResolvedValue(undefined);
// Accessing private method via bracket notation for testing
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0001");
});
it("should increment the latest case ID", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0043");
});
it("should handle padding correctly (e.g., 9 -> 0010)", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0010");
});
});
describe("createCase", () => { describe("createCase", () => {
it("should create a new moderation case with correct values", async () => { it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
@@ -176,7 +155,7 @@ describe("ModerationService", () => {
}; };
mockReturning.mockResolvedValue([mockNewCase]); mockReturning.mockResolvedValue([mockNewCase]);
const result = await ModerationService.createCase({ const result = await moderationService.createCase({
type: CaseType.WARN, type: CaseType.WARN,
userId: "123456789", userId: "123456789",
username: "testuser", username: "testuser",
@@ -199,7 +178,7 @@ describe("ModerationService", () => {
mockFindFirst.mockResolvedValue(undefined); mockFindFirst.mockResolvedValue(undefined);
mockReturning.mockImplementation((values) => [values]); // Simplified mock mockReturning.mockImplementation((values) => [values]); // Simplified mock
const result = await ModerationService.createCase({ const result = await moderationService.createCase({
type: CaseType.BAN, type: CaseType.BAN,
userId: "123456789", userId: "123456789",
username: "testuser", username: "testuser",
@@ -219,7 +198,7 @@ describe("ModerationService", () => {
const mockCase = { caseId: "CASE-0001", reason: "test" }; const mockCase = { caseId: "CASE-0001", reason: "test" };
mockFindFirst.mockResolvedValue(mockCase); mockFindFirst.mockResolvedValue(mockCase);
const result = await ModerationService.getCaseById("CASE-0001"); const result = await moderationService.getCaseById("CASE-0001");
expect(result).toEqual(mockCase as any); expect(result).toEqual(mockCase as any);
}); });
}); });
@@ -229,7 +208,7 @@ describe("ModerationService", () => {
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }]; const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
mockFindMany.mockResolvedValue(mockCases); mockFindMany.mockResolvedValue(mockCases);
const result = await ModerationService.getUserCases("123456789"); const result = await moderationService.getUserCases("123456789");
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(mockFindMany).toHaveBeenCalled(); expect(mockFindMany).toHaveBeenCalled();
}); });
@@ -240,7 +219,7 @@ describe("ModerationService", () => {
const mockUpdatedCase = { caseId: "CASE-0001", active: false }; const mockUpdatedCase = { caseId: "CASE-0001", active: false };
mockReturning.mockResolvedValue([mockUpdatedCase]); mockReturning.mockResolvedValue([mockUpdatedCase]);
const result = await ModerationService.clearCase({ const result = await moderationService.clearCase({
caseId: "CASE-0001", caseId: "CASE-0001",
clearedBy: "987654321", clearedBy: "987654321",
clearedByName: "mod", clearedByName: "mod",
@@ -264,13 +243,13 @@ describe("ModerationService", () => {
{ id: 2n, type: CaseType.WARN, active: true } { id: 2n, type: CaseType.WARN, active: true }
]); ]);
const count = await ModerationService.getActiveWarningCount("123456789"); const count = await moderationService.getActiveWarningCount("123456789");
expect(count).toBe(2); expect(count).toBe(2);
}); });
it("should return 0 if no active warnings", async () => { it("should return 0 if no active warnings", async () => {
mockFindMany.mockResolvedValue([]); mockFindMany.mockResolvedValue([]);
const count = await ModerationService.getActiveWarningCount("123456789"); const count = await moderationService.getActiveWarningCount("123456789");
expect(count).toBe(0); expect(count).toBe(0);
}); });
}); });

View File

@@ -10,34 +10,56 @@ export interface ModerationCaseConfig {
autoTimeoutThreshold?: number; autoTimeoutThreshold?: number;
} }
export class ModerationService { /**
/** * Generate the next sequential case ID
* Generate the next sequential case ID */
*/ async function getNextCaseId(): Promise<string> {
private static async getNextCaseId(): Promise<string> { const latestCase = await DrizzleClient.query.moderationCases.findFirst({
const latestCase = await DrizzleClient.query.moderationCases.findFirst({ orderBy: [desc(moderationCases.id)],
orderBy: [desc(moderationCases.id)], });
});
if (!latestCase) { if (!latestCase) {
return "CASE-0001"; return "CASE-0001";
}
// Extract number from case ID (e.g., "CASE-0042" -> 42)
const match = latestCase.caseId.match(/CASE-(\d+)/);
if (!match || !match[1]) {
return "CASE-0001";
}
const nextNumber = parseInt(match[1], 10) + 1;
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
} }
// Extract number from case ID (e.g., "CASE-0042" -> 42)
const match = latestCase.caseId.match(/CASE-(\d+)/);
if (!match || !match[1]) {
return "CASE-0001";
}
const nextNumber = parseInt(match[1], 10) + 1;
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
}
/**
* Get active warnings for a user
*/
async function getUserWarnings(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true)
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Get total count of active warnings for a user (useful for auto-timeout)
*/
async function getActiveWarningCount(userId: string): Promise<number> {
const warnings = await getUserWarnings(userId);
return warnings.length;
}
export const moderationService = {
/** /**
* Create a new moderation case * Create a new moderation case
*/ */
static async createCase(options: CreateCaseOptions) { async createCase(options: CreateCaseOptions) {
const caseId = await this.getNextCaseId(); const caseId = await getNextCaseId();
const [newCase] = await DrizzleClient.insert(moderationCases).values({ const [newCase] = await DrizzleClient.insert(moderationCases).values({
caseId, caseId,
@@ -52,12 +74,12 @@ export class ModerationService {
}).returning(); }).returning();
return newCase; return newCase;
} },
/** /**
* Issue a warning with DM and threshold logic * Issue a warning with DM and threshold logic
*/ */
static async issueWarning(options: { async issueWarning(options: {
userId: string; userId: string;
username: string; username: string;
moderatorId: string; moderatorId: string;
@@ -81,7 +103,7 @@ export class ModerationService {
throw new Error("Failed to create moderation case"); throw new Error("Failed to create moderation case");
} }
const warningCount = await this.getActiveWarningCount(options.userId); const warningCount = await getActiveWarningCount(options.userId);
const config = options.config ?? {}; const config = options.config ?? {};
// Try to DM the user if configured // Try to DM the user if configured
@@ -127,21 +149,21 @@ export class ModerationService {
} }
return { moderationCase, warningCount, autoTimeoutIssued }; return { moderationCase, warningCount, autoTimeoutIssued };
} },
/** /**
* Get a case by its case ID * Get a case by its case ID
*/ */
static async getCaseById(caseId: string) { async getCaseById(caseId: string) {
return await DrizzleClient.query.moderationCases.findFirst({ return await DrizzleClient.query.moderationCases.findFirst({
where: eq(moderationCases.caseId, caseId), where: eq(moderationCases.caseId, caseId),
}); });
} },
/** /**
* Get all cases for a specific user * Get all cases for a specific user
*/ */
static async getUserCases(userId: string, activeOnly: boolean = false) { async getUserCases(userId: string, activeOnly: boolean = false) {
const conditions = [eq(moderationCases.userId, BigInt(userId))]; const conditions = [eq(moderationCases.userId, BigInt(userId))];
if (activeOnly) { if (activeOnly) {
@@ -152,26 +174,19 @@ export class ModerationService {
where: and(...conditions), where: and(...conditions),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
}); });
} },
/** /**
* Get active warnings for a user * Get active warnings for a user (public alias)
*/ */
static async getUserWarnings(userId: string) { async getUserWarnings(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({ return await getUserWarnings(userId);
where: and( },
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true)
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/** /**
* Get all notes for a user * Get all notes for a user
*/ */
static async getUserNotes(userId: string) { async getUserNotes(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({ return await DrizzleClient.query.moderationCases.findMany({
where: and( where: and(
eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.userId, BigInt(userId)),
@@ -179,12 +194,12 @@ export class ModerationService {
), ),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
}); });
} },
/** /**
* Clear/resolve a warning * Clear/resolve a warning
*/ */
static async clearCase(options: ClearCaseOptions) { async clearCase(options: ClearCaseOptions) {
const [updatedCase] = await DrizzleClient.update(moderationCases) const [updatedCase] = await DrizzleClient.update(moderationCases)
.set({ .set({
active: false, active: false,
@@ -196,12 +211,12 @@ export class ModerationService {
.returning(); .returning();
return updatedCase; return updatedCase;
} },
/** /**
* Search cases with various filters * Search cases with various filters
*/ */
static async searchCases(filter: SearchCasesFilter) { async searchCases(filter: SearchCasesFilter) {
const conditions = []; const conditions = [];
if (filter.userId) { if (filter.userId) {
@@ -228,13 +243,12 @@ export class ModerationService {
limit: filter.limit || 50, limit: filter.limit || 50,
offset: filter.offset || 0, offset: filter.offset || 0,
}); });
} },
/** /**
* Get total count of active warnings for a user (useful for auto-timeout) * Get total count of active warnings for a user (useful for auto-timeout)
*/ */
static async getActiveWarningCount(userId: string): Promise<number> { async getActiveWarningCount(userId: string): Promise<number> {
const warnings = await this.getUserWarnings(userId); return await getActiveWarningCount(userId);
return warnings.length; },
} };
}

View File

@@ -3,11 +3,73 @@ import type { TextBasedChannel } from "discord.js";
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types"; import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
export class PruneService { /**
* Fetch messages from a channel
*/
async function fetchMessages(
channel: TextBasedChannel,
limit: number,
before?: string
): Promise<Collection<string, Message>> {
if (!('messages' in channel)) {
return new Collection();
}
return await channel.messages.fetch({
limit,
before
});
}
/**
* Process a batch of messages for deletion
*/
async function processBatch(
channel: TextBasedChannel,
messages: Collection<string, Message>,
userId?: string
): Promise<{ deleted: number; skipped: number }> {
if (!('bulkDelete' in channel)) {
throw new Error("This channel type does not support bulk deletion");
}
// Filter by user if specified
let messagesToDelete = messages;
if (userId) {
messagesToDelete = messages.filter(msg => msg.author.id === userId);
}
if (messagesToDelete.size === 0) {
return { deleted: 0, skipped: 0 };
}
try {
// bulkDelete with filterOld=true will automatically skip messages >14 days
const deleted = await channel.bulkDelete(messagesToDelete, true);
const skipped = messagesToDelete.size - deleted.size;
return {
deleted: deleted.size,
skipped
};
} catch (error) {
console.error("Error during bulk delete:", error);
throw new Error("Failed to delete messages");
}
}
/**
* Helper to delay execution
*/
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const pruneService = {
/** /**
* Delete messages from a channel based on provided options * Delete messages from a channel based on provided options
*/ */
static async deleteMessages( async deleteMessages(
channel: TextBasedChannel, channel: TextBasedChannel,
options: PruneOptions, options: PruneOptions,
progressCallback?: (progress: PruneProgress) => Promise<void> progressCallback?: (progress: PruneProgress) => Promise<void>
@@ -38,11 +100,11 @@ export class PruneService {
requestedCount = estimatedTotal; requestedCount = estimatedTotal;
while (true) { while (true) {
const messages = await this.fetchMessages(channel, batchSize, lastMessageId); const messages = await fetchMessages(channel, batchSize, lastMessageId);
if (messages.size === 0) break; if (messages.size === 0) break;
const { deleted, skipped } = await this.processBatch( const { deleted, skipped } = await processBatch(
channel, channel,
messages, messages,
userId userId
@@ -70,15 +132,15 @@ export class PruneService {
// Delay to avoid rate limits // Delay to avoid rate limits
if (messages.size >= batchSize) { if (messages.size >= batchSize) {
await this.delay(batchDelay); await delay(batchDelay);
} }
} }
} else { } else {
// Delete specific amount // Delete specific amount
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount); const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
const messages = await this.fetchMessages(channel, limit, undefined); const messages = await fetchMessages(channel, limit, undefined);
const { deleted, skipped } = await this.processBatch( const { deleted, skipped } = await processBatch(
channel, channel,
messages, messages,
userId userId
@@ -106,67 +168,12 @@ export class PruneService {
username, username,
skippedOld: totalSkipped > 0 ? totalSkipped : undefined skippedOld: totalSkipped > 0 ? totalSkipped : undefined
}; };
} },
/**
* Fetch messages from a channel
*/
private static async fetchMessages(
channel: TextBasedChannel,
limit: number,
before?: string
): Promise<Collection<string, Message>> {
if (!('messages' in channel)) {
return new Collection();
}
return await channel.messages.fetch({
limit,
before
});
}
/**
* Process a batch of messages for deletion
*/
private static async processBatch(
channel: TextBasedChannel,
messages: Collection<string, Message>,
userId?: string
): Promise<{ deleted: number; skipped: number }> {
if (!('bulkDelete' in channel)) {
throw new Error("This channel type does not support bulk deletion");
}
// Filter by user if specified
let messagesToDelete = messages;
if (userId) {
messagesToDelete = messages.filter(msg => msg.author.id === userId);
}
if (messagesToDelete.size === 0) {
return { deleted: 0, skipped: 0 };
}
try {
// bulkDelete with filterOld=true will automatically skip messages >14 days
const deleted = await channel.bulkDelete(messagesToDelete, true);
const skipped = messagesToDelete.size - deleted.size;
return {
deleted: deleted.size,
skipped
};
} catch (error) {
console.error("Error during bulk delete:", error);
throw new Error("Failed to delete messages");
}
}
/** /**
* Estimate the total number of messages in a channel * Estimate the total number of messages in a channel
*/ */
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> { async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
if (!('messages' in channel)) { if (!('messages' in channel)) {
return 0; return 0;
} }
@@ -187,12 +194,5 @@ export class PruneService {
} catch { } catch {
return 100; // Default estimate return 100; // Default estimate
} }
} },
};
/**
* Helper to delay execution
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return null; return null;
} }
const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); const { moderationService } = await import("@shared/modules/moderation/moderation.service");
/** /**
* @route GET /api/moderation * @route GET /api/moderation
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
const cases = await ModerationService.searchCases(filter); const cases = await moderationService.searchCases(filter);
return jsonResponse({ cases }); return jsonResponse({ cases });
}, "fetch moderation cases"); }, "fetch moderation cases");
} }
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const caseId = pathname.split("/").pop()!.toUpperCase(); const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => { return withErrorHandling(async () => {
const moderationCase = await ModerationService.getCaseById(caseId); const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) { if (!moderationCase) {
return errorResponse("Case not found", 404); return errorResponse("Case not found", 404);
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
); );
} }
const newCase = await ModerationService.createCase({ const newCase = await moderationService.createCase({
type: data.type, type: data.type,
userId: data.userId, userId: data.userId,
username: data.username, username: data.username,
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400); return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
} }
const updatedCase = await ModerationService.clearCase({ const updatedCase = await moderationService.clearCase({
caseId, caseId,
clearedBy: data.clearedBy, clearedBy: data.clearedBy,
clearedByName: data.clearedByName, clearedByName: data.clearedByName,