WFDASH-WEBAPP/src/lib/server/customers-service.ts
Dave bbfa888cc1 feat(customers): add status toggle and site/location field
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:

  - customers.status (bool): flips the customer active/inactive.
    Legacy rows pre-date the field and load as undefined; both
    the form and the read side collapse that to active=true so
    the migration date doesn't silently flip the existing base.

  - customers.site_location (text): free-text site/shaft label
    keyed off the customer's company. Distinct from the existing
    'site' free-text mine tag owned by the pin tool. Storage is
    plain text on purpose, with a /api/v1/customers/sites
    endpoint returning distinct existing values for an input
    + datalist autocomplete under the company combobox.

  surfaces:
  - Active/Inactive badge on customer detail and list rows
  - site_location row in the customer Details card and under
    the company line on the list (desktop + mobile card)
  - Switch under the sales-rep section of the form to flip
    status; existing customers stay active by default

  also fixes a UX trap on edit: previously opening the edit
  form showed 'Select company...' / 'Select rep...' as placeholders
  for an instant until the lookup lists resolved, making a real,
  saved company/rep look unlinked. CompanySearch and UserSearch
  now accept a selectedLabel fallback; CustomerForm passes the
  resolved customer.expand.company / customer.expand.rep names
  through so the triggers display the linked names immediately.

  listDistinctSiteLocations helper added to customers-service.ts
  alongside the existing listDistinctSites.
2026-09-16 09:47:09 +02:00

401 lines
15 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
import { jitterCoordinates } from "@/lib/geo";
import type { Customer, User } from "../types";
const CUSTOMERS_COLLECTION = "customers";
const USERS_COLLECTION = "users";
/**
* Scatter radius for `assignCustomersToSite`. Wide enough that pins on
* the same shaft don't overlap on the office map; narrow enough to be
* noise against the 2 km check-in radius.
*/
const SITE_JITTER_MIN_METRES = 30;
const SITE_JITTER_MAX_METRES = 80;
/**
* 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 ?? "";
}
/**
* Write `customers.coordinates` for a single customer.
*
* The office pin tool (see `/visits/pin-tool`) is the first consumer —
* it seeds real coordinates onto the ~20 main shafts so the rep
* check-in flow has somewhere to land on day one. The rep flow will
* call it too, via `recordVisits`, to stamp a customer's position on
* first visit.
*
* This matters more than it looks: **0 of 616 customers had real
* coordinates** when this was written — every `coordinates` geoPoint
* was PB's `{lon: 0, lat: 0}` default. Radius matching returns nothing
* until rows start flowing through here.
*
* Validates lat ∈ [-90, 90] and lng ∈ [-180, 180] — PB itself will
* reject out-of-range values, but with a less specific 400 message;
* this guard gives the route layer a clean "lat must be in [-90, 90]"
* error to surface to the operator. `Number.isFinite` also rejects
* non-numbers, so a JS caller passing `"26.2"` (a string straight off
* a form input) can't silently write a poisoned row.
*
* `pbArg` is optional for testability — production callers omit it
* and let the service fetch its own admin client.
*/
function assertValidLatLng(lat: number, lng: number): void {
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
throw new Error(`lat must be a finite number in [-90, 90]; got ${String(lat)}`);
}
if (!Number.isFinite(lng) || lng < -180 || lng > 180) {
throw new Error(`lng/lon must be a finite number in [-180, 180]; got ${String(lng)}`);
}
}
export async function setCustomerCoordinates(
id: string,
lat: number,
lng: number,
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<Customer> {
assertValidLatLng(lat, lng);
const pb = pbArg || (await getAdminPocketBase());
return (await pb.collection(CUSTOMERS_COLLECTION).update(id, {
coordinates: { lat, lon: lng },
})) as Customer;
}
/**
* Distinct, non-blank `site` values already in use, each with how many
* customers currently carry it — sorted by that count, descending, so
* the batch pin tool's autocomplete surfaces the shafts with the most
* customers first (the ones worth re-pinning or adding to) rather than
* a one-off typo sitting alphabetically at the top.
*
* Purely a curb on drift, not an enforced vocabulary: `site` is a free
* text field (see the comment on `Customer.site`), so this list is
* only as clean as what's been typed so far.
*/
export async function listDistinctSites(
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<{ site: string; customerCount: number }[]> {
const pb = pbArg || (await getAdminPocketBase());
const rows = await pb.collection(CUSTOMERS_COLLECTION).getFullList<{ site?: string }>({
filter: `site != ""`,
fields: "site",
});
const counts = new Map<string, number>();
for (const row of rows) {
const site = (row.site ?? "").trim();
if (!site) continue;
counts.set(site, (counts.get(site) ?? 0) + 1);
}
return [...counts.entries()]
.map(([site, customerCount]) => ({ site, customerCount }))
.sort((a, b) => b.customerCount - a.customerCount || a.site.localeCompare(b.site));
}
/**
* Distinct, non-blank `site_location` values already in use, each with
* how many customers currently carry it — sorted by that count,
* descending, so the customer form's autocomplete suggests the
* locations the office is actually using today rather than a one-off
* typo sitting alphabetically at the top.
*
* Field added to PB on 2026-09 as the operator's per-customer
* shaft/site tag (distinct from the legacy `site` free-text mine tag
* used by the pin tool — see the JSDoc on `Customer.site_location`).
* Storage is plain text on purpose, so this list is purely a curb on
* drift rather than an enforced vocabulary.
*/
export async function listDistinctSiteLocations(
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<{ site_location: string; customerCount: number }[]> {
const pb = pbArg || (await getAdminPocketBase());
const rows = await pb
.collection(CUSTOMERS_COLLECTION)
.getFullList<{ site_location?: string }>({
filter: `site_location != ""`,
fields: "site_location",
});
const counts = new Map<string, number>();
for (const row of rows) {
const loc = (row.site_location ?? "").trim();
if (!loc) continue;
counts.set(loc, (counts.get(loc) ?? 0) + 1);
}
return [...counts.entries()]
.map(([site_location, customerCount]) => ({ site_location, customerCount }))
.sort(
(a, b) =>
b.customerCount - a.customerCount || a.site_location.localeCompare(b.site_location),
);
}
export interface AssignCustomersToSiteResult {
updated: string[];
errors: { customerId: string; message: string }[];
}
/**
* Tags every listed customer with `site` and gives each one a
* coordinate near `[lat, lng]` — jittered per customer so dozens of
* pins on the same shaft render as visibly distinct dots rather than
* one marker on the office map (see `jitterCoordinates` in `geo.ts`).
*
* This is the batch counterpart to `setCustomerCoordinates`: the
* single-pin tool exists for the odd customer with their own delivery
* point, but most of the customer base sits in clusters — many
* contacts at Harmony Kusasalethu, at South Deep Shaft 3, and so on —
* and pinning those one click at a time doesn't scale.
*
* Each customer is written independently and a failure on one doesn't
* abort the rest, mirroring `recordVisits` in `visits-service.ts`: a
* batch of forty shouldn't be undone by one stale id.
*/
export async function assignCustomersToSite(
customerIds: string[],
site: string,
lat: number,
lng: number,
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<AssignCustomersToSiteResult> {
assertValidLatLng(lat, lng);
const trimmedSite = site.trim();
if (!trimmedSite) {
throw new Error("assignCustomersToSite: site is required");
}
if (customerIds.length === 0) {
throw new Error("assignCustomersToSite: at least one customer is required");
}
const pb = pbArg || (await getAdminPocketBase());
const result: AssignCustomersToSiteResult = { updated: [], errors: [] };
for (const id of customerIds) {
if (!id) continue;
try {
const [jitteredLat, jitteredLng] = jitterCoordinates(
[lat, lng],
SITE_JITTER_MIN_METRES,
SITE_JITTER_MAX_METRES,
);
await pb.collection(CUSTOMERS_COLLECTION).update(id, {
site: trimmedSite,
coordinates: { lat: jitteredLat, lon: jitteredLng },
});
result.updated.push(id);
} catch (error) {
result.errors.push({
customerId: id,
message: error instanceof Error ? error.message : "Failed to update customer",
});
}
}
return result;
}