feat(orders): add since=YYYY-MM-DD to backfill endpoint for scoped re-scans

Lets the operator scope a backfill to recent orders so off-peak
sanity passes don't disturb the active workflow. e.g.:

  POST /api/v1/orders/backfill-duplicate-checks?limit=200&since=2026-08-24

rescans the last 7 days up to 200 rows. Combines with limit/skip;
rejects malformed dates with 400 instead of silently no-op'ing.
This commit is contained in:
Dave 2026-08-31 11:34:46 +02:00
parent b4855a9c9f
commit 8ae4592108
2 changed files with 58 additions and 0 deletions

View file

@ -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<ReturnType<typeof getAdminPocketBase>>);
const res = await POST(backfillRequest("?limit=500&since=2026-08-24"));
expect(res.status).toBe(200);
// The since param translates to a PB `created >= "<date> 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());

View file

@ -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;