forked from syntaxbullet/AuroraBot-discord
feat: add moderation module with case tracking database schema
This commit is contained in:
@@ -142,6 +142,25 @@ export const lootdrops = pgTable('lootdrops', {
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// 10. Moderation Cases
|
||||
export const moderationCases = pgTable('moderation_cases', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
|
||||
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
|
||||
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||
resolvedReason: text('resolved_reason'),
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
@@ -215,4 +234,19 @@ export const itemTransactionsRelations = relations(itemTransactions, ({ one }) =
|
||||
fields: [itemTransactions.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [moderationCases.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
moderator: one(users, {
|
||||
fields: [moderationCases.moderatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
resolver: one(users, {
|
||||
fields: [moderationCases.resolvedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -63,6 +63,11 @@ export interface GameConfigType {
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +144,11 @@ const configSchema = z.object({
|
||||
confirmThreshold: z.number().default(50),
|
||||
batchSize: z.number().default(100),
|
||||
batchDelayMs: z.number().default(1000)
|
||||
}),
|
||||
cases: z.object({
|
||||
dmOnWarn: z.boolean().default(true),
|
||||
logChannelId: z.string().optional(),
|
||||
autoTimeoutThreshold: z.number().optional()
|
||||
})
|
||||
}).default({
|
||||
prune: {
|
||||
@@ -146,6 +156,9 @@ const configSchema = z.object({
|
||||
confirmThreshold: 50,
|
||||
batchSize: 100,
|
||||
batchDelayMs: 1000
|
||||
},
|
||||
cases: {
|
||||
dmOnWarn: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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