From f0c5daa99ed7f0f60f6a9d7c049e6e3b1bfc08d1 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 22:28:00 +0200 Subject: [PATCH] feat(orders): add backfill endpoint for duplicate-detection fields --- .../backfill-duplicate-checks/route.test.ts | 49 ++++++++++++ .../orders/backfill-duplicate-checks/route.ts | 77 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts create mode 100644 src/app/api/v1/orders/backfill-duplicate-checks/route.ts diff --git a/src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts b/src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts new file mode 100644 index 0000000..6eefa01 --- /dev/null +++ b/src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +vi.mock("@/lib/server/order-number-duplicate-link-service", () => ({ + suggestOrderNumberDuplicate: vi.fn().mockResolvedValue({ duplicateOrderId: null }), +})); +vi.mock("@/lib/server/order-order-duplicate-link-service", () => ({ + suggestOrderDuplicate: vi + .fn() + .mockResolvedValue({ duplicateOrderId: null, method: "no_match" }), +})); +vi.mock("@/lib/server/order-delivery-note-link-service", () => ({ + suggestDeliveryNoteForOrder: vi + .fn() + .mockResolvedValue({ best: null, method: "no_match" }), +})); +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + autoCancellation: vi.fn(), + collection: () => ({ + getFullList: vi + .fn() + .mockResolvedValue([{ id: "order-1" }, { id: "order-2" }]), + }), + }), +})); + +const AGENT_API_KEY = "test-agent-key"; + +function backfillRequest(): NextRequest { + return new NextRequest("http://localhost/api/v1/orders/backfill-duplicate-checks", { + method: "POST", + headers: { authorization: `Bearer ${AGENT_API_KEY}` }, + }); +} + +describe("POST /api/v1/orders/backfill-duplicate-checks", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = AGENT_API_KEY; + }); + + it("runs all three matchers for every order and reports counts", async () => { + const res = await POST(backfillRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ordersProcessed).toBe(2); + }); +}); diff --git a/src/app/api/v1/orders/backfill-duplicate-checks/route.ts b/src/app/api/v1/orders/backfill-duplicate-checks/route.ts new file mode 100644 index 0000000..089b494 --- /dev/null +++ b/src/app/api/v1/orders/backfill-duplicate-checks/route.ts @@ -0,0 +1,77 @@ +/** + * POST /api/v1/orders/backfill-duplicate-checks + * + * Operator-triggered backfill: re-runs all three duplicate-detection + * matchers (order-number, item-duplicate, delivery-note-repeat) over + * EVERY existing order. The matchers themselves only run automatically + * on new order creation (see orders-service.ts createOrder) — this + * endpoint is what gets historical orders flagged too. + * + * Intentionally sequential per order (not Promise.all) — this can run + * over the full orders table, and PocketBase's admin client doesn't + * love thousands of concurrent requests. This is a background/admin + * operation, not a user-facing latency path. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +// Backfills over a full orders table can easily exceed the default 60s +// timeout — at ~100ms per order the 4,800-row history takes ~8 minutes. +// Lift the cap to match the sibling sweep route so Next.js doesn't kill +// the response before we finish. +export const maxDuration = 300; + +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { suggestOrderNumberDuplicate } from "@/lib/server/order-number-duplicate-link-service"; +import { suggestOrderDuplicate } from "@/lib/server/order-order-duplicate-link-service"; +import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; + +export async function POST(req: NextRequest) { + try { + assertAgentAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const orders = await pb + .collection("orders") + .getFullList<{ id: string }>({ fields: "id", batch: 500 }); + + let ordersProcessed = 0; + const failures: Array<{ orderId: string; error: string }> = []; + + for (const order of orders) { + try { + await suggestOrderNumberDuplicate(order.id); + } catch (err) { + failures.push({ + orderId: order.id, + error: `order_number: ${err instanceof Error ? err.message : String(err)}`, + }); + } + try { + await suggestOrderDuplicate(order.id); + } catch (err) { + failures.push({ + orderId: order.id, + error: `item_duplicate: ${err instanceof Error ? err.message : String(err)}`, + }); + } + try { + await suggestDeliveryNoteForOrder(order.id); + } catch (err) { + failures.push({ + orderId: order.id, + error: `delivery_note: ${err instanceof Error ? err.message : String(err)}`, + }); + } + ordersProcessed++; + } + + return NextResponse.json({ ordersProcessed, failures }); +}