From c6fd23b5fa1ac76fea9ffb8a34a9aee37c0e39c6 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 8 Jan 2026 22:35:46 +0100 Subject: [PATCH] feat(dashboard): implement bot settings page with partial updates and serialization fixes --- shared/lib/config.ts | 10 +- shared/lib/utils.ts | 39 +++ web/src/pages/Settings.tsx | 479 +++++++++++++++++++++++++++++++- web/src/server.settings.test.ts | 160 +++++++++++ web/src/server.ts | 65 +++++ 5 files changed, 740 insertions(+), 13 deletions(-) create mode 100644 web/src/server.settings.test.ts diff --git a/shared/lib/config.ts b/shared/lib/config.ts index 1ffaea9..087f31c 100644 --- a/shared/lib/config.ts +++ b/shared/lib/config.ts @@ -1,3 +1,4 @@ +import { jsonReplacer } from './utils'; import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { z } from 'zod'; @@ -191,14 +192,7 @@ export function saveConfig(newConfig: unknown) { // Validate and transform input const validatedConfig = configSchema.parse(newConfig); - const replacer = (key: string, value: any) => { - if (typeof value === 'bigint') { - return value.toString(); - } - return value; - }; - - const jsonString = JSON.stringify(validatedConfig, replacer, 4); + const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4); writeFileSync(configPath, jsonString, 'utf-8'); reloadConfig(); } diff --git a/shared/lib/utils.ts b/shared/lib/utils.ts index 98476e4..bb3a4bf 100644 --- a/shared/lib/utils.ts +++ b/shared/lib/utils.ts @@ -9,3 +9,42 @@ import type { Command } from "./types"; export function createCommand(command: Command): Command { return command; } + +/** + * JSON Replacer function for serialization + * Handles safe serialization of BigInt values to strings + */ +export const jsonReplacer = (_key: string, value: unknown): unknown => { + if (typeof value === 'bigint') { + return value.toString(); + } + return value; +}; + +/** + * Deep merge utility + */ +export function deepMerge(target: any, source: any): any { + if (typeof target !== 'object' || target === null) { + return source; + } + if (typeof source !== 'object' || source === null) { + return source; + } + + const output = { ...target }; + + Object.keys(source).forEach(key => { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + + return output; +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 8a7fddb..be1ae01 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,11 +1,480 @@ +import { useState, useEffect } from "react"; +import { Loader2, Save, RefreshCw, Smartphone, Coins, Trophy, Shield, Users, Terminal, MessageSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; + +// Types matching the backend response +interface RoleOption { id: string; name: string; color: string; } +interface ChannelOption { id: string; name: string; type: number; } + +interface SettingsMeta { + roles: RoleOption[]; + channels: ChannelOption[]; + commands: string[]; +} + +import { type GameConfigType } from "@shared/lib/config"; + +// Recursive partial type for nested updates +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; export function Settings() { + // We use a DeepPartial type for the local form state to allow for safe updates + const [config, setConfig] = useState | null>(null); + const [meta, setMeta] = useState({ roles: [], channels: [], commands: [] }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("general"); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + setLoading(true); + try { + const [configRes, metaRes] = await Promise.all([ + fetch("/api/settings"), + fetch("/api/settings/meta") + ]); + + if (configRes.ok && metaRes.ok) { + const configData = await configRes.json(); + const metaData = await metaRes.json(); + setConfig(configData); + setMeta(metaData); + } + } catch (error) { + console.error("Failed to fetch settings:", error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const response = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }); + + if (!response.ok) throw new Error("Failed to save"); + // Reload to satisfy any server-side transformations + await fetchData(); + } catch (error) { + console.error("Failed to save settings:", error); + } finally { + setSaving(false); + } + }; + + // Helper to update nested state + const updateConfig = (path: string, value: any) => { + if (!path) return; + setConfig((prev: any) => { + const newConfig = { ...prev }; + const parts = path.split('.'); + let current = newConfig; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (part === undefined) return prev; + // Clone nested objects to ensure immutability + if (!current[part]) current[part] = {}; + current[part] = { ...current[part] }; + current = current[part]; + } + + const lastPart = parts[parts.length - 1]; + if (lastPart !== undefined) { + current[lastPart] = value; + } + + return newConfig; + }); + }; + + if (loading || !config) { + return ( +
+ +
+ ); + } + + const tabs = [ + { id: "general", label: "General", icon: Smartphone }, + { id: "economy", label: "Economy", icon: Coins }, + { id: "leveling", label: "Leveling", icon: Trophy }, + { id: "moderation", label: "Moderation", icon: Shield }, + { id: "roles", label: "Roles", icon: Users }, + { id: "system", label: "System", icon: Terminal }, + ]; + return ( -
-

Settings

-

Manage bot configuration.

-
- Settings panel coming soon... +
+
+
+

Settings

+

Manage global bot configuration

+
+
+ + +
+
+ + {/* Tabs Navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + + + {activeTab === "general" && ( +
+ +
+ updateConfig("welcomeChannelId", v)} + /> + updateConfig("feedbackChannelId", v)} + /> +
+ + +
+ updateConfig("terminal.channelId", v)} + /> + updateConfig("terminal.messageId", v)} + placeholder="ID of the pinned message" + /> +
+
+ )} + + {activeTab === "economy" && ( +
+ +
+ updateConfig("economy.daily.amount", v)} + /> + updateConfig("economy.daily.streakBonus", v)} + /> + updateConfig("economy.daily.weeklyBonus", v)} + /> +
+ + +
+ updateConfig("economy.transfers.minAmount", v)} + /> +
+ Allow Self Transfer + updateConfig("economy.transfers.allowSelfTransfer", e.target.checked)} + className="h-5 w-5 rounded border-white/10 bg-white/5" + /> +
+
+ + +
+ updateConfig("lootdrop.spawnChance", Number(v))} + /> + updateConfig("lootdrop.cooldownMs", Number(v))} + /> + updateConfig("lootdrop.minMessages", Number(v))} + /> + updateConfig("lootdrop.reward.min", Number(v))} + /> + updateConfig("lootdrop.reward.max", Number(v))} + /> +
+
+ )} + + {activeTab === "leveling" && ( +
+ +
+ updateConfig("leveling.base", Number(v))} + /> + updateConfig("leveling.exponent", Number(v))} + /> +
+ + +
+ updateConfig("leveling.chat.minXp", Number(v))} + /> + updateConfig("leveling.chat.maxXp", Number(v))} + /> + updateConfig("leveling.chat.cooldownMs", Number(v))} + /> +
+
+ )} + + {activeTab === "roles" && ( +
+ +
+ updateConfig("studentRole", v)} + /> + updateConfig("visitorRole", v)} + /> +
+ + + {/* Multi-select for color roles is complex, simpler impl for now */} +
+

Available Color Roles

+
+ {(config?.colorRoles || []).map((roleId: string | undefined) => { + if (!roleId) return null; + const role = meta.roles.find(r => r.id === roleId); + return ( + + + {role?.name || roleId} + + + ); + })} +
+
+ +
+
+
+ )} + {activeTab === "moderation" && ( +
+ +
+ updateConfig("moderation.prune.maxAmount", Number(v))} + /> + updateConfig("moderation.prune.confirmThreshold", Number(v))} + /> +
+
+ )} + + {activeTab === "system" && ( +
+ +
+ {meta.commands.map(cmd => ( +
+
+ /{cmd} + + {config?.commands?.[cmd] === false ? "Disabled" : "Enabled"} + +
+ updateConfig(`commands.${cmd}`, e.target.checked)} + className="h-4 w-4 rounded border-white/10 bg-white/5 accent-primary" + /> +
+ ))} + {meta.commands.length === 0 && ( +
+ No commands found in metadata. +
+ )} +
+
+ )} +
+
+
+ ); +} + +// Sub-components for cleaner code + +function SectionTitle({ icon: Icon, title, description }: { icon: any, title: string, description: string }) { + return ( +
+
+ +
+
+

{title}

+

{description}

+
+
+ ); +} + +function InputField({ label, value, onChange, type = "text", placeholder }: { label: string, value: any, onChange: (val: string) => void, type?: string, placeholder?: string }) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="bg-black/20 border-white/10 text-white focus:border-primary/50" + /> +
+ ); +} + +function SelectField({ label, value, options, onChange }: { label: string, value: string | undefined, options: any[], onChange: (val: string) => void }) { + return ( +
+ +
+ +
+ +
); diff --git a/web/src/server.settings.test.ts b/web/src/server.settings.test.ts new file mode 100644 index 0000000..b6b64a0 --- /dev/null +++ b/web/src/server.settings.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test"; +import { type WebServerInstance } from "./server"; + +// Mock the dependencies +const mockConfig = { + leveling: { + base: 100, + exponent: 1.5, + chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } + }, + economy: { + daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 }, + transfers: { allowSelfTransfer: false, minAmount: 50n }, + exam: { multMin: 1.5, multMax: 2.5 } + }, + inventory: { maxStackSize: 99n, maxSlots: 20 }, + lootdrop: { + spawnChance: 0.1, + cooldownMs: 3600000, + minMessages: 10, + reward: { min: 100, max: 500, currency: "gold" } + }, + commands: { "help": true }, + system: {}, + moderation: { + prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 }, + cases: { dmOnWarn: true } + } +}; + +const mockSaveConfig = jest.fn(); + +// Mock @shared/lib/config using mock.module +mock.module("@shared/lib/config", () => ({ + config: mockConfig, + saveConfig: mockSaveConfig, + GameConfigType: {} +})); + +// Mock BotClient +const mockGuild = { + roles: { + cache: [ + { id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 }, + { id: "role2", name: "User", hexColor: "#000000", position: 0 } + ] + }, + channels: { + cache: [ + { id: "chan1", name: "general", type: 0 } + ] + } +}; + +mock.module("../../bot/lib/BotClient", () => ({ + AuroraClient: { + guilds: { + cache: { + get: () => mockGuild + } + }, + commands: [ + { data: { name: "ping" } } + ] + } +})); + +mock.module("@shared/lib/env", () => ({ + env: { + DISCORD_GUILD_ID: "123456789" + } +})); + +// Mock spawn +mock.module("bun", () => { + return { + spawn: jest.fn(() => ({ + unref: () => { } + })), + serve: Bun.serve + }; +}); + +// Import createWebServer after mocks +import { createWebServer } from "./server"; + +describe("Settings API", () => { + let serverInstance: WebServerInstance; + const PORT = 3009; + const BASE_URL = `http://localhost:${PORT}`; + + beforeEach(async () => { + jest.clearAllMocks(); + serverInstance = await createWebServer({ port: PORT }); + }); + + afterEach(async () => { + if (serverInstance) { + await serverInstance.stop(); + } + }); + + it("GET /api/settings should return current configuration", async () => { + const res = await fetch(`${BASE_URL}/api/settings`); + expect(res.status).toBe(200); + + const data = await res.json(); + // Check if BigInts are converted to strings + expect(data.economy.daily.amount).toBe("100"); + expect(data.leveling.base).toBe(100); + }); + + it("POST /api/settings should save valid configuration via merge", async () => { + // We only send a partial update, expecting the server to merge it + // Note: For now the server implementation might still default to overwrite if we haven't updated it yet. + // But the user requested "partial vs full" fix. + // Let's assume we implement the merge logic. + const partialConfig = { studentRole: "new-role-partial" }; + + const res = await fetch(`${BASE_URL}/api/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(partialConfig) + }); + + expect(res.status).toBe(200); + // Expect saveConfig to be called with the MERGED result + expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({ + studentRole: "new-role-partial", + leveling: mockConfig.leveling // Should keep existing values + })); + }); + + it("POST /api/settings should return 400 when save fails", async () => { + mockSaveConfig.mockImplementationOnce(() => { + throw new Error("Validation failed"); + }); + + const res = await fetch(`${BASE_URL}/api/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) // Empty might be valid partial, but mocks throw + }); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.details).toBe("Validation failed"); + }); + + it("GET /api/settings/meta should return simplified metadata", async () => { + const res = await fetch(`${BASE_URL}/api/settings/meta`); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.roles).toHaveLength(2); + expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" }); + expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 }); + expect(data.commands).toContain("ping"); + }); +}); diff --git a/web/src/server.ts b/web/src/server.ts index 873d181..04cd2e4 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -168,6 +168,71 @@ export async function createWebServer(config: WebServerConfig = {}): Promise b.position - a.position) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })); + + const channels = guild.channels.cache + .map(c => ({ id: c.id, name: c.name, type: c.type })); + + const commands = AuroraClient.commands.map(c => c.data.name); + + return Response.json({ roles, channels, commands }); + } catch (error) { + console.error("Error fetching settings meta:", error); + return Response.json( + { error: "Failed to fetch metadata" }, + { status: 500 } + ); + } + } + // Static File Serving let pathName = url.pathname; if (pathName === "/") pathName = "/index.html";