← back to Commercialrealestate

scripts/db/brokers-db.js

129 lines

// brokers-db.js — connection + ingest + graph queries for the broker relationship DB (local "cre").
'use strict';
const { Pool } = require('pg');
const pool = new Pool({
  host: process.env.CRE_PGHOST || '/tmp', port: +(process.env.CRE_PGPORT || 5432),
  // The `cre` DB lives ONLY on the local dev/host box (owner = the mac user). On prod (crcp.agentabrams.com)
  // there is NO cre DB, and the process runs as `root` — a role Postgres doesn't have. Guard that: never fall
  // through to `root` (the old `process.env.USER` default silently became `root` on prod and 502'd every broker
  // endpoint until the snapshot fallback caught it). On prod this connect just fails fast → serve.js serves the
  // exported snapshot instead. See export/refresh-broker-snapshot + readBrokerSnap() in serve.js.
  database: process.env.CRE_PGDATABASE || 'cre',
  user: process.env.CRE_PGUSER || (process.env.USER && process.env.USER !== 'root' ? process.env.USER : 'macstudio3'),
  max: 6, idleTimeoutMillis: 10000, connectionTimeoutMillis: 4000
});
pool.on('error', () => {});

async function upsertFirm(name) {
  if (!name) return null;
  const r = await pool.query(
    `INSERT INTO firm(name) VALUES($1) ON CONFLICT(name) DO UPDATE SET name=EXCLUDED.name RETURNING id`, [name.trim()]);
  return r.rows[0].id;
}

async function upsertBroker(b) {
  const firmId = await upsertFirm(b.firm);
  const r = await pool.query(
    `INSERT INTO broker(name, firm_id, title, phone, email, crexi_id, source, total_assets)
     VALUES($1,$2,$3,$4,$5,$6,$7,$8)
     ON CONFLICT(name, firm_id) DO UPDATE SET
       phone=COALESCE(EXCLUDED.phone, broker.phone),
       email=COALESCE(EXCLUDED.email, broker.email),
       crexi_id=COALESCE(EXCLUDED.crexi_id, broker.crexi_id),
       title=COALESCE(EXCLUDED.title, broker.title),
       total_assets=GREATEST(COALESCE(EXCLUDED.total_assets,0), COALESCE(broker.total_assets,0))
     RETURNING id`,
    [b.name.trim(), firmId, b.title || null, b.phone || null, b.email || null, b.crexi_id || null, b.source || 'crexi', b.total_assets || null]);
  return r.rows[0].id;
}

async function upsertListing(l) {
  await pool.query(
    `INSERT INTO listing(id, address, city, zip, type, price, cap_rate, units, firm_name, source)
     VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
     ON CONFLICT(id) DO UPDATE SET firm_name=COALESCE(EXCLUDED.firm_name, listing.firm_name)`,
    [l.id, l.address || null, l.city || null, l.zip || null, l.type || null,
     l.price || null, l.cap_rate || null, l.units || null, l.firm_name || null, l.source || null]);
  return l.id;
}

async function link(brokerId, listingId, role) {
  await pool.query(
    `INSERT INTO broker_listing(broker_id, listing_id, role) VALUES($1,$2,$3)
     ON CONFLICT DO NOTHING`, [brokerId, listingId, role || 'broker']);
}

// 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 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));
  const firms = {};
  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', dbId: b.id, label: b.name, firm: b.firm, listings: +b.listings, total: b.total_assets,
    agent_type: b.agent_type, 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 }));
  const stats = {
    brokers: (await pool.query(`SELECT count(*) c FROM broker`)).rows[0].c,
    firms: (await pool.query(`SELECT count(*) c FROM firm`)).rows[0].c,
    listings: (await pool.query(`SELECT count(*) c FROM listing`)).rows[0].c,
    edges: (await pool.query(`SELECT count(*) c FROM broker_listing`)).rows[0].c
  };
  return { nodes, edges, stats };
}

async function topBrokers(limit = 50) {
  // id + website ride along so snapshot consumers (prod CRCP top-brokers table) can open the
  // contact card and drive the "✉ find email" button — broker_node lacks website, hence the join.
  return (await pool.query(
    `SELECT n.id, n.name, n.firm, n.listings, n.phone, n.email, n.title, n.agent_type, b.website
       FROM broker_node n LEFT JOIN broker b ON b.id = n.id
      ORDER BY n.listings DESC NULLS LAST LIMIT $1`, [limit])).rows;
}

// 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, b.agent_type, b.license, 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;
  // our_listings spans commercial listings + residential condos (so a residential agent shows their book).
  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
     UNION ALL
     SELECT c.id, c.address, c.city, 'Condo' type, c.price FROM broker_condo bc
       JOIN condo c ON c.id=bc.condo_id WHERE bc.broker_id=$1
     ORDER BY 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 };