← back to Beverlyhillsbutler

server.js

137 lines

'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();
const PORT = process.env.PORT || 9906;

// ── Property data (loaded once at startup) ───────────────────────────────────
// data/bh-properties.json is built by scripts/fetch-bh-properties.js
let properties = [];           // full array for search
let byAIN = new Map();         // AIN -> property record for O(1) lookup

function loadProperties() {
  const f = path.join(__dirname, 'data', 'bh-properties.json');
  if (!fs.existsSync(f)) {
    console.log('[server] data/bh-properties.json not found — run: node scripts/fetch-bh-properties.js');
    return;
  }
  try {
    properties = JSON.parse(fs.readFileSync(f, 'utf8'));
    byAIN = new Map(properties.map(p => [p.ain, p]));
    console.log(`[server] Loaded ${properties.length.toLocaleString()} BH properties`);
  } catch (e) {
    console.error('[server] Failed to load properties:', e.message);
  }
}
loadProperties();

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(express.json());

// No-cache for HTML so Cloudflare doesn't stale it
app.use((req, res, next) => {
  if (req.path.match(/\.(html|htm)$/) || req.path === '/') {
    res.setHeader('Cache-Control', 'no-store, must-revalidate');
  }
  next();
});

// 404-guard: never serve snapshot/backup files
app.use((req, res, next) => {
  if (/\.(bak)(\..*)?$|\.pre-/i.test(req.path)) {
    return res.status(404).send('Not found');
  }
  next();
});

// ── API: Property search ──────────────────────────────────────────────────────
// GET /api/properties?q=<address>&zip=<zip>&use=<use_type>&limit=<N>
app.get('/api/properties', (req, res) => {
  let results = properties;
  const q = (req.query.q || '').trim().toUpperCase();
  const zipFilter = (req.query.zip || '').trim();
  const useFilter = (req.query.use || '').trim().toUpperCase();
  const limit = Math.min(parseInt(req.query.limit || '50', 10), 500);

  if (q) {
    results = results.filter(p =>
      (p.address && p.address.toUpperCase().includes(q)) ||
      (p.property_location && p.property_location.toUpperCase().includes(q)) ||
      p.ain.includes(q) ||
      p.apn.includes(q)
    );
  }
  if (zipFilter) results = results.filter(p => p.zip === zipFilter);
  if (useFilter) results = results.filter(p => (p.use_desc || '').toUpperCase().includes(useFilter));

  res.json({
    total: results.length,
    returned: Math.min(results.length, limit),
    properties: results.slice(0, limit).map(p => ({
      ain: p.ain, apn: p.apn, address: p.address, city: p.city, zip: p.zip,
      use_desc: p.use_desc, year_built: p.year_built, sqft: p.sqft,
      total_value: p.total_value, total_value_fmt: p.total_value_fmt
    }))
  });
});

// ── API: Single property dossier ──────────────────────────────────────────────
// GET /api/property/:apn  (accepts both "4331010039" and "4331-010-039")
app.get('/api/property/:apn', (req, res) => {
  const raw = req.params.apn.replace(/-/g, '');
  const prop = byAIN.get(raw);
  if (!prop) return res.status(404).json({ error: 'Property not found', apn: req.params.apn });
  res.json(prop);
});

// ── API: Stats ────────────────────────────────────────────────────────────────
// GET /api/stats
app.get('/api/stats', (req, res) => {
  const valued = properties.filter(p => p.total_value);
  const avgVal = valued.length
    ? Math.round(valued.reduce((s, p) => s + p.total_value, 0) / valued.length)
    : 0;
  const maxVal = valued.length ? Math.max(...valued.map(p => p.total_value)) : 0;
  const minVal = valued.length ? Math.min(...valued.map(p => p.total_value)) : 0;

  const byZip = {};
  const byUse = {};
  for (const p of properties) {
    byZip[p.zip] = (byZip[p.zip] || 0) + 1;
    byUse[p.use_desc || 'Unknown'] = (byUse[p.use_desc || 'Unknown'] || 0) + 1;
  }

  res.json({
    total_properties: properties.length,
    data_source: 'LA County Assessor Roll 2025 (public open data)',
    coverage: 'Beverly Hills 90210 / 90211 / 90212',
    avg_assessed_value: avgVal,
    max_assessed_value: maxVal,
    min_assessed_value: minVal,
    by_zip: byZip,
    by_use_type: byUse,
    cost: '$0 (free government data)',
    last_loaded: new Date().toISOString()
  });
});

// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (req, res) => res.json({
  status: 'ok',
  site: 'beverlyhillsbutler.com',
  properties_loaded: properties.length
}));

// ── Static + SPA fallback ─────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, 'public')));

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

app.listen(PORT, () => {
  console.log(`beverlyhillsbutler.com running on :${PORT}`);
});