fix(stock-quick-draft): dedicated /drafts endpoint with nested shape, browser-auth
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
29ffcbdc8d
commit
8acb6dcf95
5 changed files with 296 additions and 2 deletions
42
src/app/api/v1/stock-quick-draft/drafts/route.ts
Normal file
42
src/app/api/v1/stock-quick-draft/drafts/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue