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) }; }