refactor: consolidate config types and remove file-based config

Tickets: #2, #3

- Remove duplicate type definitions from shared/lib/config.ts
- Import types from schema files (game-settings.ts, guild-settings.ts)
- Add GuildConfig interface to guild-settings.ts schema
- Rename ModerationConfig to ModerationCaseConfig in moderation.service.ts
- Delete shared/config/config.json and shared/scripts/migrate-config-to-db.ts
- Update settings API to use gameSettingsService exclusively
- Return DB format (strings) from API instead of runtime BigInts
- Fix moderation service tests to pass config as parameter

Breaking Changes:
- Removes legacy file-based configuration system
- API now returns database format with string values for BigInt fields
This commit is contained in:
syntaxbullet
2026-02-13 13:24:02 +01:00
parent 2d35a5eabb
commit 6eb4a32a12
6 changed files with 95 additions and 384 deletions

View File

@@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle
export type GuildSettings = InferSelectModel<typeof guildSettings>;
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
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' }),

View File

@@ -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<string, { config: GuildConfig; timestamp: number }>();
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<string, unknown>;
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<typeof fileConfigSchema>;
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<boolean> {
try {
@@ -358,16 +160,8 @@ async function loadFromDatabase(): Promise<boolean> {
return false;
}
export async function reloadConfig(): Promise<void> {
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.");
async function loadDefaults(): Promise<void> {
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, {
@@ -399,47 +193,15 @@ export async function reloadConfig(): Promise<void> {
system: defaults.system,
});
}
}
}
export function loadFileSync(): void {
const fileConfig = loadFromFile();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (sync).");
}
}
export async function reloadConfig(): Promise<void> {
const dbLoaded = await loadFromDatabase();
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.");
if (!dbLoaded) {
await loadDefaults();
}
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<void> {
loadFileSync();
await reloadConfig();
}
loadFileSync();

View File

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

View File

@@ -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<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
config?: ModerationConfig;
config?: ModerationCaseConfig;
}) {
const moderationCase = await this.createCase({
type: CaseType.WARN,

View File

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

View File

@@ -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<Response | null> {
/**
* @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<Response | null> {
* 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<Response | null> {
* @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");
const partialConfig = await req.json() as Record<string, unknown>;
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
// Merge partial update into current config
const mergedConfig = deepMerge(currentConfig, partialConfig);
// saveConfig throws if validation fails
saveConfig(mergedConfig);
// Use upsertSettings to merge partial update
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);