diff --git a/shared/db/schema/guild-settings.ts b/shared/db/schema/guild-settings.ts index 85b0925..a994e05 100644 --- a/shared/db/schema/guild-settings.ts +++ b/shared/db/schema/guild-settings.ts @@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle export type GuildSettings = InferSelectModel; export type GuildSettingsInsert = InferInsertModel; +export interface GuildConfig { + studentRole?: string; + visitorRole?: string; + colorRoles: string[]; + welcomeChannelId?: string; + welcomeMessage?: string; + feedbackChannelId?: string; + terminal?: { + channelId: string; + messageId: string; + }; + moderation: { + cases: { + dmOnWarn: boolean; + logChannelId?: string; + autoTimeoutThreshold?: number; + }; + }; +} + export const guildSettings = pgTable('guild_settings', { guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(), studentRoleId: bigint('student_role_id', { mode: 'bigint' }), diff --git a/shared/lib/config.ts b/shared/lib/config.ts index 55a9362..3d58a1c 100644 --- a/shared/lib/config.ts +++ b/shared/lib/config.ts @@ -1,29 +1,12 @@ -import { jsonReplacer } from './utils'; -import { readFileSync, existsSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { z } from 'zod'; - -const configPath = join(import.meta.dir, '..', 'config', 'config.json'); - -export interface GuildConfig { - studentRole?: string; - visitorRole?: string; - colorRoles: string[]; - welcomeChannelId?: string; - welcomeMessage?: string; - feedbackChannelId?: string; - terminal?: { - channelId: string; - messageId: string; - }; - moderation: { - cases: { - dmOnWarn: boolean; - logChannelId?: string; - autoTimeoutThreshold?: number; - }; - }; -} +import type { + LevelingConfig, + EconomyConfig as EconomyConfigDB, + InventoryConfig as InventoryConfigDB, + LootdropConfig, + TriviaConfig as TriviaConfigDB, + ModerationConfig +} from "@db/schema/game-settings"; +import type { GuildConfig } from "@db/schema/guild-settings"; const guildConfigCache = new Map(); const CACHE_TTL_MS = 60000; @@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) { guildConfigCache.delete(guildId); } -export interface LevelingConfig { - base: number; - exponent: number; - chat: { - cooldownMs: number; - minXp: number; - maxXp: number; - }; -} +// Re-export DB types +export type { LevelingConfig, LootdropConfig, ModerationConfig }; +// Runtime config types with BigInt for numeric fields export interface EconomyConfig { daily: { amount: bigint; @@ -114,18 +91,6 @@ export interface InventoryConfig { maxSlots: number; } -export interface LootdropConfig { - activityWindowMs: number; - minMessages: number; - spawnChance: number; - cooldownMs: number; - reward: { - min: number; - max: number; - currency: string; - }; -} - export interface TriviaConfig { entryFee: bigint; rewardMultiplier: number; @@ -135,20 +100,6 @@ export interface TriviaConfig { difficulty: 'easy' | 'medium' | 'hard' | 'random'; } -export interface ModerationConfig { - prune: { - maxAmount: number; - confirmThreshold: number; - batchSize: number; - batchDelayMs: number; - }; - cases: { - dmOnWarn: boolean; - logChannelId?: string; - autoTimeoutThreshold?: number; - }; -} - export interface GameConfigType { leveling: LevelingConfig; economy: EconomyConfig; @@ -158,160 +109,11 @@ export interface GameConfigType { trivia: TriviaConfig; moderation: ModerationConfig; system: Record; - studentRole: string; - visitorRole: string; - colorRoles: string[]; - welcomeChannelId?: string; - welcomeMessage?: string; - feedbackChannelId?: string; - terminal?: { - channelId: string; - messageId: string; - }; } export const config: GameConfigType = {} as GameConfigType; -const bigIntSchema = z.union([z.string(), z.number(), z.bigint()]) - .refine((val) => { - try { - BigInt(val); - return true; - } catch { - return false; - } - }, { message: "Must be a valid integer" }) - .transform((val) => BigInt(val)); - -const fileConfigSchema = z.object({ - leveling: z.object({ - base: z.number(), - exponent: z.number(), - chat: z.object({ - cooldownMs: z.number(), - minXp: z.number(), - maxXp: z.number(), - }) - }), - economy: z.object({ - daily: z.object({ - amount: bigIntSchema, - streakBonus: bigIntSchema, - weeklyBonus: bigIntSchema.default(50n), - cooldownMs: z.number(), - }), - transfers: z.object({ - allowSelfTransfer: z.boolean(), - minAmount: bigIntSchema, - }), - exam: z.object({ - multMin: z.number(), - multMax: z.number(), - }) - }), - inventory: z.object({ - maxStackSize: bigIntSchema, - maxSlots: z.number(), - }), - commands: z.record(z.string(), z.boolean()), - lootdrop: z.object({ - activityWindowMs: z.number(), - minMessages: z.number(), - spawnChance: z.number(), - cooldownMs: z.number(), - reward: z.object({ - min: z.number(), - max: z.number(), - currency: z.string(), - }) - }), - studentRole: z.string(), - visitorRole: z.string(), - colorRoles: z.array(z.string()).default([]), - welcomeChannelId: z.string().optional(), - welcomeMessage: z.string().optional(), - feedbackChannelId: z.string().optional(), - terminal: z.object({ - channelId: z.string(), - messageId: z.string() - }).optional(), - moderation: z.object({ - prune: z.object({ - maxAmount: z.number().default(100), - 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: { - maxAmount: 100, - confirmThreshold: 50, - batchSize: 100, - batchDelayMs: 1000 - }, - cases: { - dmOnWarn: true - } - }), - trivia: z.object({ - entryFee: bigIntSchema, - rewardMultiplier: z.number().min(0).max(10), - timeoutSeconds: z.number().min(5).max(300), - cooldownMs: z.number().min(0), - categories: z.array(z.number()).default([]), - difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'), - }).default({ - entryFee: 50n, - rewardMultiplier: 1.8, - timeoutSeconds: 30, - cooldownMs: 60000, - categories: [], - difficulty: 'random' - }), - system: z.record(z.string(), z.any()).default({}), -}); - -type FileConfig = z.infer; - -function loadFromFile(): FileConfig | null { - if (!existsSync(configPath)) { - return null; - } - - try { - const raw = readFileSync(configPath, 'utf-8'); - const rawConfig = JSON.parse(raw); - return fileConfigSchema.parse(rawConfig); - } catch (error) { - console.error("Failed to load config from file:", error); - return null; - } -} - -function applyFileConfig(fileConfig: FileConfig) { - Object.assign(config, { - leveling: fileConfig.leveling, - economy: fileConfig.economy, - inventory: fileConfig.inventory, - commands: fileConfig.commands, - lootdrop: fileConfig.lootdrop, - trivia: fileConfig.trivia, - moderation: fileConfig.moderation, - system: fileConfig.system, - studentRole: fileConfig.studentRole, - visitorRole: fileConfig.visitorRole, - colorRoles: fileConfig.colorRoles, - welcomeChannelId: fileConfig.welcomeChannelId, - welcomeMessage: fileConfig.welcomeMessage, - feedbackChannelId: fileConfig.feedbackChannelId, - terminal: fileConfig.terminal, - }); -} +export const GameConfig = config; async function loadFromDatabase(): Promise { try { @@ -358,88 +160,48 @@ async function loadFromDatabase(): Promise { return false; } +async function loadDefaults(): Promise { + console.warn("⚠️ No game config found in database. Using defaults."); + const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service'); + const defaults = gameSettingsService.getDefaults(); + Object.assign(config, { + leveling: defaults.leveling, + economy: { + ...defaults.economy, + daily: { + ...defaults.economy.daily, + amount: BigInt(defaults.economy.daily.amount), + streakBonus: BigInt(defaults.economy.daily.streakBonus), + weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus), + }, + transfers: { + ...defaults.economy.transfers, + minAmount: BigInt(defaults.economy.transfers.minAmount), + }, + }, + inventory: { + ...defaults.inventory, + maxStackSize: BigInt(defaults.inventory.maxStackSize), + }, + commands: defaults.commands, + lootdrop: defaults.lootdrop, + trivia: { + ...defaults.trivia, + entryFee: BigInt(defaults.trivia.entryFee), + }, + moderation: defaults.moderation, + system: defaults.system, + }); +} + export async function reloadConfig(): Promise { const dbLoaded = await loadFromDatabase(); - + if (!dbLoaded) { - const fileConfig = loadFromFile(); - if (fileConfig) { - applyFileConfig(fileConfig); - console.log("📄 Game config loaded from file (database not available)."); - } else { - console.warn("⚠️ No game config found in database or file. Using defaults."); - const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service'); - const defaults = gameSettingsService.getDefaults(); - Object.assign(config, { - leveling: defaults.leveling, - economy: { - ...defaults.economy, - daily: { - ...defaults.economy.daily, - amount: BigInt(defaults.economy.daily.amount), - streakBonus: BigInt(defaults.economy.daily.streakBonus), - weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus), - }, - transfers: { - ...defaults.economy.transfers, - minAmount: BigInt(defaults.economy.transfers.minAmount), - }, - }, - inventory: { - ...defaults.inventory, - maxStackSize: BigInt(defaults.inventory.maxStackSize), - }, - commands: defaults.commands, - lootdrop: defaults.lootdrop, - trivia: { - ...defaults.trivia, - entryFee: BigInt(defaults.trivia.entryFee), - }, - moderation: defaults.moderation, - system: defaults.system, - }); - } + await loadDefaults(); } } -export function loadFileSync(): void { - const fileConfig = loadFromFile(); - if (fileConfig) { - applyFileConfig(fileConfig); - console.log("📄 Game config loaded from file (sync)."); - } -} - -export const GameConfig = config; - -export function saveConfig(newConfig: unknown) { - const validatedConfig = fileConfigSchema.parse(newConfig); - const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4); - writeFileSync(configPath, jsonString, 'utf-8'); - applyFileConfig(validatedConfig); - console.log("🔄 Config saved to file."); -} - -export function toggleCommand(commandName: string, enabled: boolean) { - const fileConfig = loadFromFile(); - if (!fileConfig) { - console.error("Cannot toggle command: no file config available"); - return; - } - - const newConfig = { - ...fileConfig, - commands: { - ...fileConfig.commands, - [commandName]: enabled - } - }; - saveConfig(newConfig); -} - export async function initializeConfig(): Promise { - loadFileSync(); await reloadConfig(); } - -loadFileSync(); diff --git a/shared/modules/moderation/moderation.service.test.ts b/shared/modules/moderation/moderation.service.test.ts index a89c8cc..54daa0f 100644 --- a/shared/modules/moderation/moderation.service.test.ts +++ b/shared/modules/moderation/moderation.service.test.ts @@ -14,19 +14,7 @@ const mockReturning = mock(); const mockSet = mock(); const mockWhere = mock(); -// Mock Config -const mockConfig = { - moderation: { - cases: { - dmOnWarn: true, - autoTimeoutThreshold: 3 - } - } -}; -mock.module("@shared/lib/config", () => ({ - config: mockConfig -})); // Mock View const mockGetUserWarningEmbed = mock(() => ({})); @@ -66,9 +54,6 @@ describe("ModerationService", () => { mockSet.mockClear(); mockWhere.mockClear(); mockGetUserWarningEmbed.mockClear(); - // Reset config to defaults - mockConfig.moderation.cases.dmOnWarn = true; - mockConfig.moderation.cases.autoTimeoutThreshold = 3; }); describe("issueWarning", () => { @@ -100,7 +85,6 @@ describe("ModerationService", () => { }); it("should not DM if dmOnWarn is false", async () => { - mockConfig.moderation.cases.dmOnWarn = false; mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); mockFindMany.mockResolvedValue([]); @@ -109,7 +93,8 @@ describe("ModerationService", () => { await ModerationService.issueWarning({ ...defaultOptions, - dmTarget: mockDmTarget + dmTarget: mockDmTarget, + config: { dmOnWarn: false } }); expect(mockDmTarget.send).not.toHaveBeenCalled(); @@ -125,7 +110,8 @@ describe("ModerationService", () => { const result = await ModerationService.issueWarning({ ...defaultOptions, - timeoutTarget: mockTimeoutTarget + timeoutTarget: mockTimeoutTarget, + config: { autoTimeoutThreshold: 3 } }); expect(result.autoTimeoutIssued).toBe(true); diff --git a/shared/modules/moderation/moderation.service.ts b/shared/modules/moderation/moderation.service.ts index 84c1384..b5617ca 100644 --- a/shared/modules/moderation/moderation.service.ts +++ b/shared/modules/moderation/moderation.service.ts @@ -5,7 +5,7 @@ import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/m import { getUserWarningEmbed } from "@/modules/moderation/moderation.view"; import { CaseType } from "@shared/lib/constants"; -export interface ModerationConfig { +export interface ModerationCaseConfig { dmOnWarn?: boolean; autoTimeoutThreshold?: number; } @@ -66,7 +66,7 @@ export class ModerationService { guildName?: string; dmTarget?: { send: (options: any) => Promise }; timeoutTarget?: { timeout: (duration: number, reason: string) => Promise }; - config?: ModerationConfig; + config?: ModerationCaseConfig; }) { const moderationCase = await this.createCase({ type: CaseType.WARN, diff --git a/shared/scripts/migrate-config-to-db.ts b/shared/scripts/migrate-config-to-db.ts deleted file mode 100644 index 7a1d4e8..0000000 --- a/shared/scripts/migrate-config-to-db.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; -import { config } from "@shared/lib/config"; -import { env } from "@shared/lib/env"; - -async function migrateConfigToDatabase() { - const guildId = env.DISCORD_GUILD_ID; - - if (!guildId) { - console.error("DISCORD_GUILD_ID not set. Cannot migrate config."); - console.log("Set DISCORD_GUILD_ID in your environment to migrate config."); - process.exit(1); - } - - console.log(`Migrating config for guild ${guildId}...`); - - const existing = await guildSettingsService.getSettings(guildId); - if (existing) { - console.log("Guild settings already exist in database:"); - console.log(JSON.stringify(existing, null, 2)); - console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate."); - return; - } - - await guildSettingsService.upsertSettings({ - guildId, - studentRoleId: config.studentRole ?? undefined, - visitorRoleId: config.visitorRole ?? undefined, - colorRoleIds: config.colorRoles ?? [], - welcomeChannelId: config.welcomeChannelId ?? undefined, - welcomeMessage: config.welcomeMessage ?? undefined, - feedbackChannelId: config.feedbackChannelId ?? undefined, - terminalChannelId: config.terminal?.channelId ?? undefined, - terminalMessageId: config.terminal?.messageId ?? undefined, - moderationLogChannelId: config.moderation?.cases?.logChannelId ?? undefined, - moderationDmOnWarn: config.moderation?.cases?.dmOnWarn ?? true, - moderationAutoTimeoutThreshold: config.moderation?.cases?.autoTimeoutThreshold ?? undefined, - }); - - console.log("✅ Migration complete!"); - console.log("\nGuild settings are now stored in the database."); - console.log("You can manage them via:"); - console.log(" - /settings command in Discord"); - console.log(" - API endpoints at /api/guilds/:guildId/settings"); -} - -migrateConfigToDatabase() - .then(() => process.exit(0)) - .catch((error) => { - console.error("Migration failed:", error); - process.exit(1); - }); diff --git a/web/src/routes/settings.routes.ts b/web/src/routes/settings.routes.ts index 57849e0..eb055ce 100644 --- a/web/src/routes/settings.routes.ts +++ b/web/src/routes/settings.routes.ts @@ -7,13 +7,6 @@ import type { RouteContext, RouteModule } from "./types"; import { jsonResponse, errorResponse, withErrorHandling } from "./utils"; -/** - * JSON replacer for BigInt serialization. - */ -function jsonReplacer(_key: string, value: unknown): unknown { - return typeof value === "bigint" ? value.toString() : value; -} - /** * Settings routes handler. * @@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise { /** * @route GET /api/settings - * @description Returns the current bot configuration. + * @description Returns the current bot configuration from database. * Configuration includes economy settings, leveling settings, * command toggles, and other system settings. - * @response 200 - Full configuration object + * @response 200 - Full configuration object (DB format with strings for BigInts) * @response 500 - Error fetching settings * * @example * // Response * { - * "economy": { "dailyReward": 100, "streakBonus": 10 }, - * "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" }, + * "economy": { "daily": { "amount": "100", "streakBonus": "10" } }, + * "leveling": { "base": 100, "exponent": 1.5 }, * "commands": { "disabled": [], "channelLocks": {} } * } */ if (pathname === "/api/settings" && method === "GET") { return withErrorHandling(async () => { - const { config } = await import("@shared/lib/config"); - return new Response(JSON.stringify(config, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); + const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service"); + const settings = await gameSettingsService.getSettings(); + + if (!settings) { + // Return defaults if no settings in DB yet + return jsonResponse(gameSettingsService.getDefaults()); + } + + return jsonResponse(settings); }, "fetch settings"); } @@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise { * Only the provided fields will be updated; other settings remain unchanged. * After updating, commands are automatically reloaded. * - * @body Partial configuration object + * @body Partial configuration object (DB format with strings for BigInts) * @response 200 - `{ success: true }` * @response 400 - Validation error * @response 500 - Error saving settings @@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise { * @example * // Request - Only update economy daily reward * POST /api/settings - * { "economy": { "dailyReward": 150 } } + * { "economy": { "daily": { "amount": "150" } } } */ if (pathname === "/api/settings" && method === "POST") { try { - const partialConfig = await req.json(); - const { saveConfig, config: currentConfig } = await import("@shared/lib/config"); - const { deepMerge } = await import("@shared/lib/utils"); - - // Merge partial update into current config - const mergedConfig = deepMerge(currentConfig, partialConfig); - - // saveConfig throws if validation fails - saveConfig(mergedConfig); + const partialConfig = await req.json() as Record; + const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service"); + + // Use upsertSettings to merge partial update + await gameSettingsService.upsertSettings(partialConfig as Record); const { systemEvents, EVENTS } = await import("@shared/lib/events"); systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);