← back to Rentv
contrib/contractors/router.js
253 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 PUBLISH GATE (TK-10488) -------------------------------------------
// HARD RULE from the go-live memos + task: publish ONLY license_status-verified
// contractors. The upstream /match already filters to license_status='Active'
// server-side, but we enforce it AGAIN here as defense-in-depth so a stale
// upstream, a widened upstream filter, or a bad record can NEVER put an
// unverified license in front of a reader. A record whose status is not a
// recognized in-good-standing value is DROPPED (never published), and does not
// consume a result slot. Env-overridable (comma-separated) for future statuses.
const VERIFIED_STATUSES = new Set(
String(process.env.CONTRACTORS_VERIFIED_STATUSES || 'ACTIVE,CLEAR')
.split(',')
.map((s) => s.trim().toUpperCase())
.filter(Boolean)
);
function isVerifiedStatus(status) {
return VERIFIED_STATUSES.has(String(status == null ? '' : status).trim().toUpperCase());
}
// 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. Only license_status-verified
// records are emitted (CSLB publish gate above).
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;
// CSLB PUBLISH GATE — drop anything not license_status-verified, BEFORE it
// consumes a dedupe slot or a result slot. This is the "only publish
// license_status-verified contractors" guarantee, enforced at RENTV's edge.
if (!isVerifiedStatus(c.license_status)) 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,
verified_statuses: [...VERIFIED_STATUSES],
source_as_of: SOURCE_AS_OF,
});
});
// 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();