import { moderationCases } from "@db/schema"; import { eq, and, desc } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/modules/moderation/moderation.types"; import { config } from "@shared/lib/config"; import { getUserWarningEmbed } from "@/modules/moderation/moderation.view"; import { CaseType } from "@shared/lib/constants"; export class ModerationService { /** * Generate the next sequential case ID */ private static async getNextCaseId(): Promise { 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')}`; } /** * Create a new moderation case */ static async createCase(options: CreateCaseOptions) { const caseId = await this.getNextCaseId(); const [newCase] = await DrizzleClient.insert(moderationCases).values({ caseId, type: options.type, userId: BigInt(options.userId), username: options.username, moderatorId: BigInt(options.moderatorId), moderatorName: options.moderatorName, reason: options.reason, metadata: options.metadata || {}, active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default }).returning(); return newCase; } /** * Issue a warning with DM and threshold logic */ static async issueWarning(options: { userId: string; username: string; moderatorId: string; moderatorName: string; reason: string; guildName?: string; dmTarget?: { send: (options: any) => Promise }; timeoutTarget?: { timeout: (duration: number, reason: string) => Promise }; }) { const moderationCase = await this.createCase({ type: CaseType.WARN, userId: options.userId, username: options.username, moderatorId: options.moderatorId, moderatorName: options.moderatorName, reason: options.reason, }); if (!moderationCase) { throw new Error("Failed to create moderation case"); } const warningCount = await this.getActiveWarningCount(options.userId); // Try to DM the user if configured if (config.moderation.cases.dmOnWarn && options.dmTarget) { try { await options.dmTarget.send({ embeds: [getUserWarningEmbed( options.guildName || 'this server', options.reason, moderationCase.caseId, warningCount )] }); } catch (error) { console.log(`Could not DM warning to ${options.username}: ${error}`); } } // Check for auto-timeout threshold let autoTimeoutIssued = false; if (config.moderation.cases.autoTimeoutThreshold && warningCount >= config.moderation.cases.autoTimeoutThreshold && options.timeoutTarget) { try { // Auto-timeout for 24 hours (86400000 ms) await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`); // Create a timeout case await this.createCase({ type: CaseType.TIMEOUT, userId: options.userId, username: options.username, moderatorId: "0", // System/Bot moderatorName: "System", reason: `Automatic timeout: reached ${warningCount} warnings`, metadata: { duration: '24h', automatic: true } }); autoTimeoutIssued = true; } catch (error) { console.error('Failed to auto-timeout user:', error); } } return { moderationCase, warningCount, autoTimeoutIssued }; } /** * Get a case by its case ID */ static 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) { const conditions = [eq(moderationCases.userId, BigInt(userId))]; if (activeOnly) { conditions.push(eq(moderationCases.active, true)); } return await DrizzleClient.query.moderationCases.findMany({ where: and(...conditions), orderBy: [desc(moderationCases.createdAt)], }); } /** * Get active warnings for a user */ 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)], }); } /** * Get all notes for a user */ static async getUserNotes(userId: string) { return await DrizzleClient.query.moderationCases.findMany({ where: and( eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.type, CaseType.NOTE) ), orderBy: [desc(moderationCases.createdAt)], }); } /** * Clear/resolve a warning */ static async clearCase(options: ClearCaseOptions) { const [updatedCase] = await DrizzleClient.update(moderationCases) .set({ active: false, resolvedAt: new Date(), resolvedBy: BigInt(options.clearedBy), resolvedReason: options.reason || 'Manually cleared', }) .where(eq(moderationCases.caseId, options.caseId)) .returning(); return updatedCase; } /** * Search cases with various filters */ static async searchCases(filter: SearchCasesFilter) { const conditions = []; if (filter.userId) { conditions.push(eq(moderationCases.userId, BigInt(filter.userId))); } if (filter.moderatorId) { conditions.push(eq(moderationCases.moderatorId, BigInt(filter.moderatorId))); } if (filter.type) { conditions.push(eq(moderationCases.type, filter.type)); } if (filter.active !== undefined) { conditions.push(eq(moderationCases.active, filter.active)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; return await DrizzleClient.query.moderationCases.findMany({ where: whereClause, orderBy: [desc(moderationCases.createdAt)], 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 { const warnings = await this.getUserWarnings(userId); return warnings.length; } }