← back to Stayclaim

src/lib/la-arcgis.ts

281 lines

/**
 * LA County GIS ArcGIS REST client — TS port of mcp-la-records/src/arcgis.mjs.
 *
 * Why duplicated: pastdoor is server-side and can call the public ArcGIS
 * endpoint directly without going through MCP/stdio. The MCP server stays
 * for agent + CLI use; this module is the in-app caller.
 *
 * Cached in `la_parcel` PG table — see migration 013_la_parcel_cache.sql.
 */
import { pool } from './db';

const ARCGIS_BASE =
  'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';

const FIELDS = [
  'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusDirection', 'SitusStreet',
  'SitusUnit', 'SitusCity', 'SitusZIP',
  'UseCode', 'UseType', 'UseDescription',
  'YearBuilt1', 'EffectiveYear1',
  'Units1', 'Bedrooms1', 'Bathrooms1', 'SQFTmain1',
  'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue', 'Roll_HomeOwnersExemp',
  'CENTER_LAT', 'CENTER_LON',
].join(',');

const UA = 'pastdoor/1.0 (https://wholivedthere.com)';
const CACHE_TTL_DAYS = 30;

export type LAParcel = {
  apn: string;
  listing_id: string | null;
  situs_address: string | null;
  city: string | null;
  zip: string | null;
  use_code: string | null;
  use_type: string | null;
  use_description: string | null;
  year_built: number | null;
  effective_year: number | null;
  sqft_main: number | null;
  bedrooms: number | null;
  bathrooms: number | null;
  units: number | null;
  roll_year: number | null;
  land_value: number | null;
  improvement_value: number | null;
  total_value: number | null;
  homeowner_exempt: boolean;
  lat: number | null;
  lng: number | null;
  fetched_at: Date | string;
};

function normalizeForSearch(addr: string): string {
  return String(addr)
    .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: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' } as Record<string, string>)[m] ?? m
    )
    .replace(/\s+/g, ' ')
    .trim();
}

function tokens(normalized: string): { houseNo: string | null; street: string } {
  const parts = normalized.split(' ');
  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
  const houseNo = numIdx >= 0 ? parts[numIdx] : null;
  const after = parts.slice(numIdx + 1);

  // Cities are matched as multi-word phrases scanning from the end of the
  // address — single-token hints (e.g., 'SANTA', 'WEST') falsely truncated
  // streets like 'SANTA MONICA BLVD' or 'W HOLLYWOOD BLVD' to empty. Phrases
  // use the post-normalize form, so 'WEST'→'W' and 'BOULEVARD'→'BLVD'.
  const cityPhrases: string[][] = [
    ['BEVERLY', 'HILLS'],
    ['W', 'HOLLYWOOD'],
    ['SANTA', 'MONICA'],
    ['LOS', 'ANGELES'],
    ['CULVER', 'CITY'],
    ['LONG', 'BEACH'],
    ['HOLLYWOOD'],
    ['PASADENA'],
    ['BURBANK'],
    ['GLENDALE'],
    ['MALIBU'],
  ];

  // Match the longest city phrase that is a suffix of `after`. Longest-first
  // so 'W HOLLYWOOD' wins over 'HOLLYWOOD' when both could match.
  const phrasesByLen = [...cityPhrases].sort((a, b) => b.length - a.length);
  let cityStart = -1;
  for (const phrase of phrasesByLen) {
    if (phrase.length > after.length) continue;
    const start = after.length - phrase.length;
    let match = true;
    for (let j = 0; j < phrase.length; j++) {
      if (after[start + j] !== phrase[j]) { match = false; break; }
    }
    if (match) { cityStart = start; break; }
  }

  const street = (cityStart >= 0 ? after.slice(0, cityStart) : after).join(' ');
  return { houseNo, street };
}

async function fetchArcGis(where: string): Promise<unknown[]> {
  const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}&outFields=${FIELDS}&f=json&resultRecordCount=5`;
  // 5s hard timeout — without AbortSignal a hung ArcGIS endpoint blocks the
  // server-side caller indefinitely (and the loop runs up to 5 of these in series).
  const r = await fetch(url, {
    headers: { 'User-Agent': UA, Accept: 'application/json' },
    signal: AbortSignal.timeout(5_000),
  });
  if (!r.ok) throw new Error(`arcgis ${r.status}`);
  const data: unknown = await r.json();
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return ((data as any)?.features ?? []) as unknown[];
}

function normalizeArcGisRow(a: Record<string, unknown>): Omit<LAParcel, 'listing_id' | 'fetched_at'> | null {
  if (!a.APN) return null;
  // Keep 0 as a real value — studios (Bedrooms1=0), non-residential parcels
  // (Units1=0), and zero-value rolls are legitimate data, not "missing".
  const num = (v: unknown) => {
    if (v === null || v === undefined || v === '') return null;
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  };
  const land = num(a.Roll_LandValue);
  const imp  = num(a.Roll_ImpValue);
  const totalValue = land === null && imp === null ? null : (land ?? 0) + (imp ?? 0);
  // ArcGIS returns the HOX field as a flag/dollar amount, often a string like
  // "N"/"Y"/"0"/"7000". `!!a.Roll_HomeOwnersExemp` is true for "N" too, so
  // explicitly coerce: numeric > 0 OR Y means exempted.
  const hox = a.Roll_HomeOwnersExemp;
  const hoxNum = Number(hox);
  const homeownerExempt = Number.isFinite(hoxNum)
    ? hoxNum > 0
    : typeof hox === 'string' && /^Y/i.test(hox.trim());
  return {
    apn: String(a.APN),
    situs_address: (a.SitusFullAddress as string) ?? null,
    city: (a.SitusCity as string)?.trim() ?? null,
    zip: (a.SitusZIP as string)?.trim() ?? null,
    use_code: (a.UseCode as string) ?? null,
    use_type: (a.UseType as string) ?? null,
    use_description: (a.UseDescription as string) ?? null,
    year_built: num(a.YearBuilt1),
    effective_year: num(a.EffectiveYear1),
    sqft_main: num(a.SQFTmain1),
    bedrooms: num(a.Bedrooms1),
    bathrooms: num(a.Bathrooms1),
    units: num(a.Units1),
    roll_year: num(a.Roll_Year),
    land_value: land,
    improvement_value: imp,
    total_value: totalValue,
    homeowner_exempt: homeownerExempt,
    lat: num(a.CENTER_LAT),
    lng: num(a.CENTER_LON),
  };
}

/**
 * Cache-first lookup. Returns the cached row if fresh; otherwise hits ArcGIS,
 * stores, and returns. Returns null if no parcel matches.
 */
export async function getOrFetchParcelForAddress(
  addressInput: string,
  listingId: string | null
): Promise<LAParcel | null> {
  // 1. Cache hit by listing_id (fastest)
  if (listingId) {
    try {
      const { rows } = await pool.query<LAParcel>(
        `SELECT * FROM la_parcel WHERE listing_id = $1 AND fetched_at > now() - ($2 || ' days')::interval LIMIT 1`,
        [listingId, CACHE_TTL_DAYS]
      );
      if (rows[0]) return rows[0];
    } catch (e) {
      // Table may not exist yet on a fresh deploy — fall through to fetch.
      if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) throw e;
    }
  }

  // 2. Hit ArcGIS
  const norm = normalizeForSearch(addressInput);
  const { houseNo, street } = tokens(norm);

  // Hard-allowlist tokens used in WHERE clauses. ArcGIS treats single quotes
  // as string delimiters and supports LIKE wildcards (% and _) — both are
  // meaningful and must not survive into a where clause built from user input.
  // Refuse to interpolate anything that doesn't match. Belt-and-braces: also
  // strip any character outside the allowlist before interpolation.
  const sanitizeNo     = (s: string) => /^\d{1,8}$/.test(s) ? s : '';
  const sanitizeStreet = (s: string) => s.replace(/[^A-Z0-9 ]/g, '').trim().slice(0, 60);
  const safeNo     = houseNo ? sanitizeNo(houseNo) : '';
  const safeStreet = street ? sanitizeStreet(street) : '';

  const tries: string[] = [];
  if (safeNo && safeStreet) {
    tries.push(`SitusFullAddress LIKE '${safeNo} %${safeStreet}%'`);
    tries.push(`SitusFullAddress LIKE '${safeNo} N ${safeStreet}%'`);
    tries.push(`SitusFullAddress LIKE '${safeNo} S ${safeStreet}%'`);
    tries.push(`SitusFullAddress LIKE '${safeNo} E ${safeStreet}%'`);
    tries.push(`SitusFullAddress LIKE '${safeNo} W ${safeStreet}%'`);
  }
  if (!safeNo && safeStreet) {
    tries.push(`SitusFullAddress LIKE '%${safeStreet}%'`);
  }
  if (tries.length === 0) {
    // Input was either empty or contained only disallowed chars (apostrophe-only
    // queries like O'Connor, ArcGIS wildcards like '%' or '_', SQL
    // metacharacters). Refuse the lookup.
    console.warn(`la-arcgis: refused unsafe input "${addressInput}"`);
    return null;
  }

  let raw: Record<string, unknown> | null = null;
  let anyCompleted = false;
  for (const where of tries) {
    let feats: unknown[];
    try {
      feats = await fetchArcGis(where);
      anyCompleted = true;
    } catch (e) {
      console.warn(`arcgis ${where}: ${e instanceof Error ? e.message : e}`);
      continue;
    }
    if (feats.length) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      raw = (feats[0] as any).attributes;
      break;
    }
  }
  // If every attempt threw (network/timeout), surface that to the caller as a
  // real error instead of "no parcel" — otherwise transient failures get
  // negative-cached as definitive misses.
  if (!raw && !anyCompleted) {
    throw new Error('arcgis: all attempts failed');
  }
  if (!raw) return null;

  const norm2 = normalizeArcGisRow(raw);
  if (!norm2) return null;

  // 3. Cache (best-effort — table may not exist yet)
  try {
    await pool.query(
      `INSERT INTO la_parcel
        (apn, listing_id, situs_address, city, zip, use_code, use_type, use_description,
         year_built, effective_year, sqft_main, bedrooms, bathrooms, units,
         roll_year, land_value, improvement_value, total_value, homeowner_exempt,
         lat, lng, raw, fetched_at)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22, now())
       ON CONFLICT (apn) DO UPDATE SET
         listing_id = COALESCE(EXCLUDED.listing_id, la_parcel.listing_id),
         situs_address = EXCLUDED.situs_address,
         city = EXCLUDED.city, zip = EXCLUDED.zip,
         use_code = EXCLUDED.use_code, use_type = EXCLUDED.use_type, use_description = EXCLUDED.use_description,
         year_built = EXCLUDED.year_built, effective_year = EXCLUDED.effective_year,
         sqft_main = EXCLUDED.sqft_main, bedrooms = EXCLUDED.bedrooms, bathrooms = EXCLUDED.bathrooms, units = EXCLUDED.units,
         roll_year = EXCLUDED.roll_year, land_value = EXCLUDED.land_value, improvement_value = EXCLUDED.improvement_value,
         total_value = EXCLUDED.total_value, homeowner_exempt = EXCLUDED.homeowner_exempt,
         lat = EXCLUDED.lat, lng = EXCLUDED.lng, raw = EXCLUDED.raw, fetched_at = now()`,
      [norm2.apn, listingId, norm2.situs_address, norm2.city, norm2.zip,
       norm2.use_code, norm2.use_type, norm2.use_description,
       norm2.year_built, norm2.effective_year, norm2.sqft_main, norm2.bedrooms, norm2.bathrooms, norm2.units,
       norm2.roll_year, norm2.land_value, norm2.improvement_value, norm2.total_value, norm2.homeowner_exempt,
       norm2.lat, norm2.lng, JSON.stringify(raw)]
    );
  } catch (e) {
    if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) {
      console.warn('la_parcel upsert:', e instanceof Error ? e.message : e);
    }
  }

  return { ...norm2, listing_id: listingId, fetched_at: new Date() };
}