feat(dashboard): implement bot settings page with partial updates and serialization fixes

This commit is contained in:
syntaxbullet
2026-01-08 22:35:46 +01:00
parent d46434de18
commit c6fd23b5fa
5 changed files with 740 additions and 13 deletions

View File

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

View File

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

View File

@@ -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<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export function Settings() {
// We use a DeepPartial type for the local form state to allow for safe updates
const [config, setConfig] = useState<DeepPartial<GameConfigType> | null>(null);
const [meta, setMeta] = useState<SettingsMeta>({ 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 (
<div className="flex items-center justify-center h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
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 (
<div>
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Manage bot configuration.</p>
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
Settings panel coming soon...
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white drop-shadow-md">Settings</h2>
<p className="text-white/60">Manage global bot configuration</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} className="glass border-white/10 hover:bg-white/10">
<RefreshCw className="h-4 w-4 mr-2" />
Reset
</Button>
<Button onClick={handleSave} disabled={saving} className="bg-primary hover:bg-primary/80 text-primary-foreground shadow-[0_0_15px_rgba(var(--primary),0.3)]">
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</div>
{/* Tabs Navigation */}
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all border ${activeTab === tab.id
? "bg-primary/20 border-primary/50 text-white shadow-[0_0_10px_rgba(var(--primary),0.2)]"
: "bg-white/5 border-white/5 text-white/50 hover:bg-white/10 hover:text-white"
}`}
>
<tab.icon className="h-4 w-4" />
<span className="font-medium">{tab.label}</span>
</button>
))}
</div>
<Card className="glass border-white/10">
<CardContent className="p-6">
{activeTab === "general" && (
<div className="space-y-6">
<SectionTitle icon={MessageSquare} title="Channels" description="Default channels for bot interactions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Welcome Channel"
value={config?.welcomeChannelId}
options={meta.channels}
onChange={(v) => updateConfig("welcomeChannelId", v)}
/>
<SelectField
label="Feedback Channel"
value={config?.feedbackChannelId}
options={meta.channels}
onChange={(v) => updateConfig("feedbackChannelId", v)}
/>
</div>
<SectionTitle icon={Terminal} title="Terminal" description="Dedicated channel for CLI-like interactions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Terminal Channel"
value={config?.terminal?.channelId}
options={meta.channels}
onChange={(v) => updateConfig("terminal.channelId", v)}
/>
<InputField
label="Terminal Message ID"
value={config?.terminal?.messageId}
onChange={(v) => updateConfig("terminal.messageId", v)}
placeholder="ID of the pinned message"
/>
</div>
</div>
)}
{activeTab === "economy" && (
<div className="space-y-6">
<SectionTitle icon={Coins} title="Daily Rewards" description="Configure daily currency claims" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Base Amount"
type="number"
value={config?.economy?.daily?.amount}
onChange={(v) => updateConfig("economy.daily.amount", v)}
/>
<InputField
label="Streak Bonus"
type="number"
value={config?.economy?.daily?.streakBonus}
onChange={(v) => updateConfig("economy.daily.streakBonus", v)}
/>
<InputField
label="Weekly Bonus"
type="number"
value={config?.economy?.daily?.weeklyBonus}
onChange={(v) => updateConfig("economy.daily.weeklyBonus", v)}
/>
</div>
<SectionTitle icon={RefreshCw} title="Transfers" description="Rules for player-to-player transfers" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Minimum Amount"
type="number"
value={config?.economy?.transfers?.minAmount}
onChange={(v) => updateConfig("economy.transfers.minAmount", v)}
/>
<div className="flex items-center justify-between p-4 rounded-lg bg-white/5 border border-white/5">
<span className="text-sm font-medium">Allow Self Transfer</span>
<input
type="checkbox"
checked={config?.economy?.transfers?.allowSelfTransfer ?? false}
onChange={(e) => updateConfig("economy.transfers.allowSelfTransfer", e.target.checked)}
className="h-5 w-5 rounded border-white/10 bg-white/5"
/>
</div>
</div>
<SectionTitle icon={Trophy} title="Lootdrops" description="Random event configuration" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Spawn Chance"
type="number"
value={config?.lootdrop?.spawnChance}
onChange={(v) => updateConfig("lootdrop.spawnChance", Number(v))}
/>
<InputField
label="Cooldown (ms)"
type="number"
value={config?.lootdrop?.cooldownMs}
onChange={(v) => updateConfig("lootdrop.cooldownMs", Number(v))}
/>
<InputField
label="Min Messages"
type="number"
value={config?.lootdrop?.minMessages}
onChange={(v) => updateConfig("lootdrop.minMessages", Number(v))}
/>
<InputField
label="Reward Min"
type="number"
value={config?.lootdrop?.reward?.min}
onChange={(v) => updateConfig("lootdrop.reward.min", Number(v))}
/>
<InputField
label="Reward Max"
type="number"
value={config?.lootdrop?.reward?.max}
onChange={(v) => updateConfig("lootdrop.reward.max", Number(v))}
/>
</div>
</div>
)}
{activeTab === "leveling" && (
<div className="space-y-6">
<SectionTitle icon={Trophy} title="XP Formula" description="Calculate level requirements" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Base XP"
type="number"
value={config?.leveling?.base}
onChange={(v) => updateConfig("leveling.base", Number(v))}
/>
<InputField
label="Exponent"
type="number"
value={config?.leveling?.exponent}
onChange={(v) => updateConfig("leveling.exponent", Number(v))}
/>
</div>
<SectionTitle icon={MessageSquare} title="Chat XP" description="XP gained from text messages" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Min XP"
type="number"
value={config?.leveling?.chat?.minXp}
onChange={(v) => updateConfig("leveling.chat.minXp", Number(v))}
/>
<InputField
label="Max XP"
type="number"
value={config?.leveling?.chat?.maxXp}
onChange={(v) => updateConfig("leveling.chat.maxXp", Number(v))}
/>
<InputField
label="Cooldown (ms)"
type="number"
value={config?.leveling?.chat?.cooldownMs}
onChange={(v) => updateConfig("leveling.chat.cooldownMs", Number(v))}
/>
</div>
</div>
)}
{activeTab === "roles" && (
<div className="space-y-6">
<SectionTitle icon={Users} title="System Roles" description="Map discord roles to bot functions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Student Role"
value={config?.studentRole}
options={meta.roles}
onChange={(v) => updateConfig("studentRole", v)}
/>
<SelectField
label="Visitor Role"
value={config?.visitorRole}
options={meta.roles}
onChange={(v) => updateConfig("visitorRole", v)}
/>
</div>
<SectionTitle icon={Terminal} title="Color Roles" description="Roles that players can buy/equip for username colors" />
{/* Multi-select for color roles is complex, simpler impl for now */}
<div className="p-4 rounded-lg bg-white/5 border border-white/5 space-y-2">
<p className="text-sm text-white/60 mb-2">Available Color Roles</p>
<div className="flex flex-wrap gap-2">
{(config?.colorRoles || []).map((roleId: string | undefined) => {
if (!roleId) return null;
const role = meta.roles.find(r => r.id === roleId);
return (
<span key={roleId} className="px-2 py-1 rounded bg-white/10 text-xs flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: role?.color || '#999' }} />
{role?.name || roleId}
<button
onClick={() => updateConfig("colorRoles", (config?.colorRoles || []).filter((id: string | undefined) => id !== roleId))}
className="hover:text-red-400 ml-1"
>
×
</button>
</span>
);
})}
</div>
<div className="mt-2">
<select
className="w-full bg-black/20 border border-white/10 rounded-md p-2 text-sm text-white mt-2"
onChange={(e) => {
if (e.target.value && !(config?.colorRoles || []).includes(e.target.value)) {
updateConfig("colorRoles", [...(config?.colorRoles || []), e.target.value]);
}
e.target.value = "";
}}
>
<option value="">+ Add Color Role</option>
{meta.roles.map(r => (
<option key={r.id} value={r.id} style={{ color: r.color }}>{r.name}</option>
))}
</select>
</div>
</div>
</div>
)}
{activeTab === "moderation" && (
<div className="space-y-6">
<SectionTitle icon={Shield} title="Pruning" description="Batch message deletion config" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Max Prune Amount"
type="number"
value={config?.moderation?.prune?.maxAmount}
onChange={(v) => updateConfig("moderation.prune.maxAmount", Number(v))}
/>
<InputField
label="Confirmation Threshold"
type="number"
value={config?.moderation?.prune?.confirmThreshold}
onChange={(v) => updateConfig("moderation.prune.confirmThreshold", Number(v))}
/>
</div>
</div>
)}
{activeTab === "system" && (
<div className="space-y-6">
<SectionTitle icon={Terminal} title="Commands" description="Enable or disable specific bot commands" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{meta.commands.map(cmd => (
<div key={cmd} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5">
<div className="flex flex-col">
<span className="text-sm font-medium">/{cmd}</span>
<span className="text-[10px] text-white/40">
{config?.commands?.[cmd] === false ? "Disabled" : "Enabled"}
</span>
</div>
<input
type="checkbox"
checked={config?.commands?.[cmd] !== false}
onChange={(e) => updateConfig(`commands.${cmd}`, e.target.checked)}
className="h-4 w-4 rounded border-white/10 bg-white/5 accent-primary"
/>
</div>
))}
{meta.commands.length === 0 && (
<div className="col-span-full text-center py-8 text-white/30">
No commands found in metadata.
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// Sub-components for cleaner code
function SectionTitle({ icon: Icon, title, description }: { icon: any, title: string, description: string }) {
return (
<div className="flex items-start gap-3 border-b border-white/5 pb-2 mb-4">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white leading-none">{title}</h3>
<p className="text-sm text-white/40 mt-1">{description}</p>
</div>
</div>
);
}
function InputField({ label, value, onChange, type = "text", placeholder }: { label: string, value: any, onChange: (val: string) => void, type?: string, placeholder?: string }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
<Input
type={type}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="bg-black/20 border-white/10 text-white focus:border-primary/50"
/>
</div>
);
}
function SelectField({ label, value, options, onChange }: { label: string, value: string | undefined, options: any[], onChange: (val: string) => void }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
<div className="relative">
<select
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="flex h-10 w-full rounded-md border border-white/10 bg-black/20 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-white appearance-none"
>
<option value="" className="bg-zinc-900 text-white/50">Select...</option>
{options.map((opt) => (
<option key={opt.id} value={opt.id} className="bg-zinc-900">
{opt.name}
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white/50">
<svg className="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /></svg>
</div>
</div>
</div>
);

View File

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

View File

@@ -168,6 +168,71 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
// Settings Management
if (url.pathname === "/api/settings") {
try {
if (req.method === "GET") {
const { config } = await import("@shared/lib/config");
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(config, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
}
if (req.method === "POST") {
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);
return Response.json({ success: true });
}
} catch (error) {
console.error("Settings error:", error);
return Response.json(
{ error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) },
{ status: 400 }
);
}
}
if (url.pathname === "/api/settings/meta") {
try {
const { AuroraClient } = await import("../../bot/lib/BotClient");
const { env } = await import("@shared/lib/env");
if (!env.DISCORD_GUILD_ID) {
return Response.json({ roles: [], channels: [] });
}
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
if (!guild) {
return Response.json({ roles: [], channels: [] });
}
// Map roles and channels to a simplified format
const roles = guild.roles.cache
.sort((a, b) => 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";