← back to Nationalrealestate

src/server/commercial.ts

179 lines

/**
 * LA-region commercial real-estate API — the "local commercial RE news" layer.
 * Reads the materialized `commercial_parcel` table (see 005_commercial.sql +
 * src/ingest/commercial/la_assessor.ts). LA carries assessed values + last
 * recording date but NO sale price / owner (assessor-roll limits), so the feed is
 * "recorded activity + assessed value" — never fabricated prices.
 *
 *   GET /api/commercial/types                       -> taxonomy + per-type counts
 *   GET /api/commercial/search?type&city&q&min&max&sort&limit&offset
 *   GET /api/commercial/feed?type&city&limit        -> recent recorded activity
 *   GET /api/commercial/marquee?type&city&limit      -> top assets by assessed value
 *   GET /api/commercial/property/:ain               -> single-parcel detail + context
 */
import type { Express, Request, Response } from 'express';
import { query } from '../../db/pool.ts';
import { COMMERCIAL_CATEGORIES, categoryLabel, type CommercialType } from '../lib/commercial_types.ts';

const COUNTY = '06037'; // LA County (first region; multi-county-ready via ?county)
const likeEscape = (s: string) => s.replace(/[\\%_]/g, c => '\\' + c);
const TYPE_KEYS = new Set(COMMERCIAL_CATEGORIES.map(c => c.key));
const clampLimit = (v: unknown, def: number, max: number) => Math.min(max, Math.max(1, Number(v) || def));

/** shared WHERE builder for type/city/q/value filters */
function buildFilter(q: Request['query']): { where: string; params: unknown[] } {
  const county = String(q.county || COUNTY);
  const params: unknown[] = [county];
  const parts = ['county_fips = $1'];
  if (q.type && TYPE_KEYS.has(String(q.type) as CommercialType)) { params.push(String(q.type)); parts.push(`ctype = $${params.length}`); }
  if (q.city) { params.push(String(q.city).toUpperCase()); parts.push(`city = $${params.length}`); }
  if (q.q) { params.push('%' + likeEscape(String(q.q).toUpperCase()) + '%'); parts.push(`(upper(address) LIKE $${params.length} ESCAPE '\\' OR upper(city) LIKE $${params.length} ESCAPE '\\')`); }
  if (q.min) { params.push(Number(q.min)); parts.push(`assessed_total >= $${params.length}`); }
  if (q.max) { params.push(Number(q.max)); parts.push(`assessed_total <= $${params.length}`); }
  return { where: parts.join(' AND '), params };
}

const SORTS: Record<string, string> = {
  value_desc: 'assessed_total DESC NULLS LAST',
  value_asc: 'assessed_total ASC NULLS LAST',
  recent: 'recording_date DESC NULLS LAST',
  sqft_desc: 'sqft DESC NULLS LAST',
};

function shape(r: any) {
  return {
    ain: r.ain, address: r.address, city: r.city, zip: r.zip,
    type: r.ctype, type_label: categoryLabel(r.ctype), use_desc: r.use_desc,
    assessed_total: r.assessed_total != null ? Number(r.assessed_total) : null,
    assessed_land: r.assessed_land != null ? Number(r.assessed_land) : null,
    assessed_improvement: r.assessed_imp != null ? Number(r.assessed_imp) : null,
    roll_year: r.roll_year, recording_date: r.recording_date,
    sqft: r.sqft, year_built: r.year_built, units: r.units,
  };
}

export function mountCommercial(app: Express) {
  // taxonomy + per-type counts (drives the filter UI)
  app.get('/api/commercial/types', async (req: Request, res: Response) => {
    try {
      const county = String(req.query.county || COUNTY);
      const r = await query<{ ctype: string; n: string }>(
        `SELECT ctype, count(*)::text n FROM commercial_parcel WHERE county_fips=$1 GROUP BY ctype`, [county]);
      const counts = Object.fromEntries(r.rows.map(x => [x.ctype, Number(x.n)]));
      const total = r.rows.reduce((s, x) => s + Number(x.n), 0);
      res.json({ county, total, types: COMMERCIAL_CATEGORIES.map(c => ({ ...c, count: counts[c.key] || 0 })) });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // top cities by commercial count (for the filter dropdown)
  app.get('/api/commercial/cities', async (req: Request, res: Response) => {
    try {
      const county = String(req.query.county || COUNTY);
      const r = await query<{ city: string; n: string }>(
        `SELECT city, count(*)::text n FROM commercial_parcel WHERE county_fips=$1 AND city IS NOT NULL
         GROUP BY city ORDER BY count(*) DESC LIMIT 60`, [county]);
      res.json({ cities: r.rows.map(x => ({ city: x.city, count: Number(x.n) })) });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // filtered browse/search
  app.get('/api/commercial/search', async (req: Request, res: Response) => {
    try {
      const { where, params } = buildFilter(req.query);
      const sort = SORTS[String(req.query.sort || 'value_desc')] || SORTS.value_desc;
      const limit = clampLimit(req.query.limit, 60, 200);
      const offset = Math.max(0, Number(req.query.offset) || 0);
      const total = await query<{ n: string }>(`SELECT count(*)::text n FROM commercial_parcel WHERE ${where}`, params);
      const rows = await query(
        `SELECT * FROM commercial_parcel WHERE ${where} ORDER BY ${sort}, ain LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
        [...params, limit, offset]);
      res.json({ total: Number(total.rows[0].n), count: rows.rows.length, offset, results: rows.rows.map(shape) });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // recent recorded activity — the "news feed" (recording date, no price for LA)
  app.get('/api/commercial/feed', async (req: Request, res: Response) => {
    try {
      const { where, params } = buildFilter(req.query);
      const limit = clampLimit(req.query.limit, 40, 100);
      const rows = await query(
        `SELECT p.*, b.brief FROM commercial_parcel p
           LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
         WHERE ${where.replace(/\b(county_fips|ctype|city|address|assessed_total)\b/g, 'p.$1')}
           AND p.recording_date IS NOT NULL
         ORDER BY p.recording_date DESC, p.assessed_total DESC NULLS LAST LIMIT $${params.length + 1}`,
        [...params, limit]);
      res.json({ count: rows.rows.length, results: rows.rows.map(r => ({ ...shape(r), brief: r.brief || null })) });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // marquee assets — top by assessed value (the newsworthy trophy hook), with briefs
  app.get('/api/commercial/marquee', async (req: Request, res: Response) => {
    try {
      const { where, params } = buildFilter(req.query);
      const limit = clampLimit(req.query.limit, 12, 50);
      const rows = await query(
        `SELECT p.*, b.brief FROM commercial_parcel p
           LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
         WHERE ${where.replace(/\b(county_fips|ctype|city|address|assessed_total)\b/g, 'p.$1')}
           AND p.assessed_total IS NOT NULL
         ORDER BY p.assessed_total DESC LIMIT $${params.length + 1}`, [...params, limit]);
      res.json({ count: rows.rows.length, results: rows.rows.map(r => ({ ...shape(r), brief: r.brief || null })) });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // one-shot digest for syndication (e.g. RENTV): marquee+briefs, recent activity, top LA firms
  app.get('/api/commercial/digest', async (req: Request, res: Response) => {
    try {
      const county = String(req.query.county || COUNTY);
      const [marquee, recent, firms] = await Promise.all([
        query(`SELECT p.*, b.brief FROM commercial_parcel p
                 LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
               WHERE p.county_fips=$1 AND p.assessed_total IS NOT NULL AND b.brief IS NOT NULL
               ORDER BY p.assessed_total DESC LIMIT 12`, [county]),
        query(`SELECT p.*, b.brief FROM commercial_parcel p
                 LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
               WHERE p.county_fips=$1 AND p.recording_date IS NOT NULL
               ORDER BY p.recording_date DESC, p.assessed_total DESC NULLS LAST LIMIT 20`, [county]),
        query(`SELECT name, agent_count, hq_city, license_no FROM firm
               WHERE license_state='CA' AND upper(hq_city)='LOS ANGELES' AND agent_count>0
               ORDER BY agent_count DESC NULLS LAST LIMIT 12`),
      ]);
      res.json({
        fetched_at: new Date().toISOString(), county,
        marquee: marquee.rows.map(r => ({ ...shape(r), brief: r.brief || null })),
        recent: recent.rows.map(r => ({ ...shape(r), brief: r.brief || null })),
        firms: firms.rows.map((f: any) => ({ name: f.name, agent_count: f.agent_count, city: f.hq_city, license_no: f.license_no })),
      });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // generated news blurb for one asset (precomputed by ingest:commercial-briefs)
  app.get('/api/commercial/brief/:ain', async (req: Request, res: Response) => {
    try {
      const county = String(req.query.county || COUNTY);
      const r = await query<{ brief: string; model: string; generated_at: string }>(
        `SELECT brief, model, generated_at FROM commercial_brief WHERE county_fips=$1 AND ain=$2`, [county, req.params.ain]);
      if (!r.rows.length) return res.status(404).json({ error: 'no brief for this asset' });
      res.json(r.rows[0]);
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });

  // single-parcel detail + county market context
  app.get('/api/commercial/property/:ain', async (req: Request, res: Response) => {
    try {
      const county = String(req.query.county || COUNTY);
      const r = await query(
        `SELECT p.*, b.brief FROM commercial_parcel p
           LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
         WHERE p.county_fips=$1 AND p.ain=$2`, [county, req.params.ain]);
      if (!r.rows.length) return res.status(404).json({ error: 'not found' });
      const p = { ...shape(r.rows[0]), brief: r.rows[0].brief || null };
      res.json({
        ...p,
        pricing_note: 'LA County assessor roll — assessed values + last recording date only. Sale price and owner are not in the public roll; market listing prices come from the commercial listings feed.',
      });
    } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
  });
}