← back to Sublease Agentabrams

server.js

235 lines

'use strict';
// sublease.agentabrams.com — gated CRE marketplace over CRUnifiedDB.
//   local/dev  → Postgres CRUnifiedDB (crawlers + CRCP import write here)
//   production → USE_SNAPSHOT=1 serves data/snapshot.json (no DB; zero load on shared PG)
const fs = require('fs');
const path = require('path');
const express = require('express');

const app = express();
const PORT = process.env.PORT || 9714;
const PUB = path.join(__dirname, 'public');
const USE_SNAPSHOT = process.env.USE_SNAPSHOT === '1';

let snap = null;
function loadSnap() {
  try { snap = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'snapshot.json'), 'utf8')); }
  catch { snap = { sponsors: [], brokers: [], listings: [], map_points: [], recent_runs: [], stats: {} }; }
}
let pool = null, q = null;
if (USE_SNAPSHOT) loadSnap();
else {
  const { Pool } = require('pg');
  pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'crunified', max: 6 });
  q = (sql, params) => pool.query(sql, params).then(r => r.rows);
}

app.use(express.json({ limit: '64kb' }));
const CLAIMS_FILE = path.join(__dirname, 'data', 'claims.jsonl');

// Credential → role. admin = full access; client = browse + claim, no admin surfaces.
// Extra logins via BASIC_AUTH_EXTRA as "user:pass" (client) or "user:pass:admin".
const ROLE_CREDS = new Map([['admin:DW2024!', 'admin'], ['Boomer:Sublease2024!', 'client']]);
(process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean).forEach(c => {
  const parts = c.split(':'); const role = parts.length >= 3 ? parts.pop() : 'client'; ROLE_CREDS.set(parts.join(':'), role);
});
const ACCEPTED = new Map([...ROLE_CREDS].map(([c, role]) => ['Basic ' + Buffer.from(c).toString('base64'), { role, user: c.split(':')[0] }]));
app.get('/api/health', (_q, r) => r.json({ ok: true, mode: USE_SNAPSHOT ? 'snapshot' : 'db', at: new Date().toISOString() }));
app.get('/healthz', (_q, r) => r.json({ ok: true }));
app.use((req, res, next) => {
  const hit = ACCEPTED.get(req.headers.authorization || '');
  if (hit) { req.role = hit.role; req.user = hit.user; return next(); }
  res.set('WWW-Authenticate', 'Basic realm="Sublease Marketplace"');
  return res.status(401).send('Authentication required');
});
// role gate for admin-only surfaces
const requireAdmin = (req, res, next) => req.role === 'admin' ? next()
  : res.status(403).send('<h2 style="font:600 18px system-ui;color:#b3261e;padding:40px">Admin access only</h2><p style="font:14px system-ui;padding:0 40px"><a href="/">← back to marketplace</a></p>');
app.get('/api/me', (req, res) => res.json({ user: req.user, role: req.role }));

// ── Individual client accounts (file-based, snapshot-safe) — separate from the basic-auth wall ──
const accounts = require('./lib/accounts');
app.post('/api/auth/signup', (req, res) => {
  const { email, password, name } = req.body || {};
  const r = accounts.createUser({ email, password, name });
  if (r.error) return res.status(400).json({ error: r.error });
  accounts.setSession(res, req, r.user.id);
  res.json({ ok: true, user: accounts.publicUser(r.user) });
});
app.post('/api/auth/login', (req, res) => {
  const { email, password } = req.body || {};
  const u = accounts.authenticate(email, password);
  if (!u) return res.status(401).json({ error: 'invalid email or password' });
  accounts.setSession(res, req, u.id);
  res.json({ ok: true, user: accounts.publicUser(u) });
});
app.post('/api/auth/logout', (req, res) => { accounts.clearSession(res); res.json({ ok: true }); });
app.get('/api/auth/me', (req, res) => res.json({ user: accounts.publicUser(accounts.currentUser(req)) }));

const LISTING_COLS = `l.id, l.listing_kind, l.is_sublease, l.title, l.address, l.city, l.state, l.zip, l.lat, l.lng,
  l.space_type, l.asset_class, l.size_sf, l.units, l.cap_rate, l.year_built, l.price_amount, l.price_unit, l.term,
  l.source_url, l.image_url, l.source_label, s.slug AS sponsor_slug, s.name AS sponsor_name, s.logo_path`;

app.get('/api/sponsors', async (_q, res) => {
  try {
    if (USE_SNAPSHOT) return res.json({ sponsors: snap.sponsors });
    res.json({ sponsors: await q(`SELECT s.*, COUNT(l.id) FILTER (WHERE l.status='active')::int AS listing_count
      FROM sponsors s LEFT JOIN listings l ON l.sponsor_id=s.id GROUP BY s.id ORDER BY s.role, s.name`) });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/sponsors/:slug', async (req, res) => {
  try {
    if (USE_SNAPSHOT) {
      const s = snap.sponsors.find(x => x.slug === req.params.slug);
      if (!s) return res.status(404).json({ error: 'not found' });
      return res.json({ sponsor: s, listings: snap.listings.filter(l => l.sponsor_slug === s.slug), agents: [] });
    }
    const [s] = await q(`SELECT * FROM sponsors WHERE slug=$1`, [req.params.slug]);
    if (!s) return res.status(404).json({ error: 'not found' });
    const listings = await q(`SELECT ${LISTING_COLS} FROM listings l LEFT JOIN sponsors s ON s.id=l.sponsor_id
      WHERE l.sponsor_id=$1 ORDER BY l.size_sf DESC NULLS LAST, l.id`, [s.id]);
    res.json({ sponsor: s, listings, agents: [] });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

function filterSnapListings(query) {
  const { type, sponsor, kind, sublease, asset_class, min_sf, max_sf, q: search } = query;
  let rows = snap.listings.slice();
  if (type) rows = rows.filter(l => l.space_type === type);
  if (asset_class) rows = rows.filter(l => l.asset_class === asset_class);
  if (kind) rows = rows.filter(l => l.listing_kind === kind);
  if (sublease === '1') rows = rows.filter(l => l.is_sublease);
  if (sponsor) rows = rows.filter(l => l.sponsor_slug === sponsor);
  if (min_sf) rows = rows.filter(l => (l.size_sf || 0) >= +min_sf);
  if (max_sf) rows = rows.filter(l => (l.size_sf || 1e9) <= +max_sf);
  if (search) { const s = String(search).toLowerCase(); rows = rows.filter(l => `${l.title} ${l.address} ${l.city}`.toLowerCase().includes(s)); }
  return rows;
}

app.get('/api/listings', async (req, res) => {
  try {
    const limit = Math.min(+req.query.limit || 500, 4000), offset = +req.query.offset || 0;
    if (USE_SNAPSHOT) {
      const all = filterSnapListings(req.query);
      return res.json({ listings: all.slice(offset, offset + limit), count: all.length });
    }
    const { type, sponsor, kind, sublease, asset_class, min_sf, max_sf, q: search } = req.query;
    const where = []; const p = [];
    if (type)        { p.push(type); where.push(`l.space_type=$${p.length}`); }
    if (asset_class) { p.push(asset_class); where.push(`l.asset_class=$${p.length}`); }
    if (kind)        { p.push(kind); where.push(`l.listing_kind=$${p.length}`); }
    if (sublease === '1') where.push(`l.is_sublease`);
    if (sponsor)     { p.push(sponsor); where.push(`s.slug=$${p.length}`); }
    if (min_sf)      { p.push(min_sf); where.push(`l.size_sf>=$${p.length}`); }
    if (max_sf)      { p.push(max_sf); where.push(`l.size_sf<=$${p.length}`); }
    if (search)      { p.push(`%${search}%`); where.push(`(l.title ILIKE $${p.length} OR l.address ILIKE $${p.length} OR l.city ILIKE $${p.length})`); }
    const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
    const [{ n }] = await q(`SELECT COUNT(*)::int n FROM listings l LEFT JOIN sponsors s ON s.id=l.sponsor_id ${w}`, p);
    p.push(limit, offset);
    const rows = await q(`SELECT ${LISTING_COLS} FROM listings l LEFT JOIN sponsors s ON s.id=l.sponsor_id ${w}
      ORDER BY l.is_sublease DESC, l.id LIMIT $${p.length - 1} OFFSET $${p.length}`, p);
    res.json({ listings: rows, count: n });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/map-points', async (_q, res) => {
  try {
    if (USE_SNAPSHOT) return res.json({ built_at: snap.built_at, points: snap.map_points });
    const rows = await q(`SELECT l.id, l.title, l.address, l.city, l.lat, l.lng, l.listing_kind, l.space_type, l.asset_class,
      l.size_sf, l.price_amount, l.price_unit, l.cap_rate, l.source_url, s.slug AS sponsor_slug, s.name AS sponsor_name
      FROM listings l LEFT JOIN sponsors s ON s.id=l.sponsor_id WHERE l.lat IS NOT NULL AND l.lng IS NOT NULL`);
    res.json({ built_at: new Date().toISOString(), points: rows });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/brokers', async (req, res) => {
  try {
    const { q: search, state } = req.query; const limit = Math.min(+req.query.limit || 60, 500), offset = +req.query.offset || 0;
    if (USE_SNAPSHOT) {
      let rows = snap.brokers || [];
      if (state) rows = rows.filter(b => b.state === state);
      if (search) { const s = String(search).toLowerCase(); rows = rows.filter(b => `${b.name} ${b.firm} ${b.office_addr}`.toLowerCase().includes(s)); }
      const states = [...new Set((snap.brokers || []).map(b => b.state).filter(Boolean))].sort();
      return res.json({ brokers: rows.slice(offset, offset + limit), count: rows.length, states });
    }
    const p = []; const where = [];
    if (search) { p.push(`%${search}%`); where.push(`(name ILIKE $${p.length} OR firm ILIKE $${p.length} OR office_addr ILIKE $${p.length})`); }
    if (state)  { p.push(state); where.push(`state = $${p.length}`); }
    const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
    const [{ n }] = await q(`SELECT COUNT(*)::int n FROM brokers ${w}`, p);
    p.push(limit, offset);
    const rows = await q(`SELECT * FROM brokers ${w} ORDER BY total_assets DESC NULLS LAST, name LIMIT $${p.length - 1} OFFSET $${p.length}`, p);
    const states = (await q(`SELECT DISTINCT state FROM brokers WHERE state IS NOT NULL ORDER BY state`)).map(r => r.state);
    res.json({ brokers: rows, count: n, states });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/brokers/:id', async (req, res) => {
  try {
    const id = +req.params.id;
    if (USE_SNAPSHOT) {
      const b = (snap.brokers || []).find(x => x.id === id);
      return b ? res.json({ broker: b }) : res.status(404).json({ error: 'not found' });
    }
    const [b] = await q(`SELECT * FROM brokers WHERE id=$1`, [id]);
    return b ? res.json({ broker: b }) : res.status(404).json({ error: 'not found' });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

// Claim a broker profile — writes an append-only pending claim (no DB needed; works in snapshot mode).
// Claims are NEVER auto-applied to the public profile; an admin reviews them at /claims.
const clean = (s, n = 500) => String(s == null ? '' : s).slice(0, n);
app.post('/api/brokers/:id/claim', (req, res) => {
  try {
    const id = +req.params.id; const b = req.body || {};
    if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'invalid broker id' });
    if (!b.name || !b.email) return res.status(400).json({ error: 'name and email are required' });
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(b.email)) return res.status(400).json({ error: 'valid email required' });
    const rec = {
      ts: new Date().toISOString(), broker_id: id, status: 'pending',
      name: clean(b.name, 120), email: clean(b.email, 160), phone: clean(b.phone, 40),
      title: clean(b.title, 120), firm: clean(b.firm, 160), website: clean(b.website, 200),
      linkedin: clean(b.linkedin, 200), bio: clean(b.bio, 2000), corrections: clean(b.corrections, 2000),
      is_self: !!b.is_self, ua: clean(req.headers['user-agent'], 200),
    };
    const acct = accounts.currentUser(req);
    if (acct) { rec.account_id = acct.id; rec.account_email = acct.email; accounts.addClaim(acct.id, id); }
    fs.appendFileSync(CLAIMS_FILE, JSON.stringify(rec) + '\n');
    res.json({ ok: true, message: 'Claim submitted for review.', linked_account: !!acct });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

// Admin: list submitted claims (newest first) — holds people's contact info, admin-only
app.get('/api/claims', requireAdmin, (_q, res) => {
  try {
    const raw = fs.existsSync(CLAIMS_FILE) ? fs.readFileSync(CLAIMS_FILE, 'utf8') : '';
    const claims = raw.split('\n').filter(Boolean).map((l, i) => { try { return { i, ...JSON.parse(l) }; } catch { return null; } }).filter(Boolean).reverse();
    res.json({ claims, count: claims.length });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/api/stats', async (_q, res) => {
  try {
    if (USE_SNAPSHOT) return res.json({ ...snap.stats, recent_runs: snap.recent_runs });
    const [s] = await q(`SELECT
      (SELECT COUNT(*)::int FROM listings) listings,
      (SELECT COUNT(*)::int FROM listings WHERE is_sublease) subleases,
      (SELECT COUNT(*)::int FROM sponsors) sponsors,
      (SELECT COUNT(*)::int FROM brokers) brokers`);
    const byType = await q(`SELECT space_type, COUNT(*)::int n FROM listings WHERE space_type IS NOT NULL GROUP BY space_type ORDER BY n DESC`);
    const byKind = await q(`SELECT listing_kind AS kind, COUNT(*)::int n FROM listings GROUP BY listing_kind ORDER BY n DESC`);
    const runs = await q(`SELECT s.slug, cr.status, cr.finished_at, cr.listings_found FROM crawl_runs cr JOIN sponsors s ON s.id=cr.sponsor_id ORDER BY cr.started_at DESC LIMIT 20`);
    res.json({ ...s, by_type: byType, by_kind: byKind, recent_runs: runs });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

app.get('/account', (_q, r) => r.sendFile(path.join(PUB, 'account.html')));
app.get('/broker/:slug', (_q, r) => r.sendFile(path.join(PUB, 'broker.html')));
app.get('/brokers/:id', (_q, r) => r.sendFile(path.join(PUB, 'broker-profile.html')));
// Admin-only pages — gate BEFORE express.static so a client can't fetch admin.html/claims.html directly
app.get(['/claims', '/claims.html'], requireAdmin, (_q, r) => r.sendFile(path.join(PUB, 'claims.html')));
app.get(['/admin', '/admin.html'], requireAdmin, (_q, r) => r.sendFile(path.join(PUB, 'admin.html')));
app.use(express.static(PUB, { extensions: ['html'] }));
app.listen(PORT, () => console.log(`sublease-agentabrams on ${PORT} (${USE_SNAPSHOT ? 'snapshot' : 'CRUnifiedDB'})`));