From e66ea11845f06496cdc4d7158db7e2f03637384a Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 19 Oct 2026 14:26:10 +0200 Subject: [PATCH] feat(collection-confirm): Delete photo; drop the Run agent scan button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete photo next to "Add supplier invoice" removes the photo open in the previewer (wrong upload, or one of several for the same PO) after an inline "Delete …? Yes, delete" confirm; the confirm resets when you switch photos. Not offered for the PO PDF. - POST /api/v1/collection-confirm/delete-photo (browser session) → deleteCollectionPhoto: takes the file off every order line carrying it (or the stock purchase's photo slot) and deletes the AI's reads of it plus the checks built on them (shared forgetReads with Move photo). - Removed the header's blue "Run agent scan" button and its results panel (not in use); the /api/v1/agent/scan route stays for the agent. Co-Authored-By: Claude Opus 5.5 --- docs/collection-ai-checks.md | 7 + .../(app)/collection-confirm/page.test.tsx | 75 +----- src/app/(app)/collection-confirm/page.tsx | 219 ++---------------- .../delete-photo/route.test.ts | 62 +++++ .../collection-confirm/delete-photo/route.ts | 36 +++ .../collection-confirm/photo-strip.tsx | 6 + .../collection-confirm/po-card-ai.test.tsx | 33 +++ src/components/collection-confirm/po-card.tsx | 68 +++++- .../collection-photo-move-service.test.ts | 44 +++- .../server/collection-photo-move-service.ts | 79 ++++++- 10 files changed, 351 insertions(+), 278 deletions(-) create mode 100644 src/app/api/v1/collection-confirm/delete-photo/route.test.ts create mode 100644 src/app/api/v1/collection-confirm/delete-photo/route.ts diff --git a/docs/collection-ai-checks.md b/docs/collection-ai-checks.md index 1153468..0a25db7 100644 --- a/docs/collection-ai-checks.md +++ b/docs/collection-ai-checks.md @@ -106,6 +106,13 @@ and deletes the AI's read of it so it is read again where it now belongs (`POST /api/v1/collection-confirm/move-photo`, `src/lib/server/collection-photo-move-service.ts`). +Delete photo (added 2026-10-19): next to "Add supplier invoice", **Delete +photo** removes the photo open in the previewer — for a wrong upload or a +duplicate — after an inline "Delete …? Yes, delete" confirm. It takes the +file off every order line (or the stock purchase) carrying it and deletes +the AI's read of it. The PO PDF can't be deleted there. This can't be +undone (`POST /api/v1/collection-confirm/delete-photo`). + ## Reading the ticks | Colour | Meaning | diff --git a/src/app/(app)/collection-confirm/page.test.tsx b/src/app/(app)/collection-confirm/page.test.tsx index 417da4f..3a96cf4 100644 --- a/src/app/(app)/collection-confirm/page.test.tsx +++ b/src/app/(app)/collection-confirm/page.test.tsx @@ -1636,72 +1636,15 @@ describe("CollectionConfirmPage", () => { ).not.toBeInTheDocument(); }); - it("Run agent scan button calls /api/v1/agent/scan and surfaces the triage result", async () => { - // Mock the page's initial collection-confirm GET to return - // some rows so the page renders. The agent-scan REST proxy is - // the second fetch call. - vi.mocked(window.fetch).mockImplementation( - async (input: RequestInfo | URL) => { - const url = fetchUrl(input); - if (url.startsWith("/api/v1/collection-confirm")) { - return jsonResponse({ items: baseRows }); - } - if (url.startsWith("/api/v1/agent/scan")) { - return jsonResponse({ - total_needing_attention: 3, - by_kind: { stock: 2, customer: 1 }, - by_status: { sent: 3 }, - pos: [ - { - id: "sp-1", - kind: "stock", - po_number: "PO26081401STK", - status: "sent", - attachment_filenames: [], - diagnostics: { - line_count: 5, - lines_missing_sku: 1, - lines_missing_cost: 2, - lines_missing_supplier_sku: 0, - }, - }, - ], - }); - } - return jsonResponse({}); - }, - ); - const user = userEvent.setup(); + it("has no Run agent scan button (removed 2026-10-19)", async () => { + vi.mocked(window.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = fetchUrl(input); + if (url.startsWith("/api/v1/collection-confirm")) return jsonResponse({ items: baseRows }); + return jsonResponse({}); + }); render(); - await waitFor(() => - expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), - ); - - const button = screen.getByTestId("collection-confirm-run-agent-scan"); - expect(button).toBeInTheDocument(); - await user.click(button); - - // The result panel renders with the triage summary. - await waitFor(() => - expect( - screen.getByTestId("collection-confirm-agent-scan-result"), - ).toBeInTheDocument(), - ); - expect( - screen.getByTestId("collection-confirm-agent-scan-result").textContent, - ).toMatch(/3 PO\(s\) need attention/); - // The sample row surfaces in the panel. - const sampleRow = screen.getByTestId( - "collection-confirm-agent-scan-sample-row", - ); - expect(sampleRow.textContent).toMatch(/PO26081401STK/); - - // Dismiss button closes the panel. - await user.click( - screen.getByTestId("collection-confirm-agent-scan-dismiss"), - ); - expect( - screen.queryByTestId("collection-confirm-agent-scan-result"), - ).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument()); + expect(screen.queryByTestId("collection-confirm-run-agent-scan")).not.toBeInTheDocument(); + expect(screen.queryByText(/run agent scan/i)).not.toBeInTheDocument(); }); }); diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 95bcea3..e78a047 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Bot, Loader2, Search, Truck, UserRound } from "lucide-react"; +import { Search, Truck, UserRound } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -87,35 +87,6 @@ export default function CollectionConfirmPage() { rowIds: string[]; } | null>(null); const [busy, setBusy] = useState(false); - /** - * Result of the most recent `purchase_orders.scan` MCP call - * triggered by the "Run agent scan" button. Surfaced inline at - * the top of the page so the operator (and the agent, if driving - * the page via the in-browser MCP transport) can see the triage - * state without leaving /collection-confirm. - * - * `null` means "haven't run the scan yet" or "last scan failed". - */ - const [agentScanResult, setAgentScanResult] = useState<{ - total: number; - by_kind: { stock: number; customer: number }; - by_status: Record; - sample: Array<{ - id: string; - kind: string; - po_number: string; - status: string; - has_invoice_attachment: boolean; - diagnostics: { - line_count: number; - lines_missing_sku: number; - lines_missing_cost: number; - lines_missing_supplier_sku: number; - }; - }>; - ran_at: string; - } | null>(null); - const [agentScanBusy, setAgentScanBusy] = useState(false); /** * Floating-viewer state for "Pop out" (draggable, defaults) and * "Fullscreen" (full-viewport lightbox). The page renders a @@ -158,77 +129,6 @@ export default function CollectionConfirmPage() { return () => window.clearTimeout(handle); }, [load]); - /** - * Trigger the `po-agent`'s triage scan via the server-side - * proxy at `/api/v1/agent/scan`. The proxy calls the same - * `scanPurchaseOrders` service the MCP `purchase_orders.scan` - * tool wraps, so the operator sees the same triage the agent - * would see — without needing the AGENT_API_KEY bearer (which is - * server-only). - * - * Result is rendered inline at the top of the page so the - * operator can see the same triage the agent would compute — - * total + by-kind + by-status + a 5-row sample of the POs most - * needing attention. - * - * We don't go through the agent persona's `purchase_orders.diff` - * step here — that's a per-PO action; this button is just the - * list-side triage. - */ - const runAgentScan = useCallback(async () => { - setAgentScanBusy(true); - try { - const response = await fetch("/api/v1/agent/scan?kind=both", { - headers: { "content-type": "application/json" }, - }); - if (!response.ok) { - throw new Error(`Agent scan failed (HTTP ${response.status})`); - } - const parsed = (await response.json()) as { - total_needing_attention: number; - by_kind: { stock: number; customer: number }; - by_status: Record; - pos: Array<{ - id: string; - kind: string; - po_number: string; - status: string; - attachment_filenames: string[]; - diagnostics: { - line_count: number; - lines_missing_sku: number; - lines_missing_cost: number; - lines_missing_supplier_sku: number; - }; - }>; - }; - setAgentScanResult({ - total: parsed.total_needing_attention, - by_kind: parsed.by_kind, - by_status: parsed.by_status, - sample: parsed.pos.slice(0, 5).map((p) => ({ - id: p.id, - kind: p.kind, - po_number: p.po_number, - status: p.status, - has_invoice_attachment: p.attachment_filenames.length > 0, - diagnostics: p.diagnostics, - })), - ran_at: new Date().toISOString(), - }); - toast.success( - parsed.total_needing_attention === 0 - ? "Agent scan: nothing to process" - : `Agent scan: ${parsed.total_needing_attention} PO(s) need attention`, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - toast.error(message); - } finally { - setAgentScanBusy(false); - } - }, []); - useEffect(() => { if (typeof window === "undefined") return; const email = getClientIdentity(); @@ -514,6 +414,26 @@ export default function CollectionConfirmPage() { [addRowsToPos, load], ); + /** Delete a wrongly uploaded or duplicate photo, then reload. */ + const handleDeletePhoto = useCallback( + async (target: { collection: string; ids: string[]; filename: string }) => { + try { + const response = await fetch("/api/v1/collection-confirm/delete-photo", { + method: "POST", + headers: authedJsonHeaders(), + body: JSON.stringify(target), + }); + const data = (await response.json().catch(() => ({}))) as { error?: string }; + if (!response.ok) throw new Error(data.error || `Deleting the photo failed (HTTP ${response.status})`); + toast.success(`Deleted ${target.filename}`); + await load({ silent: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [authedJsonHeaders, load], + ); + /** Move a driver photo onto the PO its invoice names, then reload. */ const handleMovePhoto = useCallback( async (source: { collection: string; id: string; filename: string }, toPoNumber: string) => { @@ -844,25 +764,6 @@ export default function CollectionConfirmPage() { > Refresh - @@ -886,83 +787,6 @@ export default function CollectionConfirmPage() { )} - {/* Agent scan results — surfaced inline at the top of the page - when the operator (or the agent via the in-browser MCP - transport) clicks "Run agent scan". Shows the same triage - state the `po-agent` would compute — total + by-kind + - by-status + a 5-row sample of the POs most needing - attention. Closes itself when the operator dismisses it; - auto-closes when `load()` finishes (so the panel doesn't - stay stale after a refresh). */} - {agentScanResult ? ( -
-
-
-
- - Agent scan — {new Date(agentScanResult.ran_at).toLocaleTimeString()} -
-

- {agentScanResult.total} PO(s) need attention - {Object.keys(agentScanResult.by_status).length > 0 ? ( - <> - {" "}— by status:{" "} - {Object.entries(agentScanResult.by_status) - .map(([s, n]) => `${s}: ${n}`) - .join(", ")} - ; by kind: stock {agentScanResult.by_kind.stock}, customer{" "} - {agentScanResult.by_kind.customer} - - ) : null} -

- {agentScanResult.sample.length > 0 ? ( -
    - {agentScanResult.sample.map((p) => ( -
  • - {p.po_number} - - {p.kind} - - - {p.has_invoice_attachment ? "invoice ✓" : "no invoice"} - - - ·{" "} - {p.diagnostics.lines_missing_sku + - p.diagnostics.lines_missing_cost + - p.diagnostics.lines_missing_supplier_sku}{" "} - line(s) need fill - -
  • - ))} -
- ) : null} -
- -
-
- ) : null} - {rows !== null && groupedRows.length > 0 && (
@@ -1009,6 +833,7 @@ export default function CollectionConfirmPage() { onAddToPos={(rowId) => void handleAddToPos([rowId])} onCheckWithAi={() => handleCheckWithAi(group.poNumber)} onMovePhoto={handleMovePhoto} + onDeletePhoto={handleDeletePhoto} onConfirmAndAddToPos={() => setConfirmState({ poNumber: group.poNumber, diff --git a/src/app/api/v1/collection-confirm/delete-photo/route.test.ts b/src/app/api/v1/collection-confirm/delete-photo/route.test.ts new file mode 100644 index 0000000..328b9ee --- /dev/null +++ b/src/app/api/v1/collection-confirm/delete-photo/route.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const deleteCollectionPhoto = vi.fn(); +vi.mock("@/lib/server/collection-photo-move-service", async () => { + class MovePhotoError extends Error { + constructor(message: string, public status: number) { + super(message); + } + } + return { deleteCollectionPhoto: (...a: unknown[]) => deleteCollectionPhoto(...a), MovePhotoError }; +}); +const getBrowserSessionUser = vi.fn(); +vi.mock("@/lib/server/browser-session", () => ({ + getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a), +})); + +import { POST } from "./route"; +import { MovePhotoError } from "@/lib/server/collection-photo-move-service"; + +const body = { collection: "order_items", ids: ["oi-1"], filename: "inv.jpg" }; +const req = (b: unknown) => + new NextRequest("http://localhost/api/v1/collection-confirm/delete-photo", { + method: "POST", + headers: { authorization: "Bearer tok", "content-type": "application/json" }, + body: JSON.stringify(b), + }); + +beforeEach(() => { + vi.clearAllMocks(); + getBrowserSessionUser.mockResolvedValue({ id: "u1", name: "Sam" }); +}); + +describe("POST /api/v1/collection-confirm/delete-photo", () => { + it("401s without a session", async () => { + getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 })); + expect((await POST(req(body))).status).toBe(401); + expect(deleteCollectionPhoto).not.toHaveBeenCalled(); + }); + + it.each([[{ ...body, ids: [] }], [{ ...body, filename: "" }], [{ ...body, ids: "oi-1" }], [{}]])( + "400s for a bad body %#", + async (b) => { + expect((await POST(req(b))).status).toBe(400); + expect(deleteCollectionPhoto).not.toHaveBeenCalled(); + }, + ); + + it("deletes the photo", async () => { + deleteCollectionPhoto.mockResolvedValue({ removed_from: ["oi-1"] }); + const res = await POST(req(body)); + expect(deleteCollectionPhoto).toHaveBeenCalledWith(body); + expect(await res.json()).toEqual({ removed_from: ["oi-1"] }); + }); + + it("passes the service's status through", async () => { + deleteCollectionPhoto.mockRejectedValue(new MovePhotoError("not the PO itself", 400)); + const res = await POST(req(body)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "not the PO itself" }); + }); +}); diff --git a/src/app/api/v1/collection-confirm/delete-photo/route.ts b/src/app/api/v1/collection-confirm/delete-photo/route.ts new file mode 100644 index 0000000..b11fd93 --- /dev/null +++ b/src/app/api/v1/collection-confirm/delete-photo/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { deleteCollectionPhoto, MovePhotoError } from "@/lib/server/collection-photo-move-service"; + +/** + * POST /api/v1/collection-confirm/delete-photo + * + * "Delete photo" on a Collection Confirm card: removes a wrongly uploaded + * or duplicate photo (and the AI's read of it). Body: + * `{ collection: "order_items" | "stock_purchases", ids: string[], filename }`. + * The PO PDF can't be deleted here. + * + * Auth: browser session (PB token forwarded in `Authorization`). + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const str = (v: unknown) => (typeof v === "string" ? v.trim() : ""); + +export async function POST(req: NextRequest) { + try { + await getBrowserSessionUser(req); + const body = (await req.json().catch(() => ({}))) as { collection?: unknown; ids?: unknown; filename?: unknown }; + const collection = str(body.collection); + const filename = str(body.filename); + const ids = Array.isArray(body.ids) ? body.ids.map(str).filter(Boolean) : []; + if (!collection || !filename || ids.length === 0) { + return NextResponse.json({ error: "collection, ids and filename are required" }, { status: 400 }); + } + return NextResponse.json(await deleteCollectionPhoto({ collection, ids, filename })); + } catch (err) { + const message = err instanceof Error ? err.message : "Deleting the photo failed"; + const status = err instanceof MovePhotoError ? err.status : (err as { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/components/collection-confirm/photo-strip.tsx b/src/components/collection-confirm/photo-strip.tsx index f72a35e..5c01c39 100644 --- a/src/components/collection-confirm/photo-strip.tsx +++ b/src/components/collection-confirm/photo-strip.tsx @@ -14,6 +14,12 @@ export type Attachment = { * uploaded the same filename to multiple rows. */ itemId: string; + /** + * What "Delete photo" removes: the order lines (or the stock purchase) + * carrying this file. Absent for files that can't be deleted from + * Collection Confirm (the PO PDF). + */ + deleteTarget?: { collection: "order_items" | "stock_purchases"; ids: string[]; filename: string }; }; type PhotoStripProps = { diff --git a/src/components/collection-confirm/po-card-ai.test.tsx b/src/components/collection-confirm/po-card-ai.test.tsx index b0f176a..894ec5a 100644 --- a/src/components/collection-confirm/po-card-ai.test.tsx +++ b/src/components/collection-confirm/po-card-ai.test.tsx @@ -119,6 +119,39 @@ describe("PoCard AI layer", () => { expect(box).toHaveTextContent("Year not printed on the document date"); }); + describe("Delete photo", () => { + const withPhotos = makeAiRow({ order_item_attachements: ["one.jpg", "two.jpg"] }); + + it("deletes the photo open in the previewer after you confirm", async () => { + const onDeletePhoto = vi.fn(async () => {}); + render(); + await userEvent.click(screen.getByRole("button", { name: /two\.jpg 2 of 2/ })); + await userEvent.click(screen.getByTestId("po-card-delete-photo-PO-1")); + expect(onDeletePhoto).not.toHaveBeenCalled(); + expect(screen.getByTestId("po-card-delete-confirm-PO-1")).toHaveTextContent("Delete two.jpg?"); + await userEvent.click(screen.getByRole("button", { name: "Yes, delete" })); + expect(onDeletePhoto).toHaveBeenCalledWith({ collection: "order_items", ids: [withPhotos.id], filename: "two.jpg" }); + }); + + it("can be cancelled", async () => { + const onDeletePhoto = vi.fn(async () => {}); + render(); + await userEvent.click(screen.getByTestId("po-card-delete-photo-PO-1")); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByTestId("po-card-delete-confirm-PO-1")).not.toBeInTheDocument(); + expect(onDeletePhoto).not.toHaveBeenCalled(); + }); + + it("isn't offered for the PO PDF", () => { + const poOnly = makeAiRow({ + order_item_attachements: [], + expand: { purchase_order: { id: "po-1", documents: ["po.pdf"] } }, + } as never); + render(); + expect(screen.queryByTestId("po-card-delete-photo-PO-1")).not.toBeInTheDocument(); + }); + }); + describe("photo on the wrong PO", () => { const wrongDoc = { read_id: "r", diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 5e8fa4e..16f2d3f 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react"; +import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Splitter } from "@/components/ui/splitter"; @@ -141,6 +141,8 @@ type Props = { onCheckWithAi?: () => Promise; /** Confirm for invoicing, then add every not-in-POS customer line to the POS. */ onConfirmAndAddToPos?: () => void; + /** Delete a wrongly uploaded or duplicate photo (the one open in the previewer). */ + onDeletePhoto?: (target: NonNullable) => Promise; /** Move a driver photo whose invoice names another PO onto that PO. */ onMovePhoto?: (source: { collection: string; id: string; filename: string }, toPoNumber: string) => Promise; }; @@ -202,6 +204,7 @@ export function PoCard({ onCheckWithAi, onConfirmAndAddToPos, onMovePhoto, + onDeletePhoto, }: Props) { const itemCount = rows.length; const [checkingAi, setCheckingAi] = useState(false); @@ -225,6 +228,8 @@ export function PoCard({ return out; }, [rows]); const [movingPhoto, setMovingPhoto] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deletingPhoto, setDeletingPhoto] = useState(false); // Every line the AI read from the chip's document; lines paired with a row // on this card are marked "matched". Falls back to the paired lines alone // when the document's full line list is unavailable. @@ -381,10 +386,16 @@ export function PoCard({ : purchaseOrderId ? `purchase_orders/${encodeURIComponent(purchaseOrderId)}` : `order_items/${encodeURIComponent(itemId)}`; + const deleteTarget: Attachment["deleteTarget"] = stockPurchaseId + ? { collection: "stock_purchases", ids: [stockPurchaseId], filename } + : purchaseOrderId + ? undefined + : { collection: "order_items", ids: itemIds, filename }; return { filename, itemId, url: `/api/v1/files/${collectionPath}/${encodeURIComponent(filename)}`, + ...(deleteTarget ? { deleteTarget } : {}), } satisfies Attachment; }), [rows], @@ -399,6 +410,9 @@ export function PoCard({ const [activeAttachment, setActiveAttachment] = useState( () => attachments[0] ?? null, ); + // A pending "Delete …?" belongs to the photo it was asked about. + const activeUrl = activeAttachment?.url; + useEffect(() => setConfirmDelete(false), [activeUrl]); // If the row set changes (refresh, PO switch) and the active // attachment is no longer present, fall back to the first one. @@ -760,8 +774,54 @@ export function PoCard({
) : null} - {attachments.length > 0 && onInvoiceUpload ? ( -
+ {attachments.length > 0 && (onInvoiceUpload || onDeletePhoto) ? ( +
+ {onDeletePhoto && activeAttachment?.deleteTarget ? ( + confirmDelete ? ( + + Delete {activeAttachment.filename}? This can't be undone. + + + + ) : ( + + ) + ) : null} + {onInvoiceUpload ? ( + <> : } {uploading ? "Uploading" : hasCustomerRows ? "Add supplier invoice" : "Add another invoice"} + + ) : null}
) : null} diff --git a/src/lib/server/collection-photo-move-service.test.ts b/src/lib/server/collection-photo-move-service.test.ts index d52b90c..6c8f3b6 100644 --- a/src/lib/server/collection-photo-move-service.test.ts +++ b/src/lib/server/collection-photo-move-service.test.ts @@ -50,7 +50,7 @@ const pb = { }; vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb })); -import { movePhotoToPo, MovePhotoError } from "./collection-photo-move-service"; +import { deleteCollectionPhoto, movePhotoToPo, MovePhotoError } from "./collection-photo-move-service"; const fetchFile = vi.fn(async () => ({ bytes: Buffer.from("jpeg-bytes"), contentType: "image/jpeg" })); const SOURCE = { collection: "order_items", id: "oi-thin", filename: "inv_abc.jpg" }; @@ -111,3 +111,45 @@ describe("movePhotoToPo", () => { expect(writes).toEqual([]); }); }); + +describe("deleteCollectionPhoto", () => { + it("removes the photo from every line that carries it and forgets the AI's read", async () => { + table("order_items").set("oi-dup", { + id: "oi-dup", + purchase_order: "po-bly", + item_status: "collected", + order_item_attachements: ["inv_abc.jpg"], + ai_check: { read_id: "r1" }, + }); + + await deleteCollectionPhoto({ collection: "order_items", ids: ["oi-thin", "oi-dup"], filename: "inv_abc.jpg" }); + + expect(table("order_items").get("oi-thin")?.order_item_attachements).toEqual(["other.jpg"]); + expect(table("order_items").get("oi-dup")?.order_item_attachements).toEqual([]); + expect(deletes).toEqual([{ coll: "collection_doc_reads", id: "r1" }]); + expect(table("order_items").get("oi-thin")?.ai_check).toBeNull(); + expect(table("order_items").get("oi-dup")?.ai_check).toBeNull(); + }); + + it("clears a stock purchase's single photo slot", async () => { + table("stock_purchases").get("sp-1")!.additional_attachments = "dn.jpg"; + table("collection_doc_reads").set("r3", { id: "r3", source_collection: "stock_purchases", source_id: "sp-1", filename: "dn.jpg" }); + table("stock_purchase_items").get("spi-1")!.ai_check = { read_id: "r3" }; + + await deleteCollectionPhoto({ collection: "stock_purchases", ids: ["sp-1"], filename: "dn.jpg" }); + + expect(table("stock_purchases").get("sp-1")?.additional_attachments).toBeNull(); + expect(deletes).toEqual([{ coll: "collection_doc_reads", id: "r3" }]); + expect(table("stock_purchase_items").get("spi-1")?.ai_check).toBeNull(); + }); + + it("refuses the PO PDF and photos that aren't there", async () => { + await expect( + deleteCollectionPhoto({ collection: "purchase_orders", ids: ["po-bly"], filename: "po.pdf" }), + ).rejects.toMatchObject({ status: 400 }); + await expect( + deleteCollectionPhoto({ collection: "order_items", ids: ["oi-thin"], filename: "nope.jpg" }), + ).rejects.toMatchObject({ status: 404 }); + expect(writes).toEqual([]); + }); +}); diff --git a/src/lib/server/collection-photo-move-service.ts b/src/lib/server/collection-photo-move-service.ts index bb0eecd..1c0be60 100644 --- a/src/lib/server/collection-photo-move-service.ts +++ b/src/lib/server/collection-photo-move-service.ts @@ -139,21 +139,78 @@ export async function movePhotoToPo( } // 3. Forget the AI's read of it where it was, and the checks built on it. + await forgetReads(pb, source, srcPoId); + + return { kind: hit.kind, po_number: hit.po_number, target_id: targetId }; +} + +type AdminPb = Awaited>; + +/** + * Delete the AI's reads of one photo and clear the checks on the PO's + * lines that were built on them, so nothing points at a photo that has + * moved or gone. `poId` is the customer PO (order-line source) or the + * stock purchase itself. + */ +async function forgetReads(pb: AdminPb, source: PhotoSource, poId: string): Promise { const reads = await pb.collection(READS).getFullList({ filter: `source_collection = "${q(source.collection)}" && source_id = "${q(source.id)}" && filename = "${q(source.filename)}"`, }); const readIds = new Set(reads.map((r) => r.id)); + if (readIds.size === 0) return; for (const id of readIds) await pb.collection(READS).delete(id); - if (readIds.size > 0) { - const srcLines = - source.collection === STOCK_POS - ? { coll: STOCK_LINES, items: await pb.collection(STOCK_LINES).getFullList({ filter: `purchase = "${q(source.id)}"` }) } - : { coll: orderItems, items: await pb.collection(orderItems).getFullList({ filter: `purchase_order = "${q(srcPoId)}"` }) }; - for (const l of srcLines.items) { - const readId = (l.ai_check as { read_id?: string } | null)?.read_id; - if (readId && readIds.has(readId)) await pb.collection(srcLines.coll).update(l.id, { ai_check: null }); - } + const lines = + source.collection === STOCK_POS + ? { coll: STOCK_LINES, items: await pb.collection(STOCK_LINES).getFullList({ filter: `purchase = "${q(poId)}"` }) } + : { + coll: source.collection, + items: poId ? await pb.collection(source.collection).getFullList({ filter: `purchase_order = "${q(poId)}"` }) : [], + }; + for (const l of lines.items) { + const readId = (l.ai_check as { read_id?: string } | null)?.read_id; + if (readId && readIds.has(readId)) await pb.collection(lines.coll).update(l.id, { ai_check: null }); } - - return { kind: hit.kind, po_number: hit.po_number, target_id: targetId }; +} + +/** + * Delete a photo a driver or buyer uploaded by mistake (or a duplicate) + * from Collection Confirm: off every record that carries it, plus the + * AI's reads of it. Only driver/buyer uploads — order-line + * `order_item_attachements` and a stock purchase's `additional_attachments`; + * the PO PDF on `purchase_orders` can't be deleted here. PB removes the + * file itself; this can't be undone. + */ +export async function deleteCollectionPhoto(args: { + collection: string; + ids: string[]; + filename: string; +}): Promise<{ removed_from: string[] }> { + const orderItems = getOrderItemsCollectionName(); + const collection = args.collection === "order_items" ? orderItems : args.collection; + if (collection !== orderItems && collection !== STOCK_POS) { + throw new MovePhotoError("Only driver and invoice photos can be deleted here, not the PO itself", 400); + } + const filename = args.filename.trim(); + const ids = [...new Set(args.ids.map((i) => i.trim()).filter(Boolean))]; + if (!filename || ids.length === 0) throw new MovePhotoError("ids and filename are required", 400); + + const pb = await getAdminPocketBase(); + const field = collection === STOCK_POS ? "additional_attachments" : "order_item_attachements"; + const holders: Rec[] = []; + for (const id of ids) { + const rec = (await pb.collection(collection).getOne(id).catch(() => null)) as Rec | null; + if (rec && fileList(rec[field]).includes(filename)) holders.push(rec); + } + if (holders.length === 0) throw new MovePhotoError("That photo isn't there any more", 404); + + for (const rec of holders) { + if (collection === STOCK_POS) { + await pb.collection(STOCK_POS).update(rec.id, { additional_attachments: null }); + } else { + await pb.collection(collection).update(rec.id, { "order_item_attachements-": [filename] }); + } + const poId = collection === STOCK_POS ? rec.id : String(rec.purchase_order ?? ""); + await forgetReads(pb, { collection, id: rec.id, filename }, poId); + } + return { removed_from: holders.map((r) => r.id) }; }