diff --git a/drizzle/0001_heavy_thundra.sql b/drizzle/0001_heavy_thundra.sql new file mode 100644 index 0000000..630e8bb --- /dev/null +++ b/drizzle/0001_heavy_thundra.sql @@ -0,0 +1,17 @@ +CREATE TABLE "moderation_cases" ( + "id" bigserial PRIMARY KEY NOT NULL, + "case_id" varchar(50) NOT NULL, + "type" varchar(20) NOT NULL, + "user_id" bigint NOT NULL, + "username" varchar(255) NOT NULL, + "moderator_id" bigint NOT NULL, + "moderator_name" varchar(255) NOT NULL, + "reason" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone, + "resolved_by" bigint, + "resolved_reason" text, + CONSTRAINT "moderation_cases_case_id_unique" UNIQUE("case_id") +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a72cd7a --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "72cb5e22-fb44-4db8-9527-020dbec017d0", + "prevId": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.classes": { + "name": "classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "0" + }, + "role_id": { + "name": "role_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "classes_name_unique": { + "name": "classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory": { + "name": "inventory", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "1" + } + }, + "indexes": {}, + "foreignKeys": { + "inventory_user_id_users_id_fk": { + "name": "inventory_user_id_users_id_fk", + "tableFrom": "inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_item_id_items_id_fk": { + "name": "inventory_item_id_items_id_fk", + "tableFrom": "inventory", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "inventory_user_id_item_id_pk": { + "name": "inventory_user_id_item_id_pk", + "columns": [ + "user_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "quantity_check": { + "name": "quantity_check", + "value": "\"inventory\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.item_transactions": { + "name": "item_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "related_user_id": { + "name": "related_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "item_transactions_user_id_users_id_fk": { + "name": "item_transactions_user_id_users_id_fk", + "tableFrom": "item_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_transactions_related_user_id_users_id_fk": { + "name": "item_transactions_related_user_id_users_id_fk", + "tableFrom": "item_transactions", + "tableTo": "users", + "columnsFrom": [ + "related_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "item_transactions_item_id_items_id_fk": { + "name": "item_transactions_item_id_items_id_fk", + "tableFrom": "item_transactions", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rarity": { + "name": "rarity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'Common'" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'MATERIAL'" + }, + "usage_data": { + "name": "usage_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "price": { + "name": "price", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "items_name_unique": { + "name": "items_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lootdrops": { + "name": "lootdrops", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reward_amount": { + "name": "reward_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "claimed_by": { + "name": "claimed_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lootdrops_claimed_by_users_id_fk": { + "name": "lootdrops_claimed_by_users_id_fk", + "tableFrom": "lootdrops", + "tableTo": "users", + "columnsFrom": [ + "claimed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moderation_cases": { + "name": "moderation_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "moderator_id": { + "name": "moderator_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "moderator_name": { + "name": "moderator_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_reason": { + "name": "resolved_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moderation_cases_case_id_unique": { + "name": "moderation_cases_case_id_unique", + "nullsNotDistinct": false, + "columns": [ + "case_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quests": { + "name": "quests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_event": { + "name": "trigger_event", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "requirements": { + "name": "requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "rewards": { + "name": "rewards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "related_user_id": { + "name": "related_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_related_user_id_users_id_fk": { + "name": "transactions_related_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "related_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_quests": { + "name": "user_quests", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "quest_id": { + "name": "quest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_quests_user_id_users_id_fk": { + "name": "user_quests_user_id_users_id_fk", + "tableFrom": "user_quests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_quests_quest_id_quests_id_fk": { + "name": "user_quests_quest_id_quests_id_fk", + "tableFrom": "user_quests", + "tableTo": "quests", + "columnsFrom": [ + "quest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_quests_user_id_quest_id_pk": { + "name": "user_quests_user_id_quest_id_pk", + "columns": [ + "user_id", + "quest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_timers": { + "name": "user_timers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_timers_user_id_users_id_fk": { + "name": "user_timers_user_id_users_id_fk", + "tableFrom": "user_timers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_timers_user_id_type_key_pk": { + "name": "user_timers_user_id_type_key_pk", + "columns": [ + "user_id", + "type", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "class_id": { + "name": "class_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "0" + }, + "xp": { + "name": "xp", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "0" + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "daily_streak": { + "name": "daily_streak", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_class_id_classes_id_fk": { + "name": "users_class_id_classes_id_fk", + "tableFrom": "users", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e08da8a..e46e7fb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1766137924760, "tag": "0000_fixed_tomas", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1766606046050, + "tag": "0001_heavy_thundra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index d147f0f..1776399 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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], + }), })); \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts index 18aef06..00263ec 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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 } }) }); diff --git a/src/modules/moderation/moderation.service.ts b/src/modules/moderation/moderation.service.ts new file mode 100644 index 0000000..c019eab --- /dev/null +++ b/src/modules/moderation/moderation.service.ts @@ -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 { + 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 { + const warnings = await this.getUserWarnings(userId); + return warnings.length; + } +} diff --git a/src/modules/moderation/moderation.types.ts b/src/modules/moderation/moderation.types.ts new file mode 100644 index 0000000..82dd03b --- /dev/null +++ b/src/modules/moderation/moderation.types.ts @@ -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; +} + +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; + 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; +} diff --git a/src/modules/moderation/moderation.view.ts b/src/modules/moderation/moderation.view.ts new file mode 100644 index 0000000..14f5afd --- /dev/null +++ b/src/modules/moderation/moderation.view.ts @@ -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 = {}; + 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.' }); +}