← back to Rentv V1

contrib/contractors/router.js

221 lines

'use strict';
// ============================================================================
// RENTV contrib — Licensed-contractor market lookup router  (TK-10488)
// ----------------------------------------------------------------------------
// A SELF-CONTAINED, DROP-IN Express router that RENTV can mount to expose
// "licensed contractors for a market" on a CRE deal news story, without RENTV
// having to know anything about the shared usre CSLB contractor database.
//
// It proxies the shared usre (nationalrealestate) contractor API:
//     GET {CONTRACTORS_API_BASE}/api/contractors/match?mode=deal&county=&city=
//         -> { mode, criteria, cap_per_group, matches: { <classCode>: [ ... ] } }
//
// and re-serves a SLIM, text-only shape at:
//     GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
//
// Zero new dependencies: uses Node's built-in global fetch (Node 18+) and a
// small in-memory TTL cache. Nothing here writes to any database, touches any
// existing RENTV file, or re-hosts any external asset.
//
// Owned by: claude-rentv-contractors (contrib author). RENTV (claude-rentv)
// mounts it when ready — see README.md. Gated for any publish/deploy.
// ============================================================================

const express = require('express');

// --- Config (all env-overridable; safe defaults) ----------------------------
const API_BASE = (
  process.env.CONTRACTORS_API_BASE || 'http://localhost:9913'
).replace(/\/+$/, ''); // trim trailing slash

// The usre API is behind Basic Auth (CRCP pattern). RENTV can supply creds via
// CONTRACTORS_API_AUTH="user:pass"; if unset we send none and let the upstream
// decide (loopback / same-host deployments may not require it).
const API_AUTH = process.env.CONTRACTORS_API_AUTH || '';

// Upstream request timeout (ms) and per-trade result cap we surface to callers.
const API_TIMEOUT_MS = Number(process.env.CONTRACTORS_API_TIMEOUT_MS || 6000);
const DEFAULT_LIMIT = Number(process.env.CONTRACTORS_DEFAULT_LIMIT || 6);
const MAX_LIMIT = 24;

// CSLB dataset freshness date the widget must display ("CSLB data as of {date}").
// The upstream /match response does not currently carry a dataset-level date, so
// we surface one here. Priority: any per-record source_as_of the upstream may add
// later -> CONTRACTORS_SOURCE_AS_OF env -> the known load date of the shared
// registry (2026-08-07, ca_contractors.source_as_of). RENTV/ops bump the env when
// the CSLB registry is refreshed so the date the reader sees stays honest.
const SOURCE_AS_OF = process.env.CONTRACTORS_SOURCE_AS_OF || '2026-08-07';

// Short in-memory cache so a hot news story doesn't hammer the upstream.
const CACHE_TTL_MS = Number(process.env.CONTRACTORS_CACHE_TTL_MS || 5 * 60 * 1000);
const _cache = new Map(); // key -> { at, data }

function cacheGet(key) {
  const hit = _cache.get(key);
  if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.data;
  if (hit) _cache.delete(key);
  return null;
}
function cacheSet(key, data) {
  _cache.set(key, { at: Date.now(), data });
  // bound the cache so a spidered site can't grow it without limit
  if (_cache.size > 500) {
    const oldest = [..._cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
    if (oldest) _cache.delete(oldest[0]);
  }
}

// --- Upstream fetch with timeout -------------------------------------------
async function fetchUpstream(url) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
  try {
    const headers = { accept: 'application/json' };
    if (API_AUTH) {
      headers.authorization = 'Basic ' + Buffer.from(API_AUTH).toString('base64');
    }
    const r = await fetch(url, { headers, signal: ctrl.signal });
    if (!r.ok) {
      const body = await r.text().catch(() => '');
      const err = new Error(`upstream ${r.status}`);
      err.status = r.status;
      err.body = body.slice(0, 300);
      throw err;
    }
    return await r.json();
  } finally {
    clearTimeout(t);
  }
}

// --- Flatten the upstream {matches:{code:[...]}} into a slim, deduped list ---
// Text/attribution only: name + trade + phone + city/county + license. No links
// out to any external site, no re-hosted assets.
function flattenMatches(payload, limit) {
  const matches = payload && payload.matches && typeof payload.matches === 'object'
    ? payload.matches : {};
  const seen = new Set(); // dedupe by license_no (a GC can appear under multiple class codes)
  const out = [];
  let upstreamAsOf = null; // newest per-record source_as_of the upstream carried, if any
  for (const code of Object.keys(matches)) {
    const list = Array.isArray(matches[code]) ? matches[code] : [];
    for (const c of list) {
      if (!c || !c.business_name) continue;
      const key = c.license_no || `${c.business_name}|${c.city || ''}`;
      if (seen.has(key)) continue;
      seen.add(key);

      // Track the freshest per-record CSLB date if the upstream ever exposes one.
      if (c.source_as_of && (!upstreamAsOf || c.source_as_of > upstreamAsOf)) {
        upstreamAsOf = String(c.source_as_of);
      }

      // Prefer the human-readable trade title for the matched class code; fall
      // back to the contractor's primary_class or the raw code.
      let trade = null;
      const titles = Array.isArray(c.classification_titles) ? c.classification_titles : [];
      const hit = titles.find((x) => x && x.code === code && x.title);
      if (hit) trade = hit.title;
      else if (titles.length && titles[0].title) trade = titles[0].title;
      else trade = c.primary_class || code;

      out.push({
        name: String(c.business_name),
        trade: trade ? String(trade) : null,
        phone: c.phone ? String(c.phone) : null,
        city: c.city ? String(c.city) : null,
        county: c.county ? String(c.county) : null,
        license_no: c.license_no ? String(c.license_no) : null,
        license_status: c.license_status ? String(c.license_status) : null,
      });
      if (out.length >= limit) {
        out._source_as_of = upstreamAsOf; // non-enumerable-ish sidecar (array prop)
        return out;
      }
    }
  }
  out._source_as_of = upstreamAsOf;
  return out;
}

// --- Router factory ---------------------------------------------------------
// Returns an express.Router() ready to mount. All routes are relative, so the
// host chooses the mount path (README recommends app.use('/contrib/contractors', ...)).
function createContractorsRouter(opts = {}) {
  const router = express.Router();
  const base = (opts.apiBase || API_BASE).replace(/\/+$/, '');

  // Health / self-describe — lets RENTV confirm the drop-in is wired.
  router.get('/health', (_req, res) => {
    res.json({ ok: true, contrib: 'contractors', upstream: base, cache_ttl_ms: CACHE_TTL_MS });
  });

  // GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
  router.get('/api/contractors-for-market', async (req, res) => {
    const county = req.query.county ? String(req.query.county).trim().slice(0, 80) : '';
    const city = req.query.city ? String(req.query.city).trim().slice(0, 80) : '';
    const mode = (req.query.mode ? String(req.query.mode) : 'deal').toLowerCase() === 'home'
      ? 'home' : 'deal';
    let limit = Number(req.query.limit || DEFAULT_LIMIT);
    if (!Number.isFinite(limit) || limit < 1) limit = DEFAULT_LIMIT;
    if (limit > MAX_LIMIT) limit = MAX_LIMIT;

    if (!county && !city) {
      return res.status(400).json({ error: 'county or city is required' });
    }

    const qs = new URLSearchParams({ mode });
    if (county) qs.set('county', county);
    if (city) qs.set('city', city);
    const url = `${base}/api/contractors/match?${qs.toString()}`;
    const cacheKey = `${url}|${limit}`;

    const cached = cacheGet(cacheKey);
    if (cached) return res.json({ ...cached, cached: true });

    try {
      const payload = await fetchUpstream(url);
      const contractors = flattenMatches(payload, limit);
      const sourceAsOf = contractors._source_as_of || SOURCE_AS_OF;
      const plain = contractors.slice(); // drop the sidecar prop from the wire array
      const result = {
        market: {
          city: (payload.criteria && payload.criteria.city) || city || null,
          county: (payload.criteria && payload.criteria.county) || county || null,
          mode,
        },
        count: plain.length,
        contractors: plain,
        // DATE EVERYTHING — the widget renders "CSLB data as of {source_as_of} —
        // verify at cslb.ca.gov" on every entry. verify_url is text-only, no link.
        source_as_of: sourceAsOf,
        verify_url: 'cslb.ca.gov',
        attribution: 'CA CSLB licensed contractors via shared usre registry',
        source: 'usre-contractors',
      };
      cacheSet(cacheKey, result);
      res.json(result);
    } catch (e) {
      // Fail soft: the widget treats an error/empty list as "none to show" and
      // hides itself — a news story must never 500 because the sidecar is down.
      const status = e && e.status === 401 ? 502 : (e && e.status) || 502;
      res.status(status).json({
        error: 'contractor lookup unavailable',
        detail: String((e && e.message) || e),
        market: { city: city || null, county: county || null, mode },
        count: 0,
        contractors: [],
        source_as_of: SOURCE_AS_OF,
        verify_url: 'cslb.ca.gov',
      });
    }
  });

  return router;
}

module.exports = createContractorsRouter;
module.exports.createContractorsRouter = createContractorsRouter;
// Convenience: a ready-to-mount default instance using env config.
module.exports.router = createContractorsRouter();