WFDASH-WEBAPP/src/lib/server/customers-service.ts

207 lines
7.6 KiB
TypeScript
Raw Normal View History

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<ReturnType<typeof getAdminPocketBase>>,
): Promise<ListCustomersResult> {
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<Customer>(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<ReturnType<typeof getAdminPocketBase>>,
search: string,
): Promise<string> {
const escaped = search.replace(/"/g, '\\"');
const usersFilter = `name ~ "${escaped}" || email ~ "${escaped}"`;
const matchingUsers = await pb
.collection(USERS_COLLECTION)
.getFullList<User>({
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<ReturnType<typeof getAdminPocketBase>>,
input: string,
): Promise<string> {
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<User>(1, 1, {
filter: `(name = "${escaped}" || email = "${escaped}" || name ~ "${escaped}" || email ~ "${escaped}")`,
fields: "id",
});
return matches.items[0]?.id ?? "";
}