- Introduced a new `quotes.create` tool for creating quotes from scratch without an inbound email, allowing operators to generate quotes directly from the dashboard. - Updated `customers.list` to support filtering by representative using either a 15-char PB user ID or a case-insensitive name/email match. - Enhanced API documentation in `docs/api-mcp-layer.md` to reflect the addition of the new quote creation tool and updated customer filtering capabilities. - Regenerated documentation to ensure accuracy, increasing the total tool count from 125 to 126.
207 lines
No EOL
7.6 KiB
TypeScript
207 lines
No EOL
7.6 KiB
TypeScript
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 ?? "";
|
|
} |