feat: add moderation module with case tracking database schema

This commit is contained in:
syntaxbullet
2025-12-24 20:55:56 +01:00
parent 37ac0ee934
commit 64cf47ee03
8 changed files with 1392 additions and 0 deletions

View File

@@ -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],
}),
}));

View File

@@ -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
}
})
});

View 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;
}
}

View 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;
}

View 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.' });
}