← back to Nationalrealestate

src/ingest/parcels/upsert.ts

124 lines

/**
 * Shared chunked upsert into `parcel` — full column set (incl. beds/baths/sales,
 * unlike the NYC-era subset). PG caps a statement at 65535 bind params;
 * 23 cols -> chunks of 2000 rows = 46k params.
 */
import { query } from '../../../db/pool.ts';
import { sanitizeOwnerName } from './owner_guard.ts';

export interface ParcelUpsertRow {
  county_fips: string; source_id: string;
  address: string | null; norm_address: string | null; city: string | null; zip: string | null;
  lat: number | null; lng: number | null;
  year_built: number | null; sqft: number | null; beds: number | null; baths: number | null;
  units: number | null; use_desc: string | null;
  land_value: number | null; improvement_value: number | null; total_value: number | null;
  tax_year: string | null; owner_name: string | null; zoning: string | null;
  last_sale_date: string | null; last_sale_price: number | null;
  extra: string | null; // JSON string
  // ── TK-50 field-level provenance (all OPTIONAL — backward compatible) ──
  // Because one source record → one parcel row, these apply to the WHOLE row:
  // every field on this parcel was obtained from this source record.
  sourceUrl?: string | null;   // per-record addressable link back to the exact source record
  sourceKey?: string | null;   // ingest source id, e.g. 'oregon-rlis' — joins source_field_map
  fetchedAt?: string | null;   // ISO timestamp the record was pulled (run start time)
  rawSource?: string | null;   // JSON string of the raw source-record attributes we mapped from
}

// DB columns (snake_case) paired with the ParcelUpsertRow key that feeds each.
// The provenance columns accept the camelCase optional fields above.
const COL_MAP: [string, keyof ParcelUpsertRow][] = [
  ['county_fips', 'county_fips'], ['source_id', 'source_id'], ['address', 'address'],
  ['norm_address', 'norm_address'], ['city', 'city'], ['zip', 'zip'], ['lat', 'lat'], ['lng', 'lng'],
  ['year_built', 'year_built'], ['sqft', 'sqft'], ['beds', 'beds'], ['baths', 'baths'],
  ['units', 'units'], ['use_desc', 'use_desc'], ['land_value', 'land_value'],
  ['improvement_value', 'improvement_value'], ['total_value', 'total_value'], ['tax_year', 'tax_year'],
  ['owner_name', 'owner_name'], ['zoning', 'zoning'], ['last_sale_date', 'last_sale_date'],
  ['last_sale_price', 'last_sale_price'], ['extra', 'extra'],
  ['source_url', 'sourceUrl'], ['source_key', 'sourceKey'], ['fetched_at', 'fetchedAt'], ['raw_source', 'rawSource'],
];
const DB_COLS = COL_MAP.map(([db]) => db);

export async function upsertParcels(rows: ParcelUpsertRow[]): Promise<number> {
  const N = COL_MAP.length;                 // 27 cols → 2000 rows = 54k params (< 65535)
  let done = 0;
  for (let i = 0; i < rows.length; i += 2000) {
    const chunk = rows.slice(i, i + 2000);
    const params: unknown[] = [];
    const values = chunk.map((r, j) => {
      const b = j * N;
      // Sanitize owner_name at the shared chokepoint so every ingester routing
      // through upsertParcels drops placeholder/junk owner values (see
      // owner_guard.ts) without each emit point having to remember to.
      for (const [dbCol, key] of COL_MAP) {
        params.push(dbCol === 'owner_name' ? sanitizeOwnerName(r.owner_name) : (r[key] ?? null));
      }
      return '(' + DB_COLS.map((_, k) => `$${b + k + 1}`).join(',') + ')';
    });
    await query(
      `INSERT INTO parcel (${DB_COLS.join(',')}) VALUES ${values.join(',')}
       ON CONFLICT (county_fips, source_id) DO UPDATE SET
         ${DB_COLS.filter(c => c !== 'county_fips' && c !== 'source_id').map(c => `${c}=EXCLUDED.${c}`).join(', ')}`,
      params,
    );
    done += chunk.length;
  }
  return done;
}

/**
 * TK-50: declare, once per source, which raw source attribute each of our
 * parcel fields maps from — the "which attribute did field X come from" half of
 * field-level provenance. Idempotent upsert into source_field_map; call at
 * ingest time so provenance is queryable without re-reading ingester code.
 *
 *   registerSourceFieldMap('oregon-rlis', { owner_name: 'OWNER', sqft: 'BLDGSQFT' }, 'RLIS taxlots')
 */
export async function registerSourceFieldMap(
  sourceKey: string,
  mapping: Record<string, string>,
  notes?: string,
): Promise<number> {
  const entries = Object.entries(mapping).filter(([, attr]) => attr != null && attr !== '');
  if (!entries.length) return 0;
  const params: unknown[] = [];
  const values = entries.map(([ourField, sourceAttr], j) => {
    const b = j * 4;
    params.push(sourceKey, ourField, sourceAttr, notes ?? null);
    return `($${b + 1},$${b + 2},$${b + 3},$${b + 4})`;
  });
  await query(
    `INSERT INTO source_field_map (source_key, our_field, source_attr, notes) VALUES ${values.join(',')}
     ON CONFLICT (source_key, our_field) DO UPDATE SET
       source_attr=EXCLUDED.source_attr, notes=EXCLUDED.notes, updated_at=NOW()`,
    params,
  );
  return entries.length;
}

/** Uppercase, collapse whitespace, strip ordinal suffixes so queries and stored
 *  addresses normalize the same way ("61ST AVE S" -> "61 AVE S"). Must mirror
 *  normAddressQuery in src/server/property.ts. */
export function normAddress(addr: string): string {
  return addr.trim().toUpperCase()
    .replace(/[.,#]/g, ' ')
    .replace(/\b(\d+)(?:ST|ND|RD|TH)\b/g, '$1')
    .replace(/\s+/g, ' ')
    .trim();
}

export async function registerParcelSource(
  countyFips: string, pathOrUrl: string, notes: string,
): Promise<number> {
  const cnt = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM parcel WHERE county_fips=$1`, [countyFips]);
  const n = Number(cnt.rows[0].n);
  await query(
    `INSERT INTO parcel_source (county_fips, source_kind, path_or_url, parcel_count, notes, loaded_at)
     VALUES ($1,'pg',$2,$3,$4,NOW())
     ON CONFLICT (county_fips) DO UPDATE SET source_kind='pg', path_or_url=EXCLUDED.path_or_url,
       parcel_count=EXCLUDED.parcel_count, notes=EXCLUDED.notes, loaded_at=NOW()`,
    [countyFips, pathOrUrl, n, notes],
  );
  return n;
}