From 0282d43109185fac40952294c93fb590f7607d02 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 01:36:37 +0200 Subject: [PATCH] feat(pos): add customer search behind /api/v1/pos/customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the till free of PocketBase credentials — the mockup queried the customers collection directly. Co-Authored-By: Claude --- src/app/api/v1/pos/customers/route.ts | 19 +++++++++ src/lib/server/pos-service.test.ts | 57 ++++++++++++++++++++++++++- src/lib/server/pos-service.ts | 33 ++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/pos/customers/route.ts diff --git a/src/app/api/v1/pos/customers/route.ts b/src/app/api/v1/pos/customers/route.ts new file mode 100644 index 0000000..ab9a260 --- /dev/null +++ b/src/app/api/v1/pos/customers/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertPosAuth } from "@/lib/server/pos-auth"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { searchCustomersForPos } from "@/lib/server/pos-service"; + +/** GET /api/v1/pos/customers?q= — customer picker for account sales. */ +export async function GET(req: NextRequest) { + try { + assertPosAuth(req); + } catch (err) { + const message = err instanceof Error ? err.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + const { searchParams } = new URL(req.url); + const q = searchParams.get("q") || ""; + const pb = await getAdminPocketBase(); + const items = await searchCustomersForPos(pb, q); + return NextResponse.json({ items }); +} diff --git a/src/lib/server/pos-service.test.ts b/src/lib/server/pos-service.test.ts index 4a0136f..8101d14 100644 --- a/src/lib/server/pos-service.test.ts +++ b/src/lib/server/pos-service.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from "vitest"; -import { checkoutSale, listActiveStockCatalog, listSalesInRange } from "./pos-service"; +import { + checkoutSale, + listActiveStockCatalog, + listSalesInRange, + searchCustomersForPos, +} from "./pos-service"; function stubPb(items: unknown[]) { return { @@ -365,3 +370,53 @@ describe("listSalesInRange", () => { expect(itemsGetList).not.toHaveBeenCalled(); }); }); + +describe("searchCustomersForPos", () => { + it("returns an empty array for a blank query without calling PB", async () => { + const getFullList = vi.fn(); + const pb = { + collection: () => ({ + getFullList, + getOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + getList: vi.fn(), + }), + }; + const result = await searchCustomersForPos(pb, " "); + expect(result).toEqual([]); + expect(getFullList).not.toHaveBeenCalled(); + }); + + it("maps customer rows to {id, name, company}", async () => { + const pb = { + collection: () => ({ + getFullList: vi.fn().mockResolvedValue([ + { id: "c1", customer: "Jane Doe", expand: { company: { company: "Acme Ltd" } } }, + ]), + getOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + getList: vi.fn(), + }), + }; + const result = await searchCustomersForPos(pb, "jane"); + expect(result).toEqual([{ id: "c1", name: "Jane Doe", company: "Acme Ltd" }]); + }); + + it("escapes quotes in the query so the PB filter can't be broken out of", async () => { + const getFullList = vi.fn().mockResolvedValue([]); + const pb = { + collection: () => ({ + getFullList, + getOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + getList: vi.fn(), + }), + }; + await searchCustomersForPos(pb, "O'Brien"); + const filter = String(getFullList.mock.calls[0][0].filter); + expect(filter).toContain("O\\'Brien"); + }); +}); diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts index ef1ab90..aae13fd 100644 --- a/src/lib/server/pos-service.ts +++ b/src/lib/server/pos-service.ts @@ -291,3 +291,36 @@ export async function listSalesInRange( const itemsPage = await pb.collection("pos_sale_items").getList(1, 2000, { filter }); return { sales, items: itemsPage.items }; } + +// ─── searchCustomersForPos ────────────────────────────────────────── + +export type PosCustomerResult = { id: string; name: string; company: string }; + +/** + * Customer picker for account sales. Lives behind the POS API rather + * than being a direct PB query from the till — the whole point of this + * app's rewrite is that no PocketBase credentials reach the client. + */ +export async function searchCustomersForPos( + pb: PosPb, + query: string, +): Promise { + if (!query.trim()) return []; + const safe = escapePbValue(query); + const rows = await pb.collection("customers").getFullList<{ + id: string; + customer: string; + number?: string; + email?: string; + expand?: { company?: { company?: string } }; + }>({ + filter: `customer ~ '${safe}' || number ~ '${safe}' || email ~ '${safe}'`, + expand: "company", + sort: "customer", + }); + return rows.slice(0, 8).map((c) => ({ + id: c.id, + name: c.customer || c.email || c.number || "—", + company: c.expand?.company?.company || "", + })); +}