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

View File

@@ -1,6 +1,6 @@
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 { CaseType } from "@shared/lib/constants";
@@ -43,7 +43,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => {
describe("moderationService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
@@ -73,7 +73,7 @@ describe("ModerationService", () => {
const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
@@ -91,7 +91,7 @@ describe("ModerationService", () => {
const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({
await moderationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget,
config: { dmOnWarn: false }
@@ -108,7 +108,7 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget,
config: { autoTimeoutThreshold: 3 }
@@ -128,7 +128,7 @@ describe("ModerationService", () => {
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
const result = await moderationService.issueWarning({
...defaultOptions,
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", () => {
it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
@@ -176,7 +155,7 @@ describe("ModerationService", () => {
};
mockReturning.mockResolvedValue([mockNewCase]);
const result = await ModerationService.createCase({
const result = await moderationService.createCase({
type: CaseType.WARN,
userId: "123456789",
username: "testuser",
@@ -199,7 +178,7 @@ describe("ModerationService", () => {
mockFindFirst.mockResolvedValue(undefined);
mockReturning.mockImplementation((values) => [values]); // Simplified mock
const result = await ModerationService.createCase({
const result = await moderationService.createCase({
type: CaseType.BAN,
userId: "123456789",
username: "testuser",
@@ -219,7 +198,7 @@ describe("ModerationService", () => {
const mockCase = { caseId: "CASE-0001", reason: "test" };
mockFindFirst.mockResolvedValue(mockCase);
const result = await ModerationService.getCaseById("CASE-0001");
const result = await moderationService.getCaseById("CASE-0001");
expect(result).toEqual(mockCase as any);
});
});
@@ -229,7 +208,7 @@ describe("ModerationService", () => {
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
mockFindMany.mockResolvedValue(mockCases);
const result = await ModerationService.getUserCases("123456789");
const result = await moderationService.getUserCases("123456789");
expect(result).toHaveLength(2);
expect(mockFindMany).toHaveBeenCalled();
});
@@ -240,7 +219,7 @@ describe("ModerationService", () => {
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
mockReturning.mockResolvedValue([mockUpdatedCase]);
const result = await ModerationService.clearCase({
const result = await moderationService.clearCase({
caseId: "CASE-0001",
clearedBy: "987654321",
clearedByName: "mod",
@@ -264,13 +243,13 @@ describe("ModerationService", () => {
{ id: 2n, type: CaseType.WARN, active: true }
]);
const count = await ModerationService.getActiveWarningCount("123456789");
const count = await moderationService.getActiveWarningCount("123456789");
expect(count).toBe(2);
});
it("should return 0 if no active warnings", async () => {
mockFindMany.mockResolvedValue([]);
const count = await ModerationService.getActiveWarningCount("123456789");
const count = await moderationService.getActiveWarningCount("123456789");
expect(count).toBe(0);
});
});

View File

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

View File

@@ -3,11 +3,73 @@ import type { TextBasedChannel } from "discord.js";
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
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
*/
static async deleteMessages(
async deleteMessages(
channel: TextBasedChannel,
options: PruneOptions,
progressCallback?: (progress: PruneProgress) => Promise<void>
@@ -38,11 +100,11 @@ export class PruneService {
requestedCount = estimatedTotal;
while (true) {
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
const messages = await fetchMessages(channel, batchSize, lastMessageId);
if (messages.size === 0) break;
const { deleted, skipped } = await this.processBatch(
const { deleted, skipped } = await processBatch(
channel,
messages,
userId
@@ -70,15 +132,15 @@ export class PruneService {
// Delay to avoid rate limits
if (messages.size >= batchSize) {
await this.delay(batchDelay);
await delay(batchDelay);
}
}
} else {
// Delete specific amount
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,
messages,
userId
@@ -106,67 +168,12 @@ export class PruneService {
username,
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
*/
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
if (!('messages' in channel)) {
return 0;
}
@@ -187,12 +194,5 @@ export class PruneService {
} catch {
return 100; // Default estimate
}
}
/**
* Helper to delay execution
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
},
};