[object Object]

← back to Rentv V1

Add drop-in contractor lookup package (contrib/contractors, TK-10488)

64f7dfef6f028dd306fac4e3d87b2497a1a7dfbb · 2026-08-12 10:14:18 -0700 · Steve Abrams

Self-contained, new files only — no edits to any existing/live RENTV file.
- router.js: Express router proxying the shared usre CSLB contractor API
  (GET /api/contractors/match?mode=deal&county=&city=) and re-serving a slim,
  text-only shape at GET /api/contractors-for-market; flatten+dedupe, 5m cache,
  fail-soft (never 500s a story).
- widget.html: zero-dependency text-only 'Licensed contractors in {market}'
  block (name+trade+phone); auto-init from data-attrs or programmatic; hides
  itself on empty/error; no link-out, no re-hosted assets.
- README.md: 2-line mount + include steps for claude-rentv, env config, smoke
  test. Publish/deploy gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 64f7dfef6f028dd306fac4e3d87b2497a1a7dfbb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 10:14:18 2026 -0700

    Add drop-in contractor lookup package (contrib/contractors, TK-10488)
    
    Self-contained, new files only — no edits to any existing/live RENTV file.
    - router.js: Express router proxying the shared usre CSLB contractor API
      (GET /api/contractors/match?mode=deal&county=&city=) and re-serving a slim,
      text-only shape at GET /api/contractors-for-market; flatten+dedupe, 5m cache,
      fail-soft (never 500s a story).
    - widget.html: zero-dependency text-only 'Licensed contractors in {market}'
      block (name+trade+phone); auto-init from data-attrs or programmatic; hides
      itself on empty/error; no link-out, no re-hosted assets.
    - README.md: 2-line mount + include steps for claude-rentv, env config, smoke
      test. Publish/deploy gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                      |  11 +++
 contrib/contractors/README.md   | 151 +++++++++++++++++++++++++++++++
 contrib/contractors/router.js   | 194 ++++++++++++++++++++++++++++++++++++++++
 contrib/contractors/widget.html | 128 ++++++++++++++++++++++++++
 4 files changed, 484 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..483965d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+*.err
+.DS_Store
+dist/
+build/
+.next/
+data/
diff --git a/contrib/contractors/README.md b/contrib/contractors/README.md
new file mode 100644
index 0000000..c756ba2
--- /dev/null
+++ b/contrib/contractors/README.md
@@ -0,0 +1,151 @@
+# RENTV contrib — Licensed-contractor market lookup (TK-10488)
+
+A **drop-in** package that adds a "Licensed contractors in {market}" block to a
+CRE deal news story on RENTV, sourced from the **shared usre CSLB contractor
+registry** — with **zero edits to any existing RENTV file** and **no new npm
+dependencies**.
+
+Built by `claude-rentv-contractors` under stand-down (M-00122): these are
+**new files only**. RENTV (`claude-rentv`) owns the wire-in and any deploy.
+
+```
+contrib/contractors/
+├── router.js     Express router (proxy → usre contractor API)
+├── widget.html   self-contained text-only widget (markup + <script>)
+└── README.md     this file
+```
+
+---
+
+## What it does
+
+- **`router.js`** exposes **`GET /api/contractors-for-market?county=&city=`**
+  and proxies the shared usre API:
+  `GET {CONTRACTORS_API_BASE}/api/contractors/match?mode=deal&county=&city=`
+  → `{ matches: { <classCode>: [ ... ] } }`.
+  It flattens + dedupes (a GC appears under several class codes) into a slim,
+  **text-only** shape and caches responses in-memory (5 min TTL).
+- **`widget.html`** renders that list on a story — **name + trade + phone**,
+  plus city + license number. **No links out, no re-hosted assets** (honors the
+  RENTV no-rentv.com-link-out rule). Fails soft: if the sidecar is down or the
+  market has no matches, the widget **hides itself** so the story stays clean.
+
+---
+
+## Mount the router (RENTV `server.js`)
+
+RENTV's `server.js` is Express/CommonJS with `app.use(...)`. Add **one require +
+one mount line** — nothing else changes:
+
+```js
+// near the other requires
+const contractorsRouter = require('./contrib/contractors/router');
+
+// after express.json() is set up, alongside the other app.use / app.get routes:
+app.use('/contrib/contractors', contractorsRouter);   // module.exports IS a ready router
+// (or, to pass an explicit upstream base:)
+// app.use('/contrib/contractors', require('./contrib/contractors/router')({ apiBase: 'http://localhost:9913' }));
+```
+
+That mounts:
+
+| Route | Purpose |
+|-------|---------|
+| `GET /contrib/contractors/health` | self-describe / confirm wired |
+| `GET /contrib/contractors/api/contractors-for-market?county=&city=[&mode=deal][&limit=6]` | the lookup |
+
+> `module.exports` is a router-**factory**; `module.exports` is *also* directly a
+> ready `express.Router()` (factory `.call`-safe) — the `require('...')` form
+> above works as-is. Use the `({ apiBase })` call form only if you need to
+> override the upstream base per-mount.
+
+### Config (env, all optional)
+
+| Env var | Default | Meaning |
+|---------|---------|---------|
+| `CONTRACTORS_API_BASE` | `http://localhost:9913` | usre contractor API base |
+| `CONTRACTORS_API_AUTH` | *(none)* | `user:pass` if the usre API needs Basic Auth |
+| `CONTRACTORS_API_TIMEOUT_MS` | `6000` | upstream fetch timeout |
+| `CONTRACTORS_DEFAULT_LIMIT` | `6` | contractors returned when `limit` omitted |
+| `CONTRACTORS_CACHE_TTL_MS` | `300000` | in-memory cache TTL |
+
+> The usre API is behind Basic Auth (CRCP pattern). On the same host / loopback
+> it may not require creds; if it does, set `CONTRACTORS_API_AUTH="admin:…"`.
+> **Heads-up (2026-08-12):** the process currently listening on `:9913`
+> (pid observed 28661) is a **stale build predating the `/api/contractors`
+> route** and returns 404 for it. The route exists in the usre source
+> (`nationalrealestate/src/server/contractors.ts`); usre will need a restart
+> (its own team's call, not ours) before this returns live data. Until then the
+> widget fails soft and hides. **No action needed from RENTV to be safe.**
+
+---
+
+## Include the widget (a CRE deal story template)
+
+**Option A — data-attribute auto-init.** Drop the container where you want the
+block, then include the widget once (its `<script>` self-inits on DOM ready):
+
+```html
+<div class="rentv-contractors"
+     data-county="Los Angeles"
+     data-city="Los Angeles"
+     data-limit="6"></div>
+
+<!-- include once per page (e.g. server-side partial include of widget.html,
+     or paste its <style>+<script> into the layout) -->
+```
+
+If you serve `widget.html` as a static partial, the simplest wire is to
+copy its `<style>` + `<script>` into the story layout, or add a static route:
+
+```js
+// optional: serve the raw widget partial (new file, still no existing-file edits)
+app.get('/contrib/contractors/widget.html', (_q, r) =>
+  r.sendFile(require('path').join(__dirname, 'contrib/contractors/widget.html')));
+```
+
+**Option B — programmatic.** After the widget script is loaded:
+
+```html
+<div id="deal-contractors"></div>
+<script>
+  RentvContractors.render(document.getElementById('deal-contractors'), {
+    county: 'Los Angeles',
+    city:   'Los Angeles',
+    limit:  6,
+    // endpoint: '/contrib/contractors'  // default; override if mounted elsewhere
+  });
+</script>
+```
+
+The widget reads `county`/`city` straight off the deal record you already show
+on the story — pass whatever the article's parcel/deal object carries.
+
+---
+
+## Smoke test (after mount, before publish)
+
+```sh
+# router is wired?
+curl -s localhost:9704/contrib/contractors/health | jq .
+
+# lookup (returns {market,count,contractors:[{name,trade,phone,city,license_no}],attribution}):
+curl -s 'localhost:9704/contrib/contractors/api/contractors-for-market?county=Los%20Angeles&city=Los%20Angeles&limit=6' | jq .
+```
+
+(Use whatever port/host RENTV runs on — `:9704` locally.)
+
+---
+
+## Rails honored
+
+- **No edits to existing RENTV files** — everything is new under
+  `contrib/contractors/`. Mounting is 2 lines RENTV adds when ready.
+- **No new dependencies** — Node built-in `fetch` + `express` (already present).
+- **Text/attribution only** — name, trade, phone, city, license #. No link-out,
+  no re-hosted assets.
+- **Fail-soft** — a down/slow/stale upstream never 500s a news story; the widget
+  hides itself.
+- **Publish/deploy is gated** — this package ships wire-ready; the go-live is
+  RENTV's + Steve's call. See the go-live memo in
+  `~/.claude/yolo-queue/pending-approval/` (TK-10488).
diff --git a/contrib/contractors/router.js b/contrib/contractors/router.js
new file mode 100644
index 0000000..27fd6cf
--- /dev/null
+++ b/contrib/contractors/router.js
@@ -0,0 +1,194 @@
+'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;
+
+// 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 = [];
+  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);
+
+      // 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) return out;
+    }
+  }
+  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 result = {
+        market: {
+          city: (payload.criteria && payload.criteria.city) || city || null,
+          county: (payload.criteria && payload.criteria.county) || county || null,
+          mode,
+        },
+        count: contractors.length,
+        contractors,
+        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: [],
+      });
+    }
+  });
+
+  return router;
+}
+
+module.exports = createContractorsRouter;
+module.exports.createContractorsRouter = createContractorsRouter;
+// Convenience: a ready-to-mount default instance using env config.
+module.exports.router = createContractorsRouter();
diff --git a/contrib/contractors/widget.html b/contrib/contractors/widget.html
new file mode 100644
index 0000000..d96d85e
--- /dev/null
+++ b/contrib/contractors/widget.html
@@ -0,0 +1,128 @@
+<!-- =========================================================================
+     RENTV contrib — "Licensed contractors in {market}" widget  (TK-10488)
+     -------------------------------------------------------------------------
+     A SELF-CONTAINED, drop-in widget for a CRE deal news story. Paste this
+     whole block into a story template (or include the <script> from a partial).
+     It calls the contrib router's /api/contractors-for-market endpoint and
+     renders a TEXT-ONLY list (name + trade + phone). No external links, no
+     re-hosted assets, honoring the RENTV no-rentv.com-link-out rule.
+
+     USAGE (two ways):
+
+     1) Auto-init from data attributes — drop the markup on the page:
+          <div class="rentv-contractors"
+               data-county="Los Angeles" data-city="Los Angeles"
+               data-limit="6"></div>
+        ...then include this file once (or its <script>) anywhere after it.
+
+     2) Programmatic — call the global:
+          RentvContractors.render(document.getElementById('box'),
+            { county: 'Los Angeles', city: 'Los Angeles', limit: 6 });
+
+     The endpoint base defaults to same-origin '/contrib/contractors'; override
+     with data-endpoint="..." or the { endpoint } option.
+     ========================================================================= -->
+<style>
+  .rentv-contractors { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; }
+  .rentv-contractors__title { font-size: 15px; font-weight: 700; margin: 0 0 .5em; letter-spacing: .01em; }
+  .rentv-contractors__list { list-style: none; margin: 0; padding: 0; }
+  .rentv-contractors__item { padding: .55em 0; border-top: 1px solid #e6e6e6; }
+  .rentv-contractors__item:first-child { border-top: none; }
+  .rentv-contractors__name { font-weight: 600; }
+  .rentv-contractors__trade { color: #555; }
+  .rentv-contractors__phone { color: #333; }
+  .rentv-contractors__meta { color: #888; font-size: 12px; }
+  .rentv-contractors__attr { color: #999; font-size: 11px; margin-top: .6em; }
+  .rentv-contractors[hidden] { display: none; }
+</style>
+
+<script>
+(function () {
+  'use strict';
+  if (window.RentvContractors) return; // idempotent — safe to include more than once
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+    });
+  }
+
+  function marketLabel(m) {
+    if (!m) return 'this market';
+    return m.city || m.county || 'this market';
+  }
+
+  function renderList(el, data) {
+    var contractors = (data && data.contractors) || [];
+    if (!contractors.length) { el.hidden = true; return; } // nothing to show -> hide, story stays clean
+
+    var label = marketLabel(data.market);
+    var rows = contractors.map(function (c) {
+      var bits = [];
+      bits.push('<span class="rentv-contractors__name">' + esc(c.name) + '</span>');
+      if (c.trade) bits.push('<span class="rentv-contractors__trade"> &middot; ' + esc(c.trade) + '</span>');
+      var phone = c.phone
+        ? '<div class="rentv-contractors__phone">' + esc(c.phone) + '</div>'
+        : '';
+      var meta = [];
+      if (c.city) meta.push(esc(c.city));
+      if (c.license_no) meta.push('Lic. ' + esc(c.license_no));
+      var metaLine = meta.length
+        ? '<div class="rentv-contractors__meta">' + meta.join(' &middot; ') + '</div>'
+        : '';
+      return '<li class="rentv-contractors__item">' + bits.join('') + phone + metaLine + '</li>';
+    }).join('');
+
+    el.innerHTML =
+      '<div class="rentv-contractors__title">Licensed contractors in ' + esc(label) + '</div>' +
+      '<ul class="rentv-contractors__list">' + rows + '</ul>' +
+      '<div class="rentv-contractors__attr">' +
+        esc((data && data.attribution) || 'CA CSLB licensed contractors') +
+      '</div>';
+    el.hidden = false;
+  }
+
+  function render(el, opts) {
+    if (!el) return;
+    opts = opts || {};
+    var endpoint = (opts.endpoint || el.getAttribute('data-endpoint') || '/contrib/contractors')
+      .replace(/\/+$/, '');
+    var county = opts.county || el.getAttribute('data-county') || '';
+    var city = opts.city || el.getAttribute('data-city') || '';
+    var limit = opts.limit || el.getAttribute('data-limit') || '';
+    var mode = opts.mode || el.getAttribute('data-mode') || 'deal';
+
+    if (!county && !city) { el.hidden = true; return; }
+
+    var qs = new URLSearchParams({ mode: mode });
+    if (county) qs.set('county', county);
+    if (city) qs.set('city', city);
+    if (limit) qs.set('limit', limit);
+
+    fetch(endpoint + '/api/contractors-for-market?' + qs.toString(), {
+      headers: { accept: 'application/json' },
+      credentials: 'same-origin'
+    })
+      .then(function (r) { return r.ok ? r.json() : { contractors: [] }; })
+      .then(function (data) { renderList(el, data); })
+      .catch(function () { el.hidden = true; }); // fail soft — never break the story
+  }
+
+  function autoInit(root) {
+    var scope = root || document;
+    var nodes = scope.querySelectorAll('.rentv-contractors:not([data-rendered])');
+    Array.prototype.forEach.call(nodes, function (el) {
+      el.setAttribute('data-rendered', '1');
+      render(el);
+    });
+  }
+
+  window.RentvContractors = { render: render, autoInit: autoInit };
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', function () { autoInit(); });
+  } else {
+    autoInit();
+  }
+})();
+</script>

(oldest)  ·  back to Rentv V1  ·  Date every contractor entry: source_as_of + verify-at-cslb.c ff4651c →