feat(collection-confirm): AI checks on/off switch in the page header

The n8n tick was calling in every 5 minutes but WFDASH answered
"disabled" because Collection checks was off in Settings → AI, and
nothing on Collection Confirm said so.

- New "AI checks" switch next to Refresh, the same setting as Settings →
  AI → Collection checks. Purple and "On" when running, with the last
  run in the tooltip; a gear links to /settings/ai.
- GET/PUT /api/v1/collection-confirm/ai/enabled (browser session). Only
  reads/writes collection_ai_enabled and the last run — no key, model or
  prompt goes through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-19 12:18:13 +02:00
parent 86020ed48d
commit 9ddb811afd
5 changed files with 318 additions and 1 deletions

View file

@ -5,6 +5,7 @@ import { Bot, Loader2, Search, Truck, UserRound } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { AiChecksToggle } from "@/components/collection-confirm/ai-checks-toggle";
import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog";
import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stock-dialog";
import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer";
@ -802,7 +803,7 @@ export default function CollectionConfirmPage() {
Review collected order items, edit costs, and confirm for invoicing.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<span
title={displayName}
aria-label={`Signed in as ${displayName}`}
@ -811,6 +812,7 @@ export default function CollectionConfirmPage() {
<UserRound className="h-3 w-3" />
<span className="font-mono">{displayName || "—"}</span>
</span>
<AiChecksToggle getHeaders={authedJsonHeaders} />
<Button
variant="outline"
size="sm"

View file

@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getAiSettings = vi.fn();
const updateAiSettings = vi.fn();
vi.mock("@/lib/server/ai-settings-service", () => ({
getAiSettings: (...a: unknown[]) => getAiSettings(...a),
updateAiSettings: (...a: unknown[]) => updateAiSettings(...a),
}));
const getBrowserSessionUser = vi.fn();
vi.mock("@/lib/server/browser-session", () => ({
getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
}));
import { GET, PUT } from "./route";
const URL = "http://localhost/api/v1/collection-confirm/ai/enabled";
const headers = { authorization: "Bearer tok", "content-type": "application/json" };
const summary = { started_at: "a", finished_at: "b", files_read: 2, cards_checked: 1, errors: [] };
beforeEach(() => {
vi.clearAllMocks();
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" });
getAiSettings.mockResolvedValue({
api_key: "",
collection_ai_enabled: false,
collection_ai_last_run_at: "2026-10-19 10:10:17Z",
collection_ai_last_run_summary: summary,
});
});
describe("GET /api/v1/collection-confirm/ai/enabled", () => {
it("401s without a valid session", async () => {
getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 }));
const res = await GET(new NextRequest(URL, { headers }));
expect(res.status).toBe(401);
expect(getAiSettings).not.toHaveBeenCalled();
});
it("returns only the switch and the last run, never other settings", async () => {
const res = await GET(new NextRequest(URL, { headers }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
enabled: false,
last_run_at: "2026-10-19 10:10:17Z",
last_run_summary: summary,
});
});
});
describe("PUT /api/v1/collection-confirm/ai/enabled", () => {
const put = (body: unknown) =>
PUT(new NextRequest(URL, { method: "PUT", headers, body: JSON.stringify(body) }));
it("401s without a valid session", async () => {
getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 }));
expect((await put({ enabled: true })).status).toBe(401);
expect(updateAiSettings).not.toHaveBeenCalled();
});
it.each([[{}], [{ enabled: "yes" }], [{ enabled: 1 }]])("400s for %j", async (body) => {
expect((await put(body)).status).toBe(400);
expect(updateAiSettings).not.toHaveBeenCalled();
});
it("only ever patches collection_ai_enabled", async () => {
updateAiSettings.mockResolvedValue({
collection_ai_enabled: true,
collection_ai_last_run_at: "",
collection_ai_last_run_summary: null,
});
const res = await put({ enabled: true, model_id: "evil", api_key: "x" });
expect(updateAiSettings).toHaveBeenCalledWith({ collection_ai_enabled: true });
expect(await res.json()).toEqual({ enabled: true, last_run_at: "", last_run_summary: null });
});
});

View file

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import {
getAiSettings,
updateAiSettings,
type AiSettings,
} from "@/lib/server/ai-settings-service";
/**
* GET / PUT /api/v1/collection-confirm/ai/enabled
*
* The "AI checks on/off" switch on the Collection Confirm header — a
* shortcut to Settings → AI → Collection checks, so the operator doesn't
* have to leave the page to start or stop the 5-minute tick.
*
* Deliberately narrow: GET returns only the switch and the last run, PUT
* accepts only `{ enabled: boolean }`. Nothing else in `ai_settings`
* (key, model, prompts) can be read or written through here.
*
* Auth: browser session (PB token forwarded in `Authorization`).
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function view(s: Pick<AiSettings, "collection_ai_enabled" | "collection_ai_last_run_at" | "collection_ai_last_run_summary">) {
return {
enabled: s.collection_ai_enabled,
last_run_at: s.collection_ai_last_run_at,
last_run_summary: s.collection_ai_last_run_summary,
};
}
function fail(err: unknown, fallback: string) {
const message = err instanceof Error ? err.message : fallback;
const status = (err as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
export async function GET(req: NextRequest) {
try {
await getBrowserSessionUser(req);
return NextResponse.json(view(await getAiSettings()));
} catch (err) {
return fail(err, "Failed to load the AI checks switch");
}
}
export async function PUT(req: NextRequest) {
try {
await getBrowserSessionUser(req);
const body = (await req.json().catch(() => ({}))) as { enabled?: unknown };
if (typeof body.enabled !== "boolean") {
return NextResponse.json({ error: "enabled must be true or false" }, { status: 400 });
}
const updated = await updateAiSettings({ collection_ai_enabled: body.enabled });
return NextResponse.json(view(updated));
} catch (err) {
return fail(err, "Failed to change the AI checks switch");
}
}

View file

@ -0,0 +1,59 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AiChecksToggle } from "./ai-checks-toggle";
const headers = () => ({ "content-type": "application/json", Authorization: "Bearer tok" });
const fetchMock = vi.fn();
function reply(body: unknown, ok = true) {
return Promise.resolve({ ok, status: ok ? 200 : 500, json: () => Promise.resolve(body) });
}
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
describe("AiChecksToggle", () => {
it("shows the switch off when the AI checks are off", async () => {
fetchMock.mockReturnValueOnce(reply({ enabled: false, last_run_at: "", last_run_summary: null }));
render(<AiChecksToggle getHeaders={headers} />);
const sw = await screen.findByRole("switch", { name: /ai checks/i });
await waitFor(() => expect(sw).not.toBeChecked());
expect(screen.getByTestId("ai-checks-status")).toHaveTextContent("Off");
expect(screen.getByRole("link", { name: /ai settings/i })).toHaveAttribute("href", "/settings/ai");
});
it("turns the AI checks on with a PUT and shows On", async () => {
fetchMock
.mockReturnValueOnce(reply({ enabled: false, last_run_at: "", last_run_summary: null }))
.mockReturnValueOnce(reply({ enabled: true, last_run_at: "", last_run_summary: null }));
const user = userEvent.setup();
render(<AiChecksToggle getHeaders={headers} />);
const sw = await screen.findByRole("switch", { name: /ai checks/i });
await waitFor(() => expect(sw).not.toBeChecked());
await user.click(sw);
await waitFor(() => expect(sw).toBeChecked());
expect(fetchMock).toHaveBeenLastCalledWith(
"/api/v1/collection-confirm/ai/enabled",
expect.objectContaining({ method: "PUT", body: JSON.stringify({ enabled: true }) }),
);
expect(screen.getByTestId("ai-checks-status")).toHaveTextContent("On");
});
it("flips back and says so when the save fails", async () => {
fetchMock
.mockReturnValueOnce(reply({ enabled: false, last_run_at: "", last_run_summary: null }))
.mockReturnValueOnce(reply({ error: "nope" }, false));
const user = userEvent.setup();
render(<AiChecksToggle getHeaders={headers} />);
const sw = await screen.findByRole("switch", { name: /ai checks/i });
await waitFor(() => expect(sw).not.toBeChecked());
await user.click(sw);
await waitFor(() => expect(screen.getByTestId("ai-checks-status")).toHaveTextContent(/couldn't save/i));
expect(sw).not.toBeChecked();
});
});

View file

@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bot, Settings } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import type { CollectionAiRunSummary } from "@/lib/types";
interface AiChecksToggleProps {
/** Same auth headers the page uses for its other /api/v1 calls. */
getHeaders: () => Record<string, string>;
}
interface SwitchState {
enabled: boolean;
last_run_at: string;
last_run_summary: CollectionAiRunSummary | null;
}
const URL = "/api/v1/collection-confirm/ai/enabled";
function lastRunLabel(s: SwitchState | null): string {
const summary = s?.last_run_summary;
if (!summary?.finished_at) return "Not run yet";
const when = new Date(summary.finished_at);
const time = Number.isNaN(when.getTime())
? summary.finished_at
: when.toLocaleString("en-ZA", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
return `Last run ${time} · read ${summary.files_read} file${summary.files_read === 1 ? "" : "s"}, checked ${summary.cards_checked} card${summary.cards_checked === 1 ? "" : "s"}`;
}
/**
* "AI checks" on/off switch for the Collection Confirm header — the same
* setting as Settings → AI → Collection checks. When it's off the n8n
* tick still calls in every 5 minutes but WFDASH answers "disabled" and
* reads nothing, which is easy to miss from this page.
*/
export function AiChecksToggle({ getHeaders }: AiChecksToggleProps) {
const [state, setState] = useState<SwitchState | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
fetch(URL, { headers: getHeaders() })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data: SwitchState) => {
if (!cancelled) setState(data);
})
.catch(() => {
if (!cancelled) setError("Couldn't load");
});
return () => {
cancelled = true;
};
}, [getHeaders]);
const change = async (enabled: boolean) => {
if (saving || !state) return;
const before = state;
setSaving(true);
setError("");
setState({ ...state, enabled });
try {
const r = await fetch(URL, {
method: "PUT",
headers: getHeaders(),
body: JSON.stringify({ enabled }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setState((await r.json()) as SwitchState);
} catch {
setState(before);
setError("Couldn't save");
} finally {
setSaving(false);
}
};
const on = state?.enabled ?? false;
const status = error || (state ? (on ? "On" : "Off") : "…");
return (
<div
className={`inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs ${
on ? "border-ai/40 bg-ai/10" : "bg-muted/30"
}`}
title={on ? lastRunLabel(state) : "The 5-minute AI check is off. Turn it on to have the AI read driver photos."}
>
<Bot className={`h-3.5 w-3.5 ${on ? "text-ai" : "text-muted-foreground"}`} aria-hidden />
<label htmlFor="ai-checks-switch" className="font-medium">
AI checks
</label>
<Switch
id="ai-checks-switch"
size="sm"
aria-label="AI checks"
checked={on}
disabled={!state || saving}
onCheckedChange={(checked) => void change(checked)}
className="data-checked:bg-ai"
/>
<span
data-testid="ai-checks-status"
className={error ? "text-destructive" : on ? "font-medium text-ai" : "text-muted-foreground"}
>
{status}
</span>
<Link
href="/settings/ai"
aria-label="AI settings"
className="text-muted-foreground hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
</Link>
</div>
);
}