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:
parent
86020ed48d
commit
9ddb811afd
5 changed files with 318 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
77
src/app/api/v1/collection-confirm/ai/enabled/route.test.ts
Normal file
77
src/app/api/v1/collection-confirm/ai/enabled/route.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
60
src/app/api/v1/collection-confirm/ai/enabled/route.ts
Normal file
60
src/app/api/v1/collection-confirm/ai/enabled/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
59
src/components/collection-confirm/ai-checks-toggle.test.tsx
Normal file
59
src/components/collection-confirm/ai-checks-toggle.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
119
src/components/collection-confirm/ai-checks-toggle.tsx
Normal file
119
src/components/collection-confirm/ai-checks-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue