← back to Homesonspec
apps/web/src/lib/contractors.ts
206 lines
// Fetch layer for the shared usre contractor API (built in parallel by another
// agent). Consumed SERVER-SIDE from the home detail page so the base URL / any
// credentials never reach the browser.
//
// Contract (per TK-10488):
// GET {base}/api/contractors/match?mode=home&city=&county=&zip=&status=CLEAR&trades=C-8,C-10,...
// -> { criteria, matches: { "C-10": [ {license_no, business_name, city,
// county, phone, license_status, classifications, primary_class,
// issue_date, expire_date, cslb_last_update, source_as_of, updated_at} ], ... } }
// active subs grouped by trade class, nearest-first.
// GET {base}/api/contractors?county=&class=&status=CLEAR&limit= (plain browse)
//
// Good-standing status is 'CLEAR' (NOT 'Active') — we always pass status=CLEAR.
// DATE EVERYTHING (Steve's hard rule): the issue/expire dates + the CSLB
// data-as-of stamp flow all the way through to each rendered row.
//
// Base URL from process.env.CONTRACTORS_API_BASE (default http://localhost:9913).
// GRACEFUL by design: if the API is unreachable / errors / returns junk, this
// returns a null-ish result so the UI renders an empty-state, never throws.
import { DEFAULT_TRADES_PARAM } from "./trades";
export const CONTRACTORS_API_BASE =
process.env.CONTRACTORS_API_BASE?.replace(/\/+$/, "") || "http://localhost:9913";
// Scoped Basic auth for the usre contractor API (least-privilege contractor-only cred).
// Set CONTRACTORS_API_USER + CONTRACTORS_API_PASS in .env; unset = no header (local dev).
export const CONTRACTORS_API_AUTH =
process.env.CONTRACTORS_API_USER && process.env.CONTRACTORS_API_PASS
? "Basic " +
Buffer.from(
`${process.env.CONTRACTORS_API_USER}:${process.env.CONTRACTORS_API_PASS}`,
).toString("base64")
: "";
export interface ContractorMatch {
license_no: string;
business_name: string;
city: string | null;
county: string | null;
phone: string | null;
license_status: string | null;
classifications: string | null;
primary_class: string | null;
/** CSLB license issue date (ISO or as-supplied) — shown on every row. */
issue_date: string | null;
/** CSLB license expiration date — shown on every row. */
expire_date: string | null;
/** When CSLB last updated this record (per the API). */
cslb_last_update: string | null;
/** Per-row CSLB data-as-of date (used as a fallback for the section stamp). */
source_as_of: string | null;
/** When our copy of this row was last synced. */
updated_at: string | null;
}
export interface MatchResult {
/** Active subs grouped by CSLB class code, nearest-first (as returned). */
matches: Record<string, ContractorMatch[]>;
/** The criteria the API echoed back (city/county/zip/trades), when present. */
criteria: Record<string, unknown> | null;
/**
* CSLB data-as-of date (the API's `source_as_of`), surfaced so the UI can
* stamp "CSLB data as of {source_as_of} — verify at cslb.ca.gov" on the
* section (DATE EVERYTHING). Falls back to the freshest per-row date we saw
* when the API omits a top-level value.
*/
source_as_of: string | null;
/** True when the API answered with a well-formed body. */
ok: boolean;
/** Set when the API was unreachable or errored — drives the empty-state copy. */
error: string | null;
}
const EMPTY: MatchResult = {
matches: {},
criteria: null,
source_as_of: null,
ok: false,
error: null,
};
interface MatchArgs {
city?: string | null;
county?: string | null;
zip?: string | null;
/** Comma-joined CSLB class codes; defaults to the spec-home build set. */
trades?: string;
/** Abort budget (ms). Short so a slow/absent API never blocks the page. */
timeoutMs?: number;
}
/**
* Fetch nearby licensed subs grouped by trade for a spec home's location.
* Returns a graceful empty result on any failure — the caller renders an
* empty-state rather than crashing the page.
*/
export async function matchSubsForHome(args: MatchArgs): Promise<MatchResult> {
const trades = args.trades ?? DEFAULT_TRADES_PARAM;
// Good-standing filter: 'CLEAR' (NOT 'Active') per the contractor API contract.
const qs = new URLSearchParams({ mode: "home", status: "CLEAR", trades });
if (args.city) qs.set("city", args.city);
if (args.county) qs.set("county", args.county);
if (args.zip) qs.set("zip", args.zip);
const url = `${CONTRACTORS_API_BASE}/api/contractors/match?${qs.toString()}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 3500);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { accept: "application/json", ...(CONTRACTORS_API_AUTH ? { Authorization: CONTRACTORS_API_AUTH } : {}) },
// Location facts change slowly; cache briefly to avoid hammering the API
// on every detail-page view.
next: { revalidate: 900 },
});
if (!res.ok) {
return { ...EMPTY, error: `contractor API ${res.status}` };
}
const body = (await res.json()) as unknown;
return normalize(body);
} catch (err) {
const error = err instanceof Error ? err.message : "contractor API unreachable";
return { ...EMPTY, error };
} finally {
clearTimeout(timer);
}
}
/** Defensively coerce the API body into a MatchResult; tolerate shape drift. */
function normalize(body: unknown): MatchResult {
if (!body || typeof body !== "object") return { ...EMPTY, error: "empty response" };
const obj = body as Record<string, unknown>;
const rawMatches = obj.matches;
const matches: Record<string, ContractorMatch[]> = {};
if (rawMatches && typeof rawMatches === "object") {
for (const [cls, list] of Object.entries(rawMatches as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
matches[cls] = list
.filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
.map((r) => ({
license_no: str(r.license_no),
business_name: str(r.business_name),
city: strOrNull(r.city),
county: strOrNull(r.county),
phone: strOrNull(r.phone),
license_status: strOrNull(r.license_status),
classifications: strOrNull(r.classifications),
primary_class: strOrNull(r.primary_class) ?? cls,
issue_date: strOrNull(r.issue_date),
expire_date: strOrNull(r.expire_date),
cslb_last_update: strOrNull(r.cslb_last_update),
source_as_of: strOrNull(r.source_as_of),
updated_at: strOrNull(r.updated_at),
}))
// Drop rows with no business name — never surface a blank sub.
.filter((r) => r.business_name.length > 0);
}
}
const criteria =
obj.criteria && typeof obj.criteria === "object"
? (obj.criteria as Record<string, unknown>)
: null;
// DATE EVERYTHING: prefer the API's top-level source_as_of; else fall back to
// the freshest per-row CSLB date we saw so the "data as of" stamp is honest.
const topSourceAsOf =
strOrNull(obj.source_as_of) ??
(criteria ? strOrNull(criteria.source_as_of) : null);
const source_as_of = topSourceAsOf ?? freshestRowDate(matches);
const total = Object.values(matches).reduce((n, a) => n + a.length, 0);
return {
matches,
criteria,
source_as_of,
ok: total > 0,
error: total > 0 ? null : "no matches",
};
}
/** Freshest per-row CSLB source date across all matches (fallback for the stamp). */
function freshestRowDate(matches: Record<string, ContractorMatch[]>): string | null {
let best: string | null = null;
let bestT = -Infinity;
for (const list of Object.values(matches)) {
for (const r of list) {
const cand = r.source_as_of ?? r.cslb_last_update ?? r.updated_at;
if (!cand) continue;
const t = Date.parse(cand);
if (!Number.isNaN(t) && t > bestT) {
bestT = t;
best = cand;
}
}
}
return best;
}
function str(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function strOrNull(v: unknown): string | null {
if (v == null) return null;
const s = typeof v === "string" ? v : String(v);
return s.trim() === "" ? null : s;
}