From d78d47909144025f59f78668c0210873f03fcdb4 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sun, 12 Jul 2026 00:33:00 +0200 Subject: [PATCH] feat(assign-api): hard-gate on ready_for_collection, drop auto-flip --- .../items/[id]/assign/route.test.ts | 54 ++++++++++++++++++- .../collection-map/items/[id]/assign/route.ts | 25 +++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/app/api/v1/collection-map/items/[id]/assign/route.test.ts b/src/app/api/v1/collection-map/items/[id]/assign/route.test.ts index 6a043cc..fc63dca 100644 --- a/src/app/api/v1/collection-map/items/[id]/assign/route.test.ts +++ b/src/app/api/v1/collection-map/items/[id]/assign/route.test.ts @@ -37,7 +37,7 @@ describe("PATCH /api/v1/collection-map/items/[id]/assign", () => { }); it("writes trimmed notes to the item when ready_notes is provided", async () => { - getOneMock.mockResolvedValueOnce({ id: "item-1" }); + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: true }); updateMock.mockResolvedValueOnce({ id: "item-1" }); const res = await PATCH( @@ -115,4 +115,56 @@ describe("PATCH /api/v1/collection-map/items/[id]/assign", () => { expect(payload.item_status).toBe("collection_pool"); expect(payload.drv_status).toBe("unassigned"); }); + + it("returns 409 when assigning a driver to an unready row", async () => { + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: false }); + + const res = await PATCH( + makeReq({ driverId: "drv-1", date: "2026-07-12" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error?: string }; + expect(body.error).toMatch(/ready for collection/i); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("succeeds and does NOT auto-flip ready_for_collection when assigning a ready row", async () => { + getOneMock.mockResolvedValueOnce({ id: "item-1", ready_for_collection: true }); + updateMock.mockResolvedValueOnce({ id: "item-1" }); + + const res = await PATCH( + makeReq({ driverId: "drv-1", date: "2026-07-12" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(200); + const payload = updateMock.mock.calls[0][1] as Record; + expect(payload.drv_id).toBe("drv-1"); + expect(payload.item_status).toBe("assigned"); + expect(payload.drv_status).toBe("assigned"); + // Auto-flip is gone: patch must not include ready_for_collection=true. + expect(payload.ready_for_collection).toBeUndefined(); + }); + + it("allows unassign from an unready row (supplier gate doesn't trap pool returns)", async () => { + getOneMock.mockResolvedValueOnce({ + id: "item-1", + ready_for_collection: false, + assigned_at: "2026-07-09T00:00:00.000Z", + }); + updateMock.mockResolvedValueOnce({ id: "item-1" }); + + const res = await PATCH( + makeReq({ date: "" }), + { params: Promise.resolve({ id: "item-1" }) }, + ); + + expect(res.status).toBe(200); + const payload = updateMock.mock.calls[0][1] as Record; + expect(payload.assigned_at).toBe(""); + expect(payload.item_status).toBe("collection_pool"); + expect(payload.drv_status).toBe("unassigned"); + }); }); \ No newline at end of file diff --git a/src/app/api/v1/collection-map/items/[id]/assign/route.ts b/src/app/api/v1/collection-map/items/[id]/assign/route.ts index 621de50..5e8f88e 100644 --- a/src/app/api/v1/collection-map/items/[id]/assign/route.ts +++ b/src/app/api/v1/collection-map/items/[id]/assign/route.ts @@ -77,15 +77,6 @@ export async function PATCH( drv_id: typeof driverId === "string" ? driverId : "", assigned_at: assignedAt, }); - // Assigning a driver means the supplier has been confirmed by - // the operator — flip `ready_for_collection` to true so the - // driver app's `getMyAssignedItems` filter surfaces the row. - // Un-assigning leaves the ready flag alone (the supplier-side - // gate doesn't change just because we're swapping which driver - // gets the pickup slot). - if (isAssigning) { - patch.ready_for_collection = true; - } // Standalone ready-for-collection flip from the pool/map pages // (operator marks a row ready without assigning a driver). When // `ready_for_collection` is explicitly provided it wins over any @@ -112,6 +103,22 @@ export async function PATCH( if (!current) { return NextResponse.json({ error: `Order item ${id} not found` }, { status: 404 }); } + // Supplier-side gate: a row must be confirmed ready_for_collection + // before a driver can be assigned. Unassign always works (the + // operator must be able to return a row to the pool mid-flip). + // `ready_for_collection` is a boolean in PB; treat `false`, + // `null`, and `undefined` as not-ready. Auto-flip on assign has + // been intentionally removed — the row reaches ready via the + // mark-ready flow on /collection-pool, not via the assign path. + if (isAssigning && !current.ready_for_collection) { + return NextResponse.json( + { + error: + "Supplier hasn't confirmed this item is ready for collection. Mark it ready in /collection-pool before assigning a driver.", + }, + { status: 409 }, + ); + } const updated = await pb.collection(getOrderItemsCollectionName()).update(id, patch); return NextResponse.json({