← back to Bubbesblock

lib/home-history.js

398 lines

/**
 * home-history.js — BubbesBlock home-history lookup module
 *
 * Exports: async function getHomeHistory(address) -> NormalizedHomeHistory
 *
 * Data sources (all free, no auth):
 *   Tier A — LA County GIS ArcGIS REST parcel layer (public.gis.lacounty.gov)
 *             Fields: year built, sqft, beds/baths, assessed value, APN
 *   Tier A — LADBS Socrata building-permit datasets (data.lacity.org)
 *             Datasets: pi9x-tg5x (2020+), dyxf-7hc4 (2010-19), e67z-kt2n (<2010)
 *
 * What is NOT available from free sources:
 *   - Owner names (intentionally redacted from public GIS layer per Gov Code §6254.21)
 *   - Sale price / last-sold date (Registrar-Recorder — no free online index)
 *   - Resident history (Census 72-yr embargo; city directories not digitized/API)
 *
 * Cache: data/home-history.json (object keyed by normalized address)
 * Source tier tagging follows stayclaim schema conventions.
 */

'use strict';

const fs   = require('fs');
const path = require('path');
const https = require('https');

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const CACHE_PATH = path.join(__dirname, '..', 'data', 'home-history.json');

// ---------------------------------------------------------------------------
// Free public endpoints (verified 2026-06-19)
// ---------------------------------------------------------------------------
const ARCGIS_BASE =
  'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';

const ARCGIS_FIELDS = [
  'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusStreet', 'SitusZIP',
  'SitusCity',
  'YearBuilt1', 'EffectiveYear1',
  'SQFTmain1', 'Bedrooms1', 'Bathrooms1', 'Units1',
  'UseDescription', 'DesignType1',
  'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue',
  'Roll_LandBaseYear', 'Roll_ImpBaseYear',
  'Roll_HomeOwnersExemp',
  'LegalDescription',
  'CENTER_LAT', 'CENTER_LON',
].join(',');

// LADBS Socrata datasets (era-bucketed)
const LADBS_DATASETS = [
  { id: 'pi9x-tg5x', label: '2020+' },
  { id: 'dyxf-7hc4', label: '2010-19' },
  { id: 'e67z-kt2n', label: 'pre-2010' },
];

// ---------------------------------------------------------------------------
// HTTP helper — simple https.get that returns a parsed JSON object
// No retries, 10-second timeout. Never throws to caller (returns null on fail).
// ---------------------------------------------------------------------------
function fetchJson(url) {
  return new Promise(resolve => {
    const req = https.get(url, { timeout: 10000 }, res => {
      let body = '';
      res.on('data', chunk => { body += chunk; });
      res.on('end', () => {
        try { resolve(JSON.parse(body)); }
        catch { resolve(null); }
      });
    });
    req.on('timeout', () => { req.destroy(); resolve(null); });
    req.on('error', () => resolve(null));
  });
}

// ---------------------------------------------------------------------------
// Address normalisation
// ---------------------------------------------------------------------------
const STREET_ABBR = {
  STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR',
  ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT',
  LANE: 'LN', WAY: 'WAY',
};

function normalizeAddress(input) {
  return String(input)
    .toUpperCase()
    .replace(/[,]/g, ' ')
    .replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
    .replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g,
      m => STREET_ABBR[m] || m)
    .replace(/\s+/g, ' ')
    .trim();
}

/** Extract house number and street tokens from a normalized address string. */
function parseAddressTokens(norm) {
  const parts = norm.split(' ');
  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
  if (numIdx < 0) return { houseNo: null, street: norm };
  const houseNo = parts[numIdx];
  // Street name = tokens after house number, stopping before city/state/ZIP tokens
  const stopTokens = new Set(['CA', 'LOS', 'ANGELES', 'BEVERLY', 'HILLS', 'SANTA', 'MONICA',
    'WEST', 'HOLLYWOOD', 'CULVER', 'CITY', 'PASADENA', 'BURBANK', 'GLENDALE',
    'TARZANA', 'RESEDA', 'WOODLAND', 'ENCINO', 'SHERMAN', 'OAKS', 'VAN', 'NUYS',
    '91356', '91406', '91335', '90028', '90210', '90036']);
  const after = parts.slice(numIdx + 1);
  const stopIdx = after.findIndex(p => stopTokens.has(p) || /^\d{5}$/.test(p));
  const streetParts = stopIdx >= 0 ? after.slice(0, stopIdx) : after;
  return { houseNo, street: streetParts.join(' ') };
}

function escapeLike(s) {
  return String(s ?? '').replace(/\\/g, '').replace(/'/g, "''").replace(/[%_]/g, '');
}

// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------
function readCache() {
  try {
    const raw = fs.readFileSync(CACHE_PATH, 'utf8');
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

function writeCache(cache) {
  try {
    fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
    fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2), 'utf8');
  } catch (e) {
    console.error('[home-history] cache write failed:', e.message);
  }
}

// ---------------------------------------------------------------------------
// ArcGIS parcel lookup
// ---------------------------------------------------------------------------
async function lookupParcel(input) {
  const norm = normalizeAddress(input);
  const { houseNo, street } = parseAddressTokens(norm);

  if (!houseNo || !street) return null;

  const safeNo     = /^\d{1,8}$/.test(houseNo) ? houseNo : '';
  const safeStreet = escapeLike(street);

  if (!safeNo || !safeStreet) return null;

  // Try multiple WHERE patterns progressively
  const wheres = [
    `SitusHouseNo='${safeNo}' AND SitusStreet='${safeStreet}'`,
    `SitusFullAddress LIKE '${safeNo} %${safeStreet}%'`,
    `SitusFullAddress LIKE '${safeNo} W ${safeStreet}%'`,
    `SitusFullAddress LIKE '${safeNo} N ${safeStreet}%'`,
  ];

  for (const where of wheres) {
    const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}`
      + `&outFields=${encodeURIComponent(ARCGIS_FIELDS)}&f=json&resultRecordCount=3`;
    const data = await fetchJson(url);
    if (!data) continue;
    const feats = data.features ?? [];
    if (feats.length > 0) {
      return feats[0].attributes;
    }
    // Small throttle between ArcGIS requests
    await new Promise(r => setTimeout(r, 300));
  }
  return null;
}

// ---------------------------------------------------------------------------
// LADBS permit lookup (by APN, no-dash format)
// ---------------------------------------------------------------------------
async function lookupPermits(apn) {
  if (!apn) return [];
  const apnClean = String(apn).replace(/-/g, '');
  const all = [];

  for (const ds of LADBS_DATASETS) {
    const url = `https://data.lacity.org/resource/${ds.id}.json`
      + `?$where=${encodeURIComponent(`apn='${apnClean}'`)}&$limit=10&$order=issue_date+DESC`;
    const data = await fetchJson(url);
    if (Array.isArray(data)) {
      all.push(...data.map(p => ({
        permitNumber: p.permit_nbr,
        type:         p.permit_type,
        subType:      p.permit_sub_type,
        issueDate:    p.issue_date ? p.issue_date.slice(0, 10) : null,
        status:       p.status_desc,
        valuation:    p.valuation ? Number(p.valuation) : null,
        description:  p.work_desc ? p.work_desc.slice(0, 120) : null,
        dataset:      ds.label,
      })));
    }
    await new Promise(r => setTimeout(r, 300));
  }

  // Sort most-recent first
  all.sort((a, b) => (b.issueDate ?? '').localeCompare(a.issueDate ?? ''));
  return all;
}

// ---------------------------------------------------------------------------
// Build normalised result from parcel attributes + permits
// ---------------------------------------------------------------------------
function buildResult(address, normalized, parcel, permits) {
  if (!parcel) {
    return {
      address,
      normalized,
      builtYear:  null,
      lastSold:   null,
      beds:       null,
      baths:      null,
      sqft:       null,
      residents:  [],
      permits:    [],
      summary:    'No parcel record found in LA County public GIS layer for this address. '
                + 'The address may be outside LA County, use a different number format, '
                + 'or may not be indexed in the current roll.',
      source:     'unavailable',
      sourceUrl:  'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0',
      tier:       'A',
      retrievedAt: new Date().toISOString(),
    };
  }

  const apn         = parcel.APN ?? null;
  const builtYear   = parcel.YearBuilt1 ? Number(parcel.YearBuilt1) : null;
  const sqft        = Number(parcel.SQFTmain1) || null;
  const beds        = Number(parcel.Bedrooms1) || null;
  const baths       = Number(parcel.Bathrooms1) || null;
  const landValue   = Number(parcel.Roll_LandValue) || null;
  const impValue    = Number(parcel.Roll_ImpValue)  || null;
  const totalAssessed = (landValue && impValue) ? landValue + impValue : null;
  const landBaseYear  = parcel.Roll_LandBaseYear ?? null;
  const impBaseYear   = parcel.Roll_ImpBaseYear  ?? null;
  const useDesc     = parcel.UseDescription ?? null;
  const situs       = parcel.SitusFullAddress ?? normalized;

  // Last-sold approximation:
  // Prop 13 resets the "base year" when a property transfers. If both land and
  // improvement base years are the same recent year, that's the likely last-sale year.
  // This is an INFERENCE, not a deed transfer record (no free online deed index in LA County).
  let lastSold = null;
  if (landBaseYear && landBaseYear === impBaseYear) {
    const yr = Number(landBaseYear);
    if (yr > 1980) {
      lastSold = {
        date:  `~${yr}`,        // approximate — Prop 13 base year, not exact sale date
        price: null,            // sale price is NOT in the public assessor layer
        note:  'Estimated from Prop 13 reassessment base year. '
             + 'Actual sale date/price requires LA County Registrar-Recorder deed research '
             + '(no free online index — in-person or CPRA request required).',
      };
    }
  }

  // Residents: NOT available from free sources.
  // The public parcel layer intentionally omits owner names (Gov Code §6254.21).
  // City directories are not available as a public API.
  const residents = [];

  // Permit summary for human reading
  const recentPermit = permits[0] ?? null;
  const permitSummary = permits.length > 0
    ? `${permits.length} building permit(s) on LADBS record; most recent: `
      + `${recentPermit.type} (${recentPermit.issueDate ?? 'date unknown'}).`
    : 'No LADBS building permits found in public records.';

  const assessedStr = totalAssessed
    ? `Total assessed value: $${totalAssessed.toLocaleString()} (${parcel.Roll_Year ?? 'roll year unknown'}).`
    : '';

  const summary = [
    `${situs}: ${useDesc ?? 'use type unknown'}.`,
    builtYear ? `Built ${builtYear}.` : '',
    sqft      ? `${sqft.toLocaleString()} sq ft.` : '',
    beds || baths ? `${beds ?? '?'} bed / ${baths ?? '?'} bath.` : '',
    lastSold  ? `Last transfer approx. ${lastSold.date} (Prop 13 base year reassessment).` : '',
    assessedStr,
    permitSummary,
    apn       ? `APN: ${apn}.` : '',
    'Owner name, sale price, and resident history are not available from free public sources.',
  ].filter(Boolean).join(' ');

  return {
    address,
    normalized,
    apn,
    builtYear,
    lastSold,
    beds,
    baths,
    sqft,
    assessedValue: totalAssessed,
    useDescription: useDesc,
    residents,           // always empty — see comment above
    permits,
    summary,
    source:     'LA County GIS ArcGIS REST (parcel) + LADBS Socrata (permits)',
    sourceUrl:  apn
      ? `https://portal.assessor.lacounty.gov/parceldetail/${apn.replace(/-/g, '')}`
      : 'https://portal.assessor.lacounty.gov',
    tier:       'A',
    retrievedAt: new Date().toISOString(),
    _raw: {
      situsFullAddress: situs,
      landValue,
      improvementValue: impValue,
      landBaseYear,
      improvementBaseYear: impBaseYear,
      rollYear: parcel.Roll_Year,
      homeownerExemption: !!parcel.Roll_HomeOwnersExemp,
      effectiveYear: parcel.EffectiveYear1,
      lat: Number(parcel.CENTER_LAT) || null,
      lng: Number(parcel.CENTER_LON) || null,
    },
  };
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * getHomeHistory(address)
 *
 * @param {string} address  Free-form address string
 * @returns {Promise<NormalizedHomeHistory>}
 *
 * NormalizedHomeHistory shape:
 * {
 *   address:       string,          // original input
 *   normalized:    string,          // normalised form used for cache key
 *   apn:           string|null,     // LA County APN (10-digit with dashes)
 *   builtYear:     number|null,     // year constructed (from assessor roll)
 *   lastSold:      {date, price, note}|null,  // price is always null (not public)
 *   beds:          number|null,
 *   baths:         number|null,
 *   sqft:          number|null,
 *   assessedValue: number|null,     // land + improvement (Prop 13 value, NOT market)
 *   useDescription:string|null,     // e.g. "Single", "Store Combination"
 *   residents:     [],              // always empty — owner names not public
 *   permits:       Array<PermitRecord>,
 *   summary:       string,
 *   source:        string,          // 'unavailable' if no data found
 *   sourceUrl:     string,
 *   tier:          'A'|'B'|'C'|'D',
 *   retrievedAt:   string,          // ISO-8601
 *   _raw:          object,          // raw assessor fields (internal use)
 * }
 *
 * Wire into Express:
 *   const { getHomeHistory } = require('./lib/home-history');
 *   app.get('/api/home-history', async (req, res) => {
 *     const result = await getHomeHistory(req.query.address ?? '');
 *     res.json(result);
 *   });
 */
async function getHomeHistory(address) {
  if (!address || typeof address !== 'string' || !address.trim()) {
    return buildResult('', '', null, []);
  }

  const normalized = normalizeAddress(address.trim());

  // --- Cache check ---
  const cache = readCache();
  if (cache[normalized]) {
    return cache[normalized];
  }

  // --- Live lookup ---
  try {
    const parcel  = await lookupParcel(address);
    const permits = parcel ? await lookupPermits(parcel.APN) : [];
    const result  = buildResult(address.trim(), normalized, parcel, permits);

    // Write back to cache
    cache[normalized] = result;
    writeCache(cache);

    return result;
  } catch (err) {
    console.error('[home-history] lookup error:', err.message);
    // Return safe unavailable stub — never throw to caller
    return buildResult(address.trim(), normalized, null, []);
  }
}

module.exports = { getHomeHistory };