feat(pos): add customer search behind /api/v1/pos/customers

Keeps the till free of PocketBase credentials — the mockup queried the
customers collection directly.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-31 01:36:37 +02:00
parent b60cdd8547
commit 0282d43109
3 changed files with 108 additions and 1 deletions

View file

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

View file

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

View file

@ -291,3 +291,36 @@ export async function listSalesInRange(
const itemsPage = await pb.collection("pos_sale_items").getList<PosSaleItem>(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<PosCustomerResult[]> {
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 || "",
}));
}