[object Object]

← back to Commercialrealestate

auto-save: 2026-06-28T07:43:27 (2 files) — public/brokers.html scripts/db/brokers-db.js

44b6973e4a91c4f635d6d7e5f4133b11053628b3 · 2026-06-28 07:43:34 -0700 · Steve Abrams

Files touched

Diff

commit 44b6973e4a91c4f635d6d7e5f4133b11053628b3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 28 07:43:34 2026 -0700

    auto-save: 2026-06-28T07:43:27 (2 files) — public/brokers.html scripts/db/brokers-db.js
---
 public/brokers.html      | 25 ++++++++++++++++++++++++-
 scripts/db/brokers-db.js | 40 +++++++++++++++++++++++++++++++++++++---
 2 files changed, 61 insertions(+), 4 deletions(-)

diff --git a/public/brokers.html b/public/brokers.html
index 3b15bff..21b29ec 100644
--- a/public/brokers.html
+++ b/public/brokers.html
@@ -83,7 +83,27 @@ function showDetail(id){
   } else {
     const co=g.edges.filter(e=>e.kind==='colist'&&(e.source===id||e.target===id));
     const partners=co.map(e=>{const oid=e.source===id?e.target:e.source;const o=g.nodes.find(x=>x.id===oid);return o?`${o.label} (${e.weight}×)`:''}).filter(Boolean);
-    d.innerHTML=`<b>${n.label}</b><div class="k">${n.firm||'—'}</div><div style="margin-top:6px">${n.listings} listings in our set · ${n.total_assets||'?'} total on Crexi</div>`+(partners.length?`<div style="margin-top:8px" class="k">co-lists with:</div>${partners.map(p=>'<div>· '+p+'</div>').join('')}`:'<div style="margin-top:8px" class="k">no co-listings in set</div>');
+    // Inline contact (from the graph payload), then lazy-load the full enriched card + book.
+    const esc=s=>(s||'').replace(/</g,'&lt;');
+    const contact=[
+      n.phone?`<div>📞 <a href="tel:${esc(n.phone)}" style="color:var(--blue)">${esc(n.phone)}</a></div>`:'',
+      n.email?`<div>✉️ <a href="mailto:${esc(n.email)}" style="color:var(--blue)">${esc(n.email)}</a></div>`:'',
+      n.website?`<div>🌐 <a href="${esc(n.website)}" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">${esc(n.website.replace(/^https?:\/\//,'').slice(0,34))}</a></div>`:'',
+      n.linkedin?`<div>💼 <a href="${esc(n.linkedin)}" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">LinkedIn</a></div>`:'',
+      n.office_addr?`<div class="k" style="font-size:11px">🏢 ${esc(n.office_addr)}</div>`:''
+    ].filter(Boolean).join('');
+    d.innerHTML=`<b>${esc(n.label)}</b><div class="k">${esc(n.firm)||'—'}</div>`+
+      `<div style="margin-top:6px">${n.listings} listings here · ${n.total_assets||'?'} total on Crexi</div>`+
+      (contact?`<div style="margin-top:8px;padding:8px;background:var(--card);border-radius:8px">${contact}</div>`:'<div class="k" style="margin-top:8px;font-size:11px">no contact info enriched</div>')+
+      (partners.length?`<div style="margin-top:8px" class="k">co-lists with:</div>${partners.map(p=>'<div>· '+esc(p)+'</div>').join('')}`:'')+
+      `<div id="bookwrap" class="k" style="margin-top:10px;font-size:11px">loading book…</div>`;
+    if(n.dbId!=null) fetch('/api/brokers/contact/'+n.dbId).then(r=>r.json()).then(c=>{
+      const bw=document.getElementById('bookwrap'); if(!bw) return;
+      const prov=(c.provenance||[]).map(p=>p.field+'←'+(p.tier||'').replace(/^tier\d-/,'')).join(', ');
+      const book=(c.other_listings||[]).slice(0,8);
+      bw.innerHTML=(book.length?`<div class="k">other listings on the open web (${c.other_listings.length}):</div>`+book.map(x=>`<div>· ${esc(x.title||x.address||'')} ${x.city?'('+esc(x.city)+')':''}</div>`).join(''):`<div class="k">book: ${c.total_assets||'?'} total on Crexi (no public profile book pulled)</div>`)+
+        (prov?`<div class="k" style="margin-top:6px;opacity:.7">source: ${esc(prov)}</div>`:'');
+    }).catch(()=>{ const bw=document.getElementById('bookwrap'); if(bw) bw.textContent=''; });
   }
   network.selectNodes([id]); network.focus(id,{scale:1.1,animation:true});
 }
@@ -100,6 +120,9 @@ fetch('/api/graph?limit=500').then(r=>r.json()).then(g=>{
   else buildVis(g);
   const s=g.stats||{};
   $('#stats').innerHTML=`<span><b>${s.brokers||0}</b> brokers</span><span><b>${s.firms||0}</b> firms</span><span><b>${s.listings||0}</b> listings</span><span><b>${s.edges||0}</b> links</span>`;
+  fetch('/api/brokers/enrich-stats').then(r=>r.json()).then(e=>{
+    if(e&&e.total) $('#stats').innerHTML += `<span><b>${e.contactable}</b> contactable</span><span style="color:var(--mut)">✉️${e.email} 📞${e.phone} 💼${e.linkedin}</span>`;
+  }).catch(()=>{});
 }).catch(e=>{ $('#net').innerHTML='<div class="empty">graph error: '+e.message+'</div>'; });
 
 fetch('/api/brokers/top?limit=40').then(r=>r.json()).then(rows=>{
diff --git a/scripts/db/brokers-db.js b/scripts/db/brokers-db.js
index faf26ed..48028e5 100644
--- a/scripts/db/brokers-db.js
+++ b/scripts/db/brokers-db.js
@@ -50,7 +50,10 @@ async function link(brokerId, listingId, role) {
 // The mind-map graph: broker + firm nodes, edges = broker→listing collapsed into broker↔firm and
 // broker↔broker (co-listing). Returns {nodes, edges, stats} sized for a force-directed view.
 async function graph(limit = 400) {
-  const brokers = (await pool.query(`SELECT * FROM broker_node ORDER BY listings DESC NULLS LAST LIMIT $1`, [limit])).rows;
+  const brokers = (await pool.query(
+    `SELECT bn.*, b.website, b.linkedin, b.office_addr, b.total_assets
+       FROM broker_node bn JOIN broker b ON b.id = bn.id
+      ORDER BY bn.listings DESC NULLS LAST LIMIT $1`, [limit])).rows;
   const ids = new Set(brokers.map(b => b.id));
   const co = (await pool.query(`SELECT a, b, shared_listings FROM broker_cobroker`)).rows
     .filter(e => ids.has(e.a) && ids.has(e.b));
@@ -58,7 +61,8 @@ async function graph(limit = 400) {
   brokers.forEach(b => { if (b.firm) firms[b.firm] = (firms[b.firm] || 0) + 1; });
   const nodes = [];
   Object.entries(firms).forEach(([name, n]) => nodes.push({ id: 'firm:' + name, kind: 'firm', label: name, weight: n }));
-  brokers.forEach(b => nodes.push({ id: 'broker:' + b.id, kind: 'broker', label: b.name, firm: b.firm, listings: +b.listings, total: b.total_assets }));
+  brokers.forEach(b => nodes.push({ id: 'broker:' + b.id, kind: 'broker', dbId: b.id, label: b.name, firm: b.firm, listings: +b.listings, total: b.total_assets,
+    phone: b.phone, email: b.email, website: b.website, linkedin: b.linkedin, office_addr: b.office_addr }));
   const edges = [];
   brokers.forEach(b => { if (b.firm) edges.push({ source: 'broker:' + b.id, target: 'firm:' + b.firm, kind: 'employs' }); });
   co.forEach(e => edges.push({ source: 'broker:' + e.a, target: 'broker:' + e.b, kind: 'colist', weight: e.shared_listings }));
@@ -75,4 +79,34 @@ async function topBrokers(limit = 50) {
   return (await pool.query(`SELECT name, firm, listings, phone, email, title FROM broker_node ORDER BY listings DESC NULLS LAST LIMIT $1`, [limit])).rows;
 }
 
-module.exports = { pool, upsertFirm, upsertBroker, upsertListing, link, graph, topBrokers };
+// Full enriched contact card for one broker (id), with per-field provenance + listing book.
+async function brokerContact(id) {
+  const b = (await pool.query(
+    `SELECT b.id, b.name, b.title, b.phone, b.email, b.website, b.linkedin, b.office_addr,
+            b.total_assets, b.enriched_at, f.name AS firm
+       FROM broker b LEFT JOIN firm f ON f.id=b.firm_id WHERE b.id=$1`, [id])).rows[0];
+  if (!b) return null;
+  b.provenance = (await pool.query(
+    `SELECT field, source_url, tier FROM broker_field_source WHERE broker_id=$1`, [id])).rows;
+  b.our_listings = (await pool.query(
+    `SELECT l.id, l.address, l.city, l.type, l.price FROM broker_listing bl
+       JOIN listing l ON l.id=bl.listing_id WHERE bl.broker_id=$1 ORDER BY l.price DESC NULLS LAST LIMIT 50`, [id])).rows;
+  b.other_listings = (await pool.query(
+    `SELECT title, address, city, state, price, asset_type, url FROM broker_other_listing
+      WHERE broker_id=$1 ORDER BY price DESC NULLS LAST LIMIT 50`, [id])).rows;
+  return b;
+}
+
+// Enrichment coverage rollup for the stats bar.
+async function enrichStats() {
+  return (await pool.query(
+    `SELECT count(*)::int total,
+            count(*) FILTER (WHERE phone IS NOT NULL)::int phone,
+            count(*) FILTER (WHERE email IS NOT NULL)::int email,
+            count(*) FILTER (WHERE website IS NOT NULL)::int website,
+            count(*) FILTER (WHERE linkedin IS NOT NULL)::int linkedin,
+            count(*) FILTER (WHERE phone IS NOT NULL OR email IS NOT NULL)::int contactable
+       FROM broker`)).rows[0];
+}
+
+module.exports = { pool, upsertFirm, upsertBroker, upsertListing, link, graph, topBrokers, brokerContact, enrichStats };

← d7ca649 CRCP: broker contact columns (phone/email/web) + click-throu  ·  back to Commercialrealestate  ·  CRE residential agents: feasibility probe (mainHouseInfo end 2d837a2 →