feat: add moderation module with case tracking database schema
This commit is contained in:
158
src/modules/moderation/moderation.service.ts
Normal file
158
src/modules/moderation/moderation.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { moderationCases } from "@/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types";
|
||||
|
||||
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)],
|
||||
});
|
||||
|
||||
if (!latestCase) {
|
||||
return "CASE-0001";
|
||||
}
|
||||
|
||||
// Extract number from case ID (e.g., "CASE-0042" -> 42)
|
||||
const match = latestCase.caseId.match(/CASE-(\d+)/);
|
||||
if (!match) {
|
||||
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 === 'warn' ? true : false, // Only warnings are "active" by default
|
||||
}).returning();
|
||||
|
||||
return newCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '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, '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<number> {
|
||||
const warnings = await this.getUserWarnings(userId);
|
||||
return warnings.length;
|
||||
}
|
||||
}
|
||||
44
src/modules/moderation/moderation.types.ts
Normal file
44
src/modules/moderation/moderation.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
|
||||
|
||||
export interface CreateCaseOptions {
|
||||
type: CaseType;
|
||||
userId: string;
|
||||
username: string;
|
||||
moderatorId: string;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClearCaseOptions {
|
||||
caseId: string;
|
||||
clearedBy: string;
|
||||
clearedByName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ModerationCase {
|
||||
id: bigint;
|
||||
caseId: string;
|
||||
type: string;
|
||||
userId: bigint;
|
||||
username: string;
|
||||
moderatorId: bigint;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata: Record<string, any>;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
resolvedBy: bigint | null;
|
||||
resolvedReason: string | null;
|
||||
}
|
||||
|
||||
export interface SearchCasesFilter {
|
||||
userId?: string;
|
||||
moderatorId?: string;
|
||||
type?: CaseType;
|
||||
active?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
241
src/modules/moderation/moderation.view.ts
Normal file
241
src/modules/moderation/moderation.view.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { EmbedBuilder, Colors, time, TimestampStyles } from "discord.js";
|
||||
import type { ModerationCase } from "./moderation.types";
|
||||
|
||||
/**
|
||||
* Get color based on case type
|
||||
*/
|
||||
function getCaseColor(type: string): number {
|
||||
switch (type) {
|
||||
case 'warn': return Colors.Yellow;
|
||||
case 'timeout': return Colors.Orange;
|
||||
case 'kick': return Colors.Red;
|
||||
case 'ban': return Colors.DarkRed;
|
||||
case 'note': return Colors.Blue;
|
||||
case 'prune': return Colors.Grey;
|
||||
default: return Colors.Grey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji based on case type
|
||||
*/
|
||||
function getCaseEmoji(type: string): string {
|
||||
switch (type) {
|
||||
case 'warn': return '⚠️';
|
||||
case 'timeout': return '🔇';
|
||||
case 'kick': return '👢';
|
||||
case 'ban': return '🔨';
|
||||
case 'note': return '📝';
|
||||
case 'prune': return '🧹';
|
||||
default: return '📋';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single case
|
||||
*/
|
||||
export function getCaseEmbed(moderationCase: ModerationCase): EmbedBuilder {
|
||||
const emoji = getCaseEmoji(moderationCase.type);
|
||||
const color = getCaseColor(moderationCase.type);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${emoji} Case ${moderationCase.caseId}`)
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: 'Type', value: moderationCase.type.toUpperCase(), inline: true },
|
||||
{ name: 'Status', value: moderationCase.active ? '🟢 Active' : '⚫ Resolved', inline: true },
|
||||
{ name: '\u200B', value: '\u200B', inline: true },
|
||||
{ name: 'User', value: `${moderationCase.username} (${moderationCase.userId})`, inline: false },
|
||||
{ name: 'Moderator', value: moderationCase.moderatorName, inline: true },
|
||||
{ name: 'Date', value: time(moderationCase.createdAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
)
|
||||
.addFields({ name: 'Reason', value: moderationCase.reason })
|
||||
.setTimestamp(moderationCase.createdAt);
|
||||
|
||||
// Add resolution info if resolved
|
||||
if (!moderationCase.active && moderationCase.resolvedAt) {
|
||||
embed.addFields(
|
||||
{ name: '\u200B', value: '**Resolution**' },
|
||||
{ name: 'Resolved At', value: time(moderationCase.resolvedAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
);
|
||||
|
||||
if (moderationCase.resolvedReason) {
|
||||
embed.addFields({ name: 'Resolution Reason', value: moderationCase.resolvedReason });
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata if present
|
||||
if (moderationCase.metadata && Object.keys(moderationCase.metadata).length > 0) {
|
||||
const metadataStr = JSON.stringify(moderationCase.metadata, null, 2);
|
||||
if (metadataStr.length < 1024) {
|
||||
embed.addFields({ name: 'Additional Info', value: `\`\`\`json\n${metadataStr}\n\`\`\`` });
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of cases
|
||||
*/
|
||||
export function getCasesListEmbed(
|
||||
cases: ModerationCase[],
|
||||
title: string,
|
||||
description?: string
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
if (cases.length === 0) {
|
||||
embed.setDescription('No cases found.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
// Group by type for better display
|
||||
const casesByType: Record<string, ModerationCase[]> = {};
|
||||
for (const c of cases) {
|
||||
if (!casesByType[c.type]) {
|
||||
casesByType[c.type] = [];
|
||||
}
|
||||
casesByType[c.type].push(c);
|
||||
}
|
||||
|
||||
// Add fields for each type
|
||||
for (const [type, typeCases] of Object.entries(casesByType)) {
|
||||
const emoji = getCaseEmoji(type);
|
||||
const caseList = typeCases.slice(0, 5).map(c => {
|
||||
const status = c.active ? '🟢' : '⚫';
|
||||
const date = time(c.createdAt, TimestampStyles.ShortDate);
|
||||
return `${status} **${c.caseId}** - ${c.reason.substring(0, 50)}${c.reason.length > 50 ? '...' : ''} (${date})`;
|
||||
}).join('\n');
|
||||
|
||||
embed.addFields({
|
||||
name: `${emoji} ${type.toUpperCase()} (${typeCases.length})`,
|
||||
value: caseList || 'None',
|
||||
inline: false
|
||||
});
|
||||
|
||||
if (typeCases.length > 5) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${typeCases.length - 5} more_`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display user's active warnings
|
||||
*/
|
||||
export function getWarningsEmbed(warnings: ModerationCase[], username: string): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ Active Warnings for ${username}`)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
if (warnings.length === 0) {
|
||||
embed.setDescription('No active warnings.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
embed.setDescription(`**Total Active Warnings:** ${warnings.length}`);
|
||||
|
||||
for (const warning of warnings.slice(0, 10)) {
|
||||
const date = time(warning.createdAt, TimestampStyles.ShortDateTime);
|
||||
embed.addFields({
|
||||
name: `${warning.caseId} - ${date}`,
|
||||
value: `**Moderator:** ${warning.moderatorName}\n**Reason:** ${warning.reason}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (warnings.length > 10) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${warnings.length - 10} more warnings. Use \`/cases\` to view all._`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after warning a user
|
||||
*/
|
||||
export function getWarnSuccessEmbed(caseId: string, username: string, reason: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Issued')
|
||||
.setDescription(`**${username}** has been warned.`)
|
||||
.addFields(
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Reason', value: reason, inline: false }
|
||||
)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after adding a note
|
||||
*/
|
||||
export function getNoteSuccessEmbed(caseId: string, username: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Note Added')
|
||||
.setDescription(`Staff note added for **${username}**.`)
|
||||
.addFields({ name: 'Case ID', value: caseId, inline: true })
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after clearing a warning
|
||||
*/
|
||||
export function getClearSuccessEmbed(caseId: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Cleared')
|
||||
.setDescription(`Case **${caseId}** has been resolved.`)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error embed for moderation operations
|
||||
*/
|
||||
export function getModerationErrorEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('❌ Error')
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning embed to send to user via DM
|
||||
*/
|
||||
export function getUserWarningEmbed(
|
||||
serverName: string,
|
||||
reason: string,
|
||||
caseId: string,
|
||||
warningCount: number
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('⚠️ You have received a warning')
|
||||
.setDescription(`You have been warned in **${serverName}**.`)
|
||||
.addFields(
|
||||
{ name: 'Reason', value: reason, inline: false },
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Total Warnings', value: warningCount.toString(), inline: true }
|
||||
)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp()
|
||||
.setFooter({ text: 'Please review the server rules to avoid further action.' });
|
||||
}
|
||||
Reference in New Issue
Block a user