From 9ddb811afd6d3f21fb20b13f4ad17bc1e6e347c3 Mon Sep 17 00:00:00 2001
From: Dave
Date: Mon, 19 Oct 2026 12:18:13 +0200
Subject: [PATCH] feat(collection-confirm): AI checks on/off switch in the page
header
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/app/(app)/collection-confirm/page.tsx | 4 +-
.../ai/enabled/route.test.ts | 77 ++++++++++++
.../v1/collection-confirm/ai/enabled/route.ts | 60 +++++++++
.../ai-checks-toggle.test.tsx | 59 +++++++++
.../collection-confirm/ai-checks-toggle.tsx | 119 ++++++++++++++++++
5 files changed, 318 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/v1/collection-confirm/ai/enabled/route.test.ts
create mode 100644 src/app/api/v1/collection-confirm/ai/enabled/route.ts
create mode 100644 src/components/collection-confirm/ai-checks-toggle.test.tsx
create mode 100644 src/components/collection-confirm/ai-checks-toggle.tsx
diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx
index 7654511..bb9371b 100644
--- a/src/app/(app)/collection-confirm/page.tsx
+++ b/src/app/(app)/collection-confirm/page.tsx
@@ -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.
-
+
{displayName || "—"}
+
({
+ 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 });
+ });
+});
diff --git a/src/app/api/v1/collection-confirm/ai/enabled/route.ts b/src/app/api/v1/collection-confirm/ai/enabled/route.ts
new file mode 100644
index 0000000..1b5deb4
--- /dev/null
+++ b/src/app/api/v1/collection-confirm/ai/enabled/route.ts
@@ -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) {
+ 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");
+ }
+}
diff --git a/src/components/collection-confirm/ai-checks-toggle.test.tsx b/src/components/collection-confirm/ai-checks-toggle.test.tsx
new file mode 100644
index 0000000..283d03a
--- /dev/null
+++ b/src/components/collection-confirm/ai-checks-toggle.test.tsx
@@ -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( );
+ 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( );
+ 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( );
+ 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();
+ });
+});
diff --git a/src/components/collection-confirm/ai-checks-toggle.tsx b/src/components/collection-confirm/ai-checks-toggle.tsx
new file mode 100644
index 0000000..666d559
--- /dev/null
+++ b/src/components/collection-confirm/ai-checks-toggle.tsx
@@ -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;
+}
+
+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(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 (
+
+
+
+ AI checks
+
+ void change(checked)}
+ className="data-checked:bg-ai"
+ />
+
+ {status}
+
+
+
+
+
+ );
+}