← 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;
}