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 index c2e282e..ce1163e 100644 --- a/src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts +++ b/src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts @@ -120,6 +120,37 @@ describe("POST /api/v1/orders/backfill-duplicate-checks", () => { expect((await res.json()).nextSkip).toBe(9); }); + it("applies a since filter to scope the backfill to recent orders", async () => { + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + const getListSpy = vi.fn().mockResolvedValue({ + items: [{ id: "recent-1" }], + totalItems: 1, + page: 1, + perPage: 500, + }); + vi.mocked(getAdminPocketBase).mockResolvedValueOnce({ + autoCancellation: vi.fn(), + collection: () => ({ getList: getListSpy }), + } as unknown as Awaited>); + + const res = await POST(backfillRequest("?limit=500&since=2026-08-24")); + expect(res.status).toBe(200); + // The since param translates to a PB `created >= " 00:00:00.000Z"` + // filter so the backfill only re-scans orders from that day forward. + expect(getListSpy).toHaveBeenCalledWith( + 1, + 500, + expect.objectContaining({ filter: 'created >= "2026-08-24 00:00:00.000Z"' }), + ); + }); + + it("returns 400 when since is malformed", async () => { + const res = await POST(backfillRequest("?since=not-a-date")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/since must be YYYY-MM-DD/); + }); + it("returns 401 when AGENT_API_KEY is missing", async () => { delete process.env.AGENT_API_KEY; const res = await POST(backfillRequest()); diff --git a/src/app/api/v1/orders/backfill-duplicate-checks/route.ts b/src/app/api/v1/orders/backfill-duplicate-checks/route.ts index 0ea8abd..7df4b8f 100644 --- a/src/app/api/v1/orders/backfill-duplicate-checks/route.ts +++ b/src/app/api/v1/orders/backfill-duplicate-checks/route.ts @@ -24,6 +24,13 @@ * POST /api/v1/orders/backfill-duplicate-checks?limit=500&skip=$skip * done * + * Scoped backfill (recent orders only): pass `?since=YYYY-MM-DD` + * (ISO date) to filter to orders created on or after that date. + * Combines with `limit`/`skip` — e.g. + * `?since=2026-08-24&limit=200` re-scans the last 7 days of orders + * (or however far back `since` reaches) up to 200 rows. Useful for + * off-peak sanity passes without disturbing the operator workflow. + * * Why not a single call? At ~25 orders/min on a busy PB instance the * matcher is N+1 on candidates — a 5,000-order table is ~3 hours of * sequential work. Chunking keeps each response under Next.js's @@ -77,6 +84,7 @@ export async function POST(req: NextRequest) { const { searchParams } = new URL(req.url); const limitRaw = Number(searchParams.get("limit")); const skipRaw = Number(searchParams.get("skip")); + const sinceRaw = searchParams.get("since"); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : DEFAULT_LIMIT; const skip = Number.isFinite(skipRaw) && skipRaw >= 0 ? Math.floor(skipRaw) : 0; if (limit > MAX_LIMIT) { @@ -86,6 +94,21 @@ export async function POST(req: NextRequest) { ); } + // `since` accepts an ISO date (YYYY-MM-DD). PB's date filter expects + // the same wire format the matcher writes — `YYYY-MM-DD HH:MM:SS.sssZ`, + // so we anchor `since` at the start of that day in UTC. We reject + // malformed dates with a 400 rather than silently no-op'ing. + let sinceFilter: string | null = null; + if (sinceRaw) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(sinceRaw)) { + return NextResponse.json( + { error: `since must be YYYY-MM-DD, got "${sinceRaw}"` }, + { status: 400 }, + ); + } + sinceFilter = `created >= "${sinceRaw} 00:00:00.000Z"`; + } + const pb = await getAdminPocketBase(); pb.autoCancellation(false); @@ -102,9 +125,13 @@ export async function POST(req: NextRequest) { { fields: "id", sort: "created", + ...(sinceFilter ? { filter: sinceFilter } : {}), }, ); const slice = orders.items; + // `hasMore` accounts for the date filter: PB's `totalItems` is the + // count of rows matching the filter, not the full collection size. + // So `nextSkip + slice.length < filteredTotal` is the right bound. const hasMore = skip + slice.length < orders.totalItems; let ordersProcessed = 0;