Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion node_modules

This file was deleted.

2 changes: 1 addition & 1 deletion tests/modules/commandAliases.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 12 additions & 3 deletions web/src/app/api/guilds/[guildId]/ai-feedback/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
44 changes: 16 additions & 28 deletions web/src/components/dashboard/ai-feedback-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand All @@ -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<FeedbackStats | null>(null);
const guildId = useGuildSelection();
const [stats, setStats] = useState<AiFeedbackStatsType | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type name inconsistency - import renames to AiFeedbackStatsType but assertion uses AiFeedbackStats

Suggested change
const data = (await res.json()) as AiFeedbackStats;
const data = (await res.json()) as AiFeedbackStatsType;
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/components/dashboard/ai-feedback-stats.tsx
Line: 53

Comment:
Type name inconsistency - import renames to `AiFeedbackStatsType` but assertion uses `AiFeedbackStats`

```suggestion
      const data = (await res.json()) as AiFeedbackStatsType;
```

How can I resolve this? If you propose a fix, please make it concise.

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
Expand Down Expand Up @@ -148,11 +139,8 @@ export function AiFeedbackStats() {
}
labelLine={false}
>
{pieData.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={PIE_COLORS[index % PIE_COLORS.length]}
/>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
Expand Down
126 changes: 126 additions & 0 deletions web/src/components/dashboard/config-diff-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={saving ? undefined : onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col">
<DialogHeader>
<DialogTitle>Review Changes Before Saving</DialogTitle>
<DialogDescription>
Review your pending changes. Revert individual sections or confirm to save all changes.
</DialogDescription>
</DialogHeader>

{/* Changed sections with per-section revert buttons */}
{changedSections.length > 0 && (
<fieldset
aria-label="Changed sections"
className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-3"
>
<legend className="sr-only">Changed sections</legend>
<span className="text-xs text-muted-foreground" aria-hidden="true">
Changed sections:
</span>
{changedSections.map((section) => (
<div key={section} className="flex items-center gap-1">
<span className="rounded border border-yellow-500/30 bg-yellow-500/20 px-2 py-0.5 text-xs capitalize text-yellow-300">
{section}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onRevertSection(section)}
disabled={saving}
aria-label={`Revert ${section} changes`}
>
<RotateCcw className="h-3 w-3" aria-hidden="true" />
</Button>
</div>
))}
</fieldset>
)}

{/* Scrollable diff view */}
<div className="min-h-0 flex-1 overflow-y-auto">
<ConfigDiff original={original} modified={modified} title="Pending Changes" />
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={onConfirm} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" aria-hidden="true" />
Confirm Save
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading