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:
parent
b60cdd8547
commit
0282d43109
3 changed files with 108 additions and 1 deletions
19
src/app/api/v1/pos/customers/route.ts
Normal file
19
src/app/api/v1/pos/customers/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue