← back to Rentv

contrib/contractors/README.md

181 lines

# 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, and license status. **Every entry is dated**:
  it shows `CSLB data as of {source_as_of} — verify at cslb.ca.gov`
  (text-only, no link). **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.

### DATE EVERYTHING — `source_as_of`

The upstream `/api/contractors/match` response does **not** carry a
dataset-level freshness date, so the router adds one. Resolution order:

1. a per-record `source_as_of` if the upstream ever starts emitting it, else
2. the `CONTRACTORS_SOURCE_AS_OF` env var, else
3. the known load date of the shared registry: **`2026-08-07`**
   (`ca_contractors.source_as_of`, verified 230,964 rows all dated 2026-08-07).

Every response includes `source_as_of` + `verify_url: "cslb.ca.gov"`, and the
widget stamps `CSLB data as of {source_as_of} — verify at cslb.ca.gov` under
**each** contractor. **When the CSLB registry is refreshed, bump
`CONTRACTORS_SOURCE_AS_OF`** so the date readers see stays honest.

### status / CLEAR

The upstream `/match` already filters to `license_status = 'Active'` server-side,
so only in-good-standing licenses come back. The router passes each record's
`license_status` through to the widget (rendered in the meta line), so a
reader sees the standing (e.g. `Active` / `CLEAR`) per contractor. No client
can widen the filter — the "clean only" guarantee lives in the shared API.

---

## 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 |
| `CONTRACTORS_SOURCE_AS_OF` | `2026-08-07` | CSLB dataset freshness date shown per entry — bump on registry refresh |

> 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,county,license_no,license_status}],
#     source_as_of:"2026-08-07", verify_url:"cslb.ca.gov", 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).