diff --git a/node_modules b/node_modules
deleted file mode 120000
index 87273d9f..00000000
--- a/node_modules
+++ /dev/null
@@ -1 +0,0 @@
-/home/bill/volvox-bot/node_modules
\ No newline at end of file
diff --git a/tests/modules/commandAliases.test.js b/tests/modules/commandAliases.test.js
index 4798adda..c9f671a2 100644
--- a/tests/modules/commandAliases.test.js
+++ b/tests/modules/commandAliases.test.js
@@ -2,7 +2,7 @@
* Tests for src/modules/commandAliases.js
*/
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/logger.js', () => ({
info: vi.fn(),
diff --git a/web/src/app/api/guilds/[guildId]/ai-feedback/stats/route.ts b/web/src/app/api/guilds/[guildId]/ai-feedback/stats/route.ts
index fbe95b48..084a5682 100644
--- a/web/src/app/api/guilds/[guildId]/ai-feedback/stats/route.ts
+++ b/web/src/app/api/guilds/[guildId]/ai-feedback/stats/route.ts
@@ -35,9 +35,18 @@ export async function GET(
);
if (upstreamUrl instanceof NextResponse) return upstreamUrl;
- const days = request.nextUrl.searchParams.get('days');
- if (days !== null) {
- upstreamUrl.searchParams.set('days', days);
+ // Validate and clamp 'days' param to prevent unbounded/expensive lookback queries
+ const rawDays = request.nextUrl.searchParams.get('days');
+ if (rawDays !== null) {
+ const parsed = parseInt(rawDays, 10);
+ if (Number.isNaN(parsed)) {
+ return NextResponse.json(
+ { error: 'Invalid days parameter: must be an integer' },
+ { status: 400 },
+ );
+ }
+ const clampedDays = Math.min(90, Math.max(1, parsed));
+ upstreamUrl.searchParams.set('days', String(clampedDays));
}
return proxyToBotApi(
diff --git a/web/src/components/dashboard/ai-feedback-stats.tsx b/web/src/components/dashboard/ai-feedback-stats.tsx
index 6ba8d1f9..8bfdbc63 100644
--- a/web/src/components/dashboard/ai-feedback-stats.tsx
+++ b/web/src/components/dashboard/ai-feedback-stats.tsx
@@ -17,19 +17,8 @@ import {
} from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useGuildSelection } from '@/hooks/use-guild-selection';
-import { getBotApiBaseUrl } from '@/lib/bot-api';
-
-interface FeedbackStats {
- positive: number;
- negative: number;
- total: number;
- ratio: number | null;
- trend: Array<{
- date: string;
- positive: number;
- negative: number;
- }>;
-}
+
+import type { AiFeedbackStats as AiFeedbackStatsType } from '@/types/analytics';
const PIE_COLORS = ['#22C55E', '#EF4444'];
@@ -38,41 +27,43 @@ const PIE_COLORS = ['#22C55E', '#EF4444'];
* Shows 👍/👎 aggregate counts, approval ratio, and daily trend.
*/
export function AiFeedbackStats() {
- const selectedGuild = useGuildSelection();
- const [stats, setStats] = useState(null);
+ const guildId = useGuildSelection();
+ const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchStats = useCallback(async () => {
- const apiBase = getBotApiBaseUrl();
- if (!selectedGuild || !apiBase) return;
+ if (!guildId) return;
setLoading(true);
setError(null);
try {
- const res = await fetch(`${apiBase}/guilds/${selectedGuild}/ai-feedback/stats?days=30`, {
- credentials: 'include',
- });
+ const res = await fetch(
+ `/api/guilds/${encodeURIComponent(guildId)}/ai-feedback/stats?days=30`,
+ {
+ credentials: 'include',
+ },
+ );
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
- const data = (await res.json()) as FeedbackStats;
+ const data = (await res.json()) as AiFeedbackStats;
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load feedback stats');
} finally {
setLoading(false);
}
- }, [selectedGuild]);
+ }, [guildId]);
useEffect(() => {
void fetchStats();
}, [fetchStats]);
- if (!selectedGuild) return null;
+ if (!guildId) return null;
const pieData =
stats && stats.total > 0
@@ -148,11 +139,8 @@ export function AiFeedbackStats() {
}
labelLine={false}
>
- {pieData.map((_, index) => (
-
+ {pieData.map((entry, index) => (
+
))}
diff --git a/web/src/components/dashboard/config-diff-modal.tsx b/web/src/components/dashboard/config-diff-modal.tsx
new file mode 100644
index 00000000..d1689ebd
--- /dev/null
+++ b/web/src/components/dashboard/config-diff-modal.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { Loader2, RotateCcw, Save } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { ConfigDiff } from './config-diff';
+
+interface ConfigDiffModalProps {
+ /** Whether the modal is open. */
+ open: boolean;
+ /** Callback to open/close the modal. Blocked while saving. */
+ onOpenChange: (open: boolean) => void;
+ /** The original (saved) config to diff against. */
+ original: object;
+ /** The modified (draft) config to diff. */
+ modified: object;
+ /** Top-level section keys that have changes. */
+ changedSections: string[];
+ /** Called when user confirms the save. */
+ onConfirm: () => void;
+ /** Called when user reverts a specific top-level section. */
+ onRevertSection: (section: string) => void;
+ /** Whether a save is in progress. */
+ saving: boolean;
+}
+
+/**
+ * A modal dialog that shows a diff preview of pending config changes before saving.
+ *
+ * Displays the changed sections as badges with individual revert buttons, a scrollable
+ * line-by-line diff, and Cancel / Confirm Save actions.
+ *
+ * @param open - Whether the dialog is visible.
+ * @param onOpenChange - Callback to open/close the dialog (blocked while saving).
+ * @param original - The original saved config object.
+ * @param modified - The draft config object with pending changes.
+ * @param changedSections - List of top-level section keys that differ.
+ * @param onConfirm - Called when the user clicks "Confirm Save".
+ * @param onRevertSection - Called with a section key when the user reverts that section.
+ * @param saving - When true, disables controls and shows a spinner on the confirm button.
+ * @returns The diff preview dialog element.
+ */
+export function ConfigDiffModal({
+ open,
+ onOpenChange,
+ original,
+ modified,
+ changedSections,
+ onConfirm,
+ onRevertSection,
+ saving,
+}: ConfigDiffModalProps) {
+ return (
+
+ );
+}
diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx
index 335c170c..325ba8c0 100644
--- a/web/src/components/dashboard/config-editor.tsx
+++ b/web/src/components/dashboard/config-editor.tsx
@@ -1,6 +1,6 @@
'use client';
-import { AlertCircle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react';
+import { Loader2, RotateCcw, Save } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -12,6 +12,7 @@ import { GUILD_SELECTED_EVENT, SELECTED_GUILD_KEY } from '@/lib/guild-selection'
import type { BotConfig, DeepPartial } from '@/types/config';
import { SYSTEM_PROMPT_MAX_LENGTH } from '@/types/config';
import { ConfigDiff } from './config-diff';
+import { ConfigDiffModal } from './config-diff-modal';
import { DiscardChangesButton } from './reset-defaults-button';
import { SystemPromptEditor } from './system-prompt-editor';
@@ -123,6 +124,11 @@ export function ConfigEditor() {
const [guildId, setGuildId] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
+ const [showDiffModal, setShowDiffModal] = useState(false);
+ const [prevSavedConfig, setPrevSavedConfig] = useState<{
+ guildId: string;
+ config: GuildConfig;
+ } | null>(null);
const [error, setError] = useState(null);
/** The config as last fetched from the API (the "saved" state). */
@@ -136,15 +142,6 @@ export function ConfigEditor() {
const abortRef = useRef(null);
- /** Auto-save status indicator. */
- const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
- /** Debounce timer for auto-save. */
- const autoSaveTimerRef = useRef | null>(null);
- /** True while the initial config load is in progress — suppresses auto-save. */
- const isLoadingConfigRef = useRef(false);
- /** Ref tracking latest draftConfig for race-condition detection during save. */
- const draftConfigRef = useRef(null);
-
const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => {
setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig));
}, []);
@@ -178,7 +175,7 @@ export function ConfigEditor() {
}, []);
// ── Load config when guild changes ─────────────────────────────
- const fetchConfig = useCallback(async (id: string, { skipSaveStatusReset = false } = {}) => {
+ const fetchConfig = useCallback(async (id: string) => {
if (!id) return;
abortRef.current?.abort();
@@ -187,7 +184,6 @@ export function ConfigEditor() {
setLoading(true);
setError(null);
- isLoadingConfigRef.current = true;
try {
const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, {
@@ -219,19 +215,10 @@ export function ConfigEditor() {
}
setSavedConfig(data);
setDraftConfig(structuredClone(data));
- // Reset status after a successful reload; clear any stale error
- if (!skipSaveStatusReset) {
- setSaveStatus('idle');
- }
- // Use a macrotask so the state setter for draftConfig fires before we clear the flag
- setTimeout(() => {
- isLoadingConfigRef.current = false;
- }, 0);
setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n'));
setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', '));
} catch (err) {
if ((err as Error).name === 'AbortError') return;
- isLoadingConfigRef.current = false;
const msg = (err as Error).message || 'Failed to load config';
setError(msg);
toast.error('Failed to load config', { description: msg });
@@ -245,11 +232,6 @@ export function ConfigEditor() {
return () => abortRef.current?.abort();
}, [guildId, fetchConfig]);
- // Keep draftConfigRef in sync for race-condition detection in saveChanges
- useEffect(() => {
- draftConfigRef.current = draftConfig;
- }, [draftConfig]);
-
// ── Derived state ──────────────────────────────────────────────
const hasChanges = useMemo(() => {
if (!savedConfig || !draftConfig) return false;
@@ -271,6 +253,12 @@ export function ConfigEditor() {
return promptLength > SYSTEM_PROMPT_MAX_LENGTH;
}, [draftConfig]);
+ /** Top-level config sections that have pending changes. */
+ const changedSections = useMemo(() => {
+ if (!savedConfig || !draftConfig) return [];
+ const patches = computePatches(savedConfig, draftConfig);
+ return [...new Set(patches.map((p) => p.path.split('.')[0]))];
+ }, [savedConfig, draftConfig]);
// ── Warn on unsaved changes before navigation ──────────────────
useEffect(() => {
if (!hasChanges) return;
@@ -285,7 +273,47 @@ export function ConfigEditor() {
}, [hasChanges]);
// ── Save changes (batched: parallel PATCH per section) ─────────
- const saveChanges = useCallback(async () => {
+ // ── Open diff modal before saving ─────────────────────────────
+ const openDiffModal = useCallback(() => {
+ if (!guildId || !savedConfig || !draftConfig) return;
+ if (hasValidationErrors) {
+ toast.error('Cannot save', {
+ description: 'Fix validation errors before saving.',
+ });
+ return;
+ }
+ if (!hasChanges) {
+ toast.info('No changes to save.');
+ return;
+ }
+ setShowDiffModal(true);
+ }, [guildId, savedConfig, draftConfig, hasValidationErrors, hasChanges]);
+
+ // ── Revert a single top-level section to saved state ──────────
+ const revertSection = useCallback(
+ (section: string) => {
+ if (!savedConfig) return;
+ setDraftConfig((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ [section]: (savedConfig as Record)[section],
+ } as GuildConfig;
+ });
+ // Keep raw string mirrors consistent
+ if (section === 'welcome') {
+ setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n'));
+ }
+ if (section === 'moderation') {
+ setProtectRoleIdsRaw((savedConfig.moderation?.protectRoles?.roleIds ?? []).join(', '));
+ }
+ toast.success(`Reverted ${section} changes.`);
+ },
+ [savedConfig],
+ );
+
+ // ── Execute the save (called from diff modal confirm) ──────────
+ const executeSave = useCallback(async () => {
if (!guildId || !savedConfig || !draftConfig) return;
if (hasValidationErrors) {
@@ -296,10 +324,11 @@ export function ConfigEditor() {
}
const patches = computePatches(savedConfig, draftConfig);
- if (patches.length === 0) return;
-
- // Snapshot draft before saving to detect changes made during in-flight save
- const draftSnapshot = draftConfig;
+ if (patches.length === 0) {
+ setShowDiffModal(false);
+ toast.info('No changes to save.');
+ return;
+ }
// Group patches by top-level section for batched requests
const bySection = new Map>();
@@ -314,7 +343,6 @@ export function ConfigEditor() {
}
setSaving(true);
- setSaveStatus('saving');
// Shared AbortController for all section saves - aborts all in-flight requests on 401
const saveAbortController = new AbortController();
@@ -379,70 +407,60 @@ export function ConfigEditor() {
return updated;
});
}
- setSaveStatus('error');
toast.error('Some sections failed to save', {
description: `Failed: ${failedSections.join(', ')}`,
});
} else {
- setSaveStatus('saved');
- // Full success: check if draft changed during save (user edited while saving)
- if (deepEqual(draftConfigRef.current, draftSnapshot)) {
- // No changes during save — safe to reload authoritative version
- await fetchConfig(guildId, { skipSaveStatusReset: true });
- } else {
- // Draft changed during save — don't overwrite user's new changes
- // Update savedConfig to reflect what was successfully persisted
- setSavedConfig(structuredClone(draftSnapshot));
- }
+ toast.success('Config saved successfully!');
+ setShowDiffModal(false);
+ // Store previous config for undo (1 level deep; scoped to current guild)
+ setPrevSavedConfig({ guildId, config: structuredClone(savedConfig) as GuildConfig });
+ // Full success: reload to get the authoritative version from the server
+ await fetchConfig(guildId);
}
} catch (err) {
const msg = (err as Error).message || 'Failed to save config';
- setSaveStatus('error');
toast.error('Failed to save config', { description: msg });
} finally {
setSaving(false);
}
}, [guildId, savedConfig, draftConfig, hasValidationErrors, fetchConfig]);
- // ── Auto-save: debounce 500ms after draft changes ──────────────
+ // Clear undo snapshot when guild changes to prevent cross-guild config corruption
useEffect(() => {
- // Don't auto-save during initial config load or if no changes
- if (isLoadingConfigRef.current || !hasChanges || hasValidationErrors || saving) return;
+ setPrevSavedConfig(null);
+ }, []);
- // Clear any pending timer
- if (autoSaveTimerRef.current) {
- clearTimeout(autoSaveTimerRef.current);
+ // ── Undo last save ─────────────────────────────────────────────
+ const undoLastSave = useCallback(() => {
+ if (!prevSavedConfig) return;
+ // Guard: discard snapshot if guild changed since save
+ if (prevSavedConfig.guildId !== guildId) {
+ setPrevSavedConfig(null);
+ return;
}
+ setDraftConfig(structuredClone(prevSavedConfig.config));
+ setDmStepsRaw((prevSavedConfig.config.welcome?.dmSequence?.steps ?? []).join('\n'));
+ setProtectRoleIdsRaw(
+ (prevSavedConfig.config.moderation?.protectRoles?.roleIds ?? []).join(', '),
+ );
+ setPrevSavedConfig(null);
+ toast.info('Reverted to previous saved state. Save again to apply.');
+ }, [prevSavedConfig, guildId]);
- autoSaveTimerRef.current = setTimeout(() => {
- void saveChanges();
- }, 500);
-
- return () => {
- if (autoSaveTimerRef.current) {
- clearTimeout(autoSaveTimerRef.current);
- }
- };
- }, [hasChanges, hasValidationErrors, saving, saveChanges]);
-
- // ── Keyboard shortcut: Ctrl/Cmd+S to save ──────────────────────
+ // ── Keyboard shortcut: Ctrl/Cmd+S → open diff preview ─────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (hasChanges && !saving && !hasValidationErrors) {
- // Cancel any pending debounce timer and save immediately
- if (autoSaveTimerRef.current) {
- clearTimeout(autoSaveTimerRef.current);
- autoSaveTimerRef.current = null;
- }
- void saveChanges();
+ openDiffModal();
}
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
- }, [hasChanges, saving, hasValidationErrors, saveChanges]);
+ }, [hasChanges, saving, hasValidationErrors, openDiffModal]);
// ── Discard edits ──────────────────────────────────────────────
const discardChanges = useCallback(() => {
@@ -757,24 +775,61 @@ export function ConfigEditor() {
Manage AI, welcome messages, and other settings.
-
- {/* Auto-save status indicator */}
-
+
+ {/* Undo last save — visible only after a successful save with no new changes */}
+ {prevSavedConfig && !hasChanges && (
+
+ )}
+ {/* Save button with unsaved-changes indicator dot */}
+