import { getAdminPocketBase } from "./pocketbase-admin"; import type { Customer, User } from "../types"; const CUSTOMERS_COLLECTION = "customers"; const USERS_COLLECTION = "users"; /** * Expand used by the customers list page — joins `company` and `rep` * (both relations on the customer row) so the page can render real * names instead of opaque PB ids. PB's expand syntax is comma-separated * field names; nested relations use dotted paths. */ export const CUSTOMER_LIST_EXPAND = "company,rep"; /** * Safety cap on the user-lookup side of rep search. The WFDASH user * roster is bounded (a few dozen sales reps + office users), so any * one search that matches > 100 users is almost certainly a mistake * (operator typed a single character, etc.). We trim to 100 and trust * that no operator is hunting for customers owned by 100+ reps at once. * * The cap also keeps the `rep = "id1" || rep = "id2" || ...` OR-chain * well under PB's filter-length cap (200 ids lands at ~4.6 KB; 100 * ids is ~2.3 KB — comfortable). */ const REP_SEARCH_USER_CAP = 100; export interface ListCustomersParams { page?: number; perPage?: number; search?: string; sort?: string; /** Optional exact-filter on the rep relation. Accepts a 15-char PB * user id (verbatim) OR a user name / email (case-insensitive * lookup). Unresolvable input is silently dropped so a bad * client param doesn't 500 the whole list call. */ rep?: string; } export interface ListCustomersResult { items: Customer[]; totalItems: number; totalPages: number; page: number; perPage: number; } /** * Read model for the `/customers` page. * * Mirrors `getOrdersDashboardData` (orders-service.ts) and * `getQuotesDashboardData` (quotes-service.ts): the service owns the * PocketBase filter assembly, the page owns the layout. Replaces the * legacy `pb-helpers.getCustomers` that lived in the client-only layer * — see AGENTS.md "data-layer discipline". * * Search semantics: a single free-text box that hits * * - `customer` (contact name) * - `email` (primary email) * - `number` (phone) * - rep name OR rep email → every customer whose `rep` relation * points at a matching user * * The rep branch exists because the previous implementation * (`pb-helpers.getCustomers`) silently skipped the `rep` field, even * though the column is in the schema and the page renders a rep pill * on every row. Operators who wanted "all of David's customers" had * to scroll every page and eyeball it — that workflow was the * motivation for this module. * * PB filter syntax doesn't let us filter on `expand.rep.name ~ "..."` * directly, so the implementation resolves the rep branch in two * steps: * * 1. Query the `users` collection (cheap — bounded by * REP_SEARCH_USER_CAP) for matching names / emails. * 2. Build `rep = "id1" || rep = "id2" || ...` and OR it into the * main customer filter. * * If the user-lookup returns zero matches, the rep branch is omitted * entirely — the customer/email/phone branches still match on their * own. That keeps a search for "Alice" hitting customers named * "Alice" even if no rep matches. * * Expand payload carries both `company` and `rep` so the row renders * without a second round-trip — same `expand` parameter that the * legacy helper used. The expand here is for DISPLAY only (PB's * expand never affects filtering), so the search path has to walk * users explicitly. */ export async function listCustomersForDashboard( params: ListCustomersParams = {}, pbArg?: Awaited>, ): Promise { const pb = pbArg || (await getAdminPocketBase()); pb.autoCancellation(false); const page = Math.max(1, params.page || 1); const perPage = Math.min(100, Math.max(1, params.perPage || 20)); const search = (params.search || "").trim(); const sort = params.sort || "-created"; let filter = ""; if (search) { const escaped = search.replace(/"/g, '\\"'); const customerFilter = `(customer ~ "${escaped}" || email ~ "${escaped}" || number ~ "${escaped}")`; const repFilter = await buildRepSearchClause(pb, search); filter = repFilter ? `(${customerFilter} || ${repFilter})` : customerFilter; } // Exact rep filter — separate from the search-built rep OR-chain. // Accepts either a 15-char PB id (verbatim) or a name/email of a // user. Unresolvable input is silently dropped so a bad param // doesn't 500 the whole list call. if (params.rep) { const repId = await resolveRepExact(pb, params.rep); if (repId) { const repClause = `rep = "${repId}"`; filter = filter ? `(${filter}) && ${repClause}` : repClause; } } const result = await pb .collection(CUSTOMERS_COLLECTION) .getList(page, perPage, { filter, sort, expand: CUSTOMER_LIST_EXPAND, }); return { items: result.items as Customer[], totalItems: result.totalItems, totalPages: result.totalPages, page: result.page, perPage: result.perPage, }; } /** * Resolve a rep-name search into a PB filter clause of the form * `rep = "id1" || rep = "id2" || ...`. Returns an empty string when * the user lookup finds no matches — caller omits the clause from * the OR-chain in that case. * * Capped at `REP_SEARCH_USER_CAP` ids; the user roster is small enough * that this never bites in practice, but the cap keeps the OR-chain * well under PB's filter-length limit and gives us a graceful * degradation when someone searches for a single character. */ async function buildRepSearchClause( pb: Awaited>, search: string, ): Promise { const escaped = search.replace(/"/g, '\\"'); const usersFilter = `name ~ "${escaped}" || email ~ "${escaped}"`; const matchingUsers = await pb .collection(USERS_COLLECTION) .getFullList({ filter: usersFilter, fields: "id", // PB defaults to 500; we cap lower so a typo doesn't drag the // whole user table over the wire just to build the filter. batch: Math.max(REP_SEARCH_USER_CAP, 1), }); if (matchingUsers.length === 0) return ""; const ids = matchingUsers .map((u) => u.id) .filter((id) => typeof id === "string" && id.length > 0) .slice(0, REP_SEARCH_USER_CAP); if (ids.length === 0) return ""; return ids.map((id) => `rep = "${id}"`).join(" || "); } /** PB ids are 15 alphanumeric chars. Detect that and treat the * input as a verbatim id rather than a name lookup. */ const PB_ID_PATTERN = /^[A-Za-z0-9]{15}$/; /** * Resolve a `rep` filter param to a single user id. Accepts either a * 15-char PB id (used verbatim) or a free-text name / email (matched * case-insensitively against `users.name` / `users.email`). Returns * "" when nothing matches — caller omits the filter clause in that * case so a bad client doesn't 500 the whole list call. */ async function resolveRepExact( pb: Awaited>, input: string, ): Promise { const trimmed = (input || "").trim(); if (!trimmed) return ""; if (PB_ID_PATTERN.test(trimmed)) return trimmed; // Match by name OR email, case-insensitive. PB's `=` is // case-sensitive on stored values, so we use `~` (substring) and // then de-dup to the first hit. For a 1-1 unique name this is // fine; for ambiguous names the first match wins. const escaped = trimmed.replace(/"/g, '\\"'); const matches = await pb.collection(USERS_COLLECTION).getList(1, 1, { filter: `(name = "${escaped}" || email = "${escaped}" || name ~ "${escaped}" || email ~ "${escaped}")`, fields: "id", }); return matches.items[0]?.id ?? ""; }