[object Object]

← back to Commercialrealestate

CRCP email-alerts: opt-in gov-agent segment (DTD verdict C) — per-metro new-licensee diff, default stays low-noise

0a6e0fcfb9977beb50953bbcdbe6cd69fc051cfe · 2026-07-12 11:58:37 -0700 · Steve

Files touched

Diff

commit 0a6e0fcfb9977beb50953bbcdbe6cd69fc051cfe
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 12 11:58:37 2026 -0700

    CRCP email-alerts: opt-in gov-agent segment (DTD verdict C) — per-metro new-licensee diff, default stays low-noise
---
 scripts/email-alerts.js | 58 ++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 50 insertions(+), 8 deletions(-)

diff --git a/scripts/email-alerts.js b/scripts/email-alerts.js
index 1716b81..ffef581 100644
--- a/scripts/email-alerts.js
+++ b/scripts/email-alerts.js
@@ -104,19 +104,38 @@ function loadConfig() {
   console.log(`seeded default config → ${CONFIG_PATH}`);
   return seed;
 }
-function loadState() { return loadJSON(STATE_PATH, { last_run: null, seen: { listings: [], brokers: [] } }); }
+function loadState() { const s = loadJSON(STATE_PATH, { last_run: null, seen: { listings: [], brokers: [] } }); s.seen = s.seen || {}; s.seen.listings = s.seen.listings || []; s.seen.brokers = s.seen.brokers || []; s.seen.gov = s.seen.gov || {}; return s; }
+
+// Gov-agent segment (DTD verdict C, opt-in): read data/gov/<metro>.json for the
+// metros any opt-in subscriber wants, return the NEW licensees vs seen.gov[metro].
+function loadGovForMetros(metros) {
+  const out = {}; // metro -> [{name, firm, license_number, license_type, city}]
+  for (const m of metros) {
+    const p = path.join(DATA, 'gov', `${m}.json`);
+    if (!fs.existsSync(p)) continue;
+    const arr = loadJSON(p, []);
+    if (Array.isArray(arr)) out[m] = arr.filter(a => a && a.license_number).map(a => ({
+      name: a.name, firm: a.firm, license_number: String(a.license_number), license_type: a.license_type, city: a.city,
+    }));
+  }
+  return out;
+}
 
 // ---- digest --------------------------------------------------------------
 function money(n) { const v = Number(n); return isFinite(v) && v > 0 ? '$' + v.toLocaleString() : '—'; }
 function metroLabel(m) { return (m || 'other').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); }
 
-function buildDigestHTML(sub, newListings, newBrokers) {
+function buildDigestHTML(sub, newListings, newBrokers, govNewByMetro) {
   const wants = new Set(sub.metros);
   const all = wants.has('*');
   const fL = newListings.filter(l => all || wants.has(l.metro));
   const fB = newBrokers.filter(b => all || wants.has(b.metro));
   const showListings = sub.segments.includes('listings') ? fL : [];
   const showBrokers = sub.segments.includes('brokers') ? fB : [];
+  // opt-in gov-agent segment (DTD verdict C)
+  const showGov = sub.segments.includes('gov-agents')
+    ? Object.entries(govNewByMetro || {}).filter(([m]) => all || wants.has(m)).flatMap(([m, arr]) => arr.map(a => ({ ...a, metro: m })))
+    : [];
 
   const byMetro = arr => arr.reduce((m, x) => { (m[x.metro || 'other'] ||= []).push(x); return m; }, {});
   const lMetros = byMetro(showListings);
@@ -129,8 +148,8 @@ function buildDigestHTML(sub, newListings, newBrokers) {
     </div>
     <div style="border:1px solid #e6e8ec;border-top:none;border-radius:0 0 12px 12px;padding:24px 26px">`;
 
-  if (!showListings.length && !showBrokers.length) {
-    body += `<p style="color:#6b7280">No new listings or brokers in your markets since the last update.</p>`;
+  if (!showListings.length && !showBrokers.length && !showGov.length) {
+    body += `<p style="color:#6b7280">No new activity in your markets since the last update.</p>`;
   }
 
   if (showListings.length) {
@@ -155,11 +174,21 @@ function buildDigestHTML(sub, newListings, newBrokers) {
     }
   }
 
+  if (showGov.length) {
+    const govByMetro = showGov.reduce((m, x) => { (m[x.metro || 'other'] ||= []).push(x); return m; }, {});
+    body += `<h2 style="font-size:16px;margin:20px 0 12px">🪪 ${showGov.length} newly-licensed agent${showGov.length>1?'s':''}</h2>`;
+    for (const [metro, items] of Object.entries(govByMetro)) {
+      body += `<div style="font-size:12px;font-weight:700;color:#C8A24B;text-transform:uppercase;letter-spacing:1px;margin:14px 0 6px">${metroLabel(metro)}</div>`;
+      for (const g of items.slice(0, 40)) {
+        body += `<div style="font-size:13px;padding:5px 0;border-bottom:1px solid #f2f4f6"><b>${g.name || 'Unknown'}</b>${g.firm?` · ${g.firm}`:''}${g.license_type?` <span style="color:#8A93A2">(${g.license_type})</span>`:''}${g.city?` <span style="color:#8A93A2">— ${g.city}</span>`:''}</div>`;
+      }
+    }
+  }
   body += `<div style="margin-top:22px;padding-top:16px;border-top:1px solid #e6e8ec;font-size:12px;color:#8A93A2">
       You're getting the <b>${sub.cadence}</b> digest for ${all?'all markets':sub.metros.map(metroLabel).join(', ')}.
       Open <a href="https://crcp.agentabrams.com" style="color:#C8A24B">CRCP</a> to work these leads.
     </div></div></div>`;
-  return { html: body, listingCount: showListings.length, brokerCount: showBrokers.length };
+  return { html: body, listingCount: showListings.length, brokerCount: showBrokers.length, govCount: showGov.length };
 }
 
 // ---- George send (GATED) -------------------------------------------------
@@ -193,6 +222,18 @@ function georgeSend({ to, subject, html, from }) {
   const newListings = listings.filter(l => !seenL.has(l.id));
   const newBrokers = brokers.filter(b => !seenB.has(b.id));
 
+  // Gov-agents segment (opt-in, DTD verdict C): only diff metros some subscriber wants.
+  const govSubs = cfg.subscribers.filter(s => (s.segments || []).includes('gov-agents'));
+  const govMetros = new Set();
+  for (const s of govSubs) { if (s.metros.includes('*')) { Object.keys(state.seen.gov).forEach(m => govMetros.add(m)); ['los-angeles','miami','texas','nyc','las-vegas'].forEach(m => govMetros.add(m)); } else s.metros.forEach(m => govMetros.add(m)); }
+  const govAll = govSubs.length ? loadGovForMetros([...govMetros]) : {};
+  const govNewByMetro = {};
+  for (const [m, arr] of Object.entries(govAll)) {
+    const seenG = new Set(state.seen.gov[m] || []);
+    const fresh = arr.filter(a => !seenG.has(a.license_number));
+    if (fresh.length) govNewByMetro[m] = fresh;
+  }
+
   console.log(`snapshot: ${listings.length} listings (${newListings.length} new), ${brokers.length} brokers (${newBrokers.length} new)`);
   console.log(`mode: ${SEND ? 'SEND (gated)' : 'DRY-RUN (no send, state unchanged unless --commit-state)'}`);
 
@@ -200,9 +241,9 @@ function georgeSend({ to, subject, html, from }) {
   const results = [];
   let sendFailures = 0;
   for (const sub of cfg.subscribers) {
-    const { html, listingCount, brokerCount } = buildDigestHTML(sub, newListings, newBrokers);
-    if (!listingCount && !brokerCount) { console.log(`  ${sub.email}: nothing new — skip`); continue; }
-    const subject = `CRCP: ${listingCount} new listing${listingCount!==1?'s':''}${brokerCount?` · ${brokerCount} new broker${brokerCount!==1?'s':''}`:''}`;
+    const { html, listingCount, brokerCount, govCount } = buildDigestHTML(sub, newListings, newBrokers, govNewByMetro);
+    if (!listingCount && !brokerCount && !govCount) { console.log(`  ${sub.email}: nothing new — skip`); continue; }
+    const subject = `CRCP: ${listingCount} new listing${listingCount!==1?'s':''}${brokerCount?` · ${brokerCount} new broker${brokerCount!==1?'s':''}`:''}${govCount?` · ${govCount} new agent${govCount!==1?'s':''}`:''}`;
     const file = path.join(OUT, `${date}-${sub.email.replace(/[^a-z0-9]/gi,'_')}.html`);
     fs.writeFileSync(file, html);
     results.push({ email: sub.email, subject, listingCount, brokerCount, file });
@@ -221,6 +262,7 @@ function georgeSend({ to, subject, html, from }) {
   if (advance) {
     state.seen.listings = listings.map(l => l.id);
     state.seen.brokers = brokers.map(b => b.id);
+    for (const [m, arr] of Object.entries(govAll)) state.seen.gov[m] = arr.map(a => a.license_number);
     state.last_run = new Date().toISOString();
     fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
     console.log(`state advanced → ${path.relative(ROOT, STATE_PATH)} (${SEND ? 'post-send' : 'baseline seed'})`);

← 9500317 broker-email: contrarian-driven hardening — exact last-name-  ·  back to Commercialrealestate  ·  CRCP listings: daily rotating refresh (DTD verdict C) — ~3 m cd428e0 →