From 8acb6dcf95e33cd0f85bfd09c8436560888e39dc Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 23:11:27 +0200 Subject: [PATCH] fix(stock-quick-draft): dedicated /drafts endpoint with nested shape, browser-auth Co-authored-by: Orca --- .../api/v1/stock-quick-draft/drafts/route.ts | 42 ++++++ .../stock-orders/quick-draft-modal.test.tsx | 2 +- .../stock-orders/quick-draft-modal.tsx | 2 +- .../server/stock-quick-draft-service.test.ts | 126 ++++++++++++++++++ src/lib/server/stock-quick-draft-service.ts | 126 ++++++++++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/stock-quick-draft/drafts/route.ts diff --git a/src/app/api/v1/stock-quick-draft/drafts/route.ts b/src/app/api/v1/stock-quick-draft/drafts/route.ts new file mode 100644 index 0000000..bb55910 --- /dev/null +++ b/src/app/api/v1/stock-quick-draft/drafts/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listOpenDrafts } from "@/lib/server/stock-quick-draft-service"; + +/** + * GET /api/v1/stock-quick-draft/drafts + * + * Lists every draft `stock_purchase` with the supplier name expanded + * and the child `stock_purchase_items` rows denormalised onto each + * row. The Quick Draft modal's cards region calls this on open, after + * every "Add & next" success, and after every "Remove" success. + * + * Why a dedicated endpoint instead of `GET /api/v1/stock-purchases`: + * - the agent-API route uses bearer auth (AGENT_API_KEY) that the + * browser doesn't carry, so the modal always 401'd against it. + * - the agent-API shape is raw PB records; the modal expects the + * supplier name pre-expanded and the lines nested under each row. + * Keeping a dedicated route lets the cards UI avoid a second + * lookup per row. + * + * Response: { ok: true, items: DraftPurchaseSummary[] } + * Empty `items` when no drafts exist. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const pb = await getAdminPocketBase(); + const drafts = await listOpenDrafts(pb as never); + return NextResponse.json({ ok: true, items: drafts }); + } catch (err) { + return NextResponse.json( + { + ok: false, + reason: "list_failed", + message: err instanceof Error ? err.message : "Failed to list drafts", + }, + { status: 500 }, + ); + } +} diff --git a/src/components/stock-orders/quick-draft-modal.test.tsx b/src/components/stock-orders/quick-draft-modal.test.tsx index 1c278ee..81c50b6 100644 --- a/src/components/stock-orders/quick-draft-modal.test.tsx +++ b/src/components/stock-orders/quick-draft-modal.test.tsx @@ -391,7 +391,7 @@ describe("QuickDraftModal — cards region", () => { it("fetches and renders draft cards on open", async () => { render( {}} />); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining("/stock-purchases"), + expect.stringContaining("/stock-quick-draft/drafts"), expect.anything(), ); await act(async () => { diff --git a/src/components/stock-orders/quick-draft-modal.tsx b/src/components/stock-orders/quick-draft-modal.tsx index f3498a8..afb09a7 100644 --- a/src/components/stock-orders/quick-draft-modal.tsx +++ b/src/components/stock-orders/quick-draft-modal.tsx @@ -96,7 +96,7 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { const fetchCards = useCallback(async () => { setCardsLoading(true); try { - const res = await fetch("/api/v1/stock-purchases?status=draft", {}); + const res = await fetch("/api/v1/stock-quick-draft/drafts", {}); const data = (await res.json()) as { ok: boolean; items?: DraftSummary[]; diff --git a/src/lib/server/stock-quick-draft-service.test.ts b/src/lib/server/stock-quick-draft-service.test.ts index 318a4c1..57cd259 100644 --- a/src/lib/server/stock-quick-draft-service.test.ts +++ b/src/lib/server/stock-quick-draft-service.test.ts @@ -162,6 +162,7 @@ vi.mock("./pocketbase-admin", () => ({ import { addQuickDraftLine, getStockItemSupplierHistory, + listOpenDrafts, removeQuickDraftLine, searchStockItemsForQuickDraft, } from "./stock-quick-draft-service"; @@ -619,3 +620,128 @@ describe("removeQuickDraftLine", () => { expect(result.reason).toBe("line_not_found"); }); }); + +describe("listOpenDrafts", () => { + it("returns an empty array when there are no draft stock_purchases", async () => { + const drafts = await listOpenDrafts(pbMock as never); + expect(drafts).toEqual([]); + }); + + it("returns draft purchases with supplier name + lines denormalised, sorted by created desc", async () => { + const supplierA = ( + await pbMock.collection("suppliers").create({ name: "Steel & Pipe Co" }) + ).id; + const supplierB = ( + await pbMock.collection("suppliers").create({ name: "Hardware Hub" }) + ).id; + + // First (older) draft. + const purchaseA = await pbMock.collection("stock_purchases").create({ + supplier: supplierA, + status: "draft", + purchase_number: "PO26072801STK", + created: "2026-07-28T08:00:00.000Z", + }); + // Second (newer) draft. + const purchaseB = await pbMock.collection("stock_purchases").create({ + supplier: supplierB, + status: "draft", + purchase_number: "PO26073001STK", + created: "2026-07-30T09:00:00.000Z", + }); + + // Two lines on purchaseA, one line on purchaseB. + await pbMock.collection("stock_purchase_items").create({ + purchase: purchaseA.id, + description: "115mm Cutting Disc", + quantity: 50, + cost_price: 18.5, + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchaseA.id, + description: "Wire rope 6mm", + quantity: 20, + cost_price: 145, + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchaseB.id, + description: "Angle grinder", + quantity: 1, + cost_price: 1500, + }); + + const drafts = await listOpenDrafts(pbMock as never); + + // Newest first. + expect(drafts).toHaveLength(2); + expect(drafts[0]).toMatchObject({ + id: purchaseB.id, + purchase_number: "PO26073001STK", + supplier: supplierB, + supplier_name: "Hardware Hub", + created: "2026-07-30T09:00:00.000Z", + }); + expect(drafts[0].lines).toHaveLength(1); + expect(drafts[0].lines[0]).toMatchObject({ + description: "Angle grinder", + quantity: 1, + cost_price: 1500, + }); + + expect(drafts[1]).toMatchObject({ + id: purchaseA.id, + purchase_number: "PO26072801STK", + supplier: supplierA, + supplier_name: "Steel & Pipe Co", + created: "2026-07-28T08:00:00.000Z", + }); + expect(drafts[1].lines).toHaveLength(2); + expect( + drafts[1].lines.map((l) => l.description).sort(), + ).toEqual(["115mm Cutting Disc", "Wire rope 6mm"]); + }); + + it("ignores non-draft stock_purchases in the result", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ name: "S" }) + ).id; + // Sent and received rows must NOT appear in the drafts list. + await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "sent", + purchase_number: "PO26072801STK", + created: "2026-07-28T08:00:00.000Z", + }); + await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "received", + purchase_number: "PO26072901STK", + created: "2026-07-29T08:00:00.000Z", + }); + + const drafts = await listOpenDrafts(pbMock as never); + + expect(drafts).toEqual([]); + }); + + it("returns an empty lines array for a draft with no child items", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ name: "Hardware Hub" }) + ).id; + // A brand-new draft with no lines yet (possible if the row was + // created by the agent, not by the modal — or after a mass delete). + const purchase = await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "draft", + purchase_number: "PO26073001STK", + created: "2026-07-30T09:00:00.000Z", + }); + + const drafts = await listOpenDrafts(pbMock as never); + + expect(drafts).toHaveLength(1); + expect(drafts[0].id).toBe(purchase.id); + expect(drafts[0].supplier_name).toBe("Hardware Hub"); + expect(drafts[0].lines).toEqual([]); + }); +}); diff --git a/src/lib/server/stock-quick-draft-service.ts b/src/lib/server/stock-quick-draft-service.ts index 5e3b3e1..58ac7aa 100644 --- a/src/lib/server/stock-quick-draft-service.ts +++ b/src/lib/server/stock-quick-draft-service.ts @@ -88,6 +88,35 @@ export type RemoveQuickDraftLineResult = | { ok: true } | { ok: false; reason: string; message: string }; +/** + * Per-line summary surfaced in the cards region of the Quick Draft + * modal. Narrower than `StockPurchaseItem` — only the fields the + * buyer needs to decide whether to keep or remove a line. + */ +export interface DraftLineSummary { + id: string; + description: string; + quantity: number; + cost_price: number; +} + +/** + * Per-purchase summary surfaced in the cards region. Distinct from + * `StockPurchase`: `supplier_name` is pre-expanded so the modal + * doesn't have to do a second lookup, and `lines` is the denormalised + * child list so a single render read populates the whole card. + */ +export interface DraftPurchaseSummary { + id: string; + purchase_number: string; + /** Raw supplier PB id (kept for symmetry with the underlying record). */ + supplier: string; + /** Denormalised `suppliers.name`, looked up via the expand. */ + supplier_name: string; + lines: DraftLineSummary[]; + created: string; +} + /* ============================================================================ * Helpers * ========================================================================== */ @@ -466,3 +495,100 @@ export async function removeQuickDraftLine( }; } } + +/* ============================================================================ + * listOpenDrafts + * ========================================================================== */ + +/** + * List every `stock_purchase` in `draft` status, with supplier name + * pre-expanded and the child line items denormalised onto the result. + * + * Backs the cards region of the Quick Draft modal. The endpoint is + * intentionally separate from `GET /api/v1/stock-purchases` so the + * browser can call it without the agent-API bearer (and so the + * shape stays tailored to what the cards UI needs — no need to + * expose the full `StockPurchase` row, the per-row `item_count`, + * or the supplier PB id as a separate lookup). + * + * Two round-trips to PB: + * 1. `stock_purchases` with `status="draft"`, `expand=supplier`, + * `sort=-created`. Returns newest drafts first. + * 2. `stock_purchase_items` filtered by `purchase IN (id1, id2, ...)`, + * grouped by `purchase` id and attached to each summary under + * `lines`. Batched (single getFullList) so the cost is O(1) + * network round-trips regardless of how many drafts the buyer + * has accumulated. + * + * Returns the summary array sorted by `created` desc. Empty when + * no drafts exist (the modal renders an empty-state on length 0). + */ +export async function listOpenDrafts( + pb: StockQuickDraftPb, +): Promise { + const result = await pb.collection(STOCK_PURCHASES).getList(1, 200, { + filter: `status = "draft"`, + sort: "-created", + expand: "supplier", + }); + + const purchases = result.items; + if (purchases.length === 0) return []; + + // Batch-fetch lines for every draft in one call. PB doesn't auto- + // expand the `purchase` reverse relation on `stock_purchase_items`, + // so we filter on `purchase = id` (OR'd across all draft ids) and + // then group in JS. Quotes are escaped because ids could in theory + // contain a quote (PB-generated ids are 15-char alphanumeric, but + // the helper keeps the pattern consistent with the rest of the file). + const purchaseIds = purchases.map((p) => String(p.id)); + const linesResult = await pb + .collection(STOCK_PURCHASE_ITEMS) + .getFullList({ + filter: purchaseIds + .map((id) => `purchase = "${escapePbFilterString(id)}"`) + .join(" || "), + }); + + type LineRow = { + id: string; + purchase?: string | null; + description?: unknown; + quantity?: unknown; + cost_price?: unknown; + }; + + const linesByPurchase = new Map(); + for (const raw of linesResult as unknown as LineRow[]) { + const purchaseId = raw.purchase ? String(raw.purchase) : null; + if (!purchaseId) continue; + const summary: DraftLineSummary = { + id: String(raw.id), + description: String(raw.description ?? ""), + quantity: typeof raw.quantity === "number" ? raw.quantity : Number(raw.quantity ?? 0), + cost_price: + typeof raw.cost_price === "number" + ? raw.cost_price + : Number(raw.cost_price ?? 0), + }; + const bucket = linesByPurchase.get(purchaseId); + if (bucket) bucket.push(summary); + else linesByPurchase.set(purchaseId, [summary]); + } + + return purchases.map((row) => { + type ExpandedSupplier = { id: string; name?: string; suppliername?: string }; + const expand = row.expand as { supplier?: ExpandedSupplier } | undefined; + const supplierName = + expand?.supplier?.name ?? expand?.supplier?.suppliername ?? ""; + + return { + id: String(row.id), + purchase_number: String(row.purchase_number ?? ""), + supplier: String(row.supplier ?? ""), + supplier_name: String(supplierName), + lines: linesByPurchase.get(String(row.id)) ?? [], + created: String(row.created ?? ""), + }; + }); +}