fix(stock-quick-draft): dedicated /drafts endpoint with nested shape, browser-auth

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 23:11:27 +02:00
parent 29ffcbdc8d
commit 8acb6dcf95
5 changed files with 296 additions and 2 deletions

View file

@ -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 },
);
}
}

View file

@ -391,7 +391,7 @@ describe("QuickDraftModal — cards region", () => {
it("fetches and renders draft cards on open", async () => {
render(<QuickDraftModal open onOpenChange={() => {}} />);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/stock-purchases"),
expect.stringContaining("/stock-quick-draft/drafts"),
expect.anything(),
);
await act(async () => {

View file

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

View file

@ -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([]);
});
});

View file

@ -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<DraftPurchaseSummary[]> {
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<string, DraftLineSummary[]>();
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 ?? ""),
};
});
}