← back to Nationalrealestate

src/server/broker-of-record.ts

177 lines

/**
 * TK-10139: broker-of-record recorder.
 *
 * Append-only change log — every time a broker/firm of record is written for an
 * address (or parcel), we record it IF it differs from the most-recent observation
 * for that key. This is what turns the table into a history of every UPDATE.
 *
 * Keyed by a normalized address_key ("address, city" lowercased/trimmed/collapsed),
 * plus an optional parcel_key ("county_fips:ain") when a stable parcel identity exists.
 *
 * Callable both from server request handlers and from ingest/backfill scripts —
 * takes a db handle (the pool's `query` fn) so it doesn't hard-import a pool.
 */

type QueryFn = <T extends Record<string, any> = any>(
  text: string,
  params?: unknown[],
) => Promise<{ rows: T[] }>;

export interface BrokerOfRecordInput {
  address?: string | null;
  city?: string | null;
  state_code?: string | null;
  county_fips?: string | null;
  ain?: string | null;
  broker_id?: number | null;
  broker_name?: string | null;
  firm_id?: number | null;
  firm_name?: string | null;
  source?: string | null;
  source_url?: string | null;
}

/**
 * CANONICAL address_key normalization — the SINGLE source of truth, imported by the
 * recorder, the backfill, the listings-ingest engine, and the commercial + listings API
 * joins so they can never drift apart.
 *
 * Goal: a listing's full "street, city, ST, ZIP" string and a commercial_parcel's separate
 * (address="street", city="city") fields for the SAME street both reduce to the SAME key,
 * e.g. both "3601 Roosevelt Ave, San Antonio, TX, 78225" and ("3601 Roosevelt Ave","San Antonio")
 * → "3601 roosevelt ave, san antonio".
 *
 * Steps:
 *   1. combine address + optional city as "address, city"
 *   2. lowercase, trim, collapse internal whitespace
 *   3. strip trailing state/zip tokens from the END — a 2-letter US state and/or a 5(-4) ZIP,
 *      whether comma-separated (", tx, 78225") or space-separated ("tx 78225"), repeatedly.
 *
 * IMPORTANT: the matching SQL in `addressKeySql()` MUST stay behaviorally identical.
 */
export function computeAddressKey(address?: string | null, city?: string | null): string {
  const a = (address || '').trim();
  const c = (city || '').trim();
  const combined = c ? `${a}, ${c}` : a;
  let k = combined.toLowerCase().replace(/\s+/g, ' ').trim();
  // strip trailing ZIP (5 or 5-4), then trailing 2-letter state, comma- OR space-separated,
  // repeatedly so ", tx, 78225" / " tx 78225" / ", tx" / ", 78225" all fall off.
  let prev: string;
  do {
    prev = k;
    k = k.replace(/[\s,]+\d{5}(?:-\d{4})?$/, '');   // trailing zip
    k = k.replace(/[\s,]+[a-z]{2}$/, '');           // trailing 2-letter state
  } while (k !== prev);
  return k.trim().replace(/[\s,]+$/, '');
}

/**
 * SQL expression producing the SAME canonical key as computeAddressKey(), given a raw
 * "address, city" text expression `expr`. Callers build `expr` themselves (e.g. the listing's
 * `address` column alone, or a parcel's `address || ', ' || city`) then wrap it here so the
 * join key matches the recorder/backfill exactly.
 *
 * Mirrors computeAddressKey: lower(collapse(trim(expr))) then strip trailing zip/state tokens.
 */
export function addressKeySql(expr: string): string {
  // 1. lowercase + collapse whitespace + trim
  const base = `btrim(regexp_replace(lower(${expr}), '\\s+', ' ', 'g'))`;
  // 2. strip trailing zip then trailing state, applied twice to peel ", ST, ZIP".
  //    Each regexp_replace peels one trailing token; two rounds cover the ST+ZIP pair
  //    (and any single leftover), matching the JS loop's fixed point for our data.
  const stripZip = (e: string) => `regexp_replace(${e}, '[[:space:],]+[0-9]{5}(-[0-9]{4})?$', '')`;
  const stripState = (e: string) => `regexp_replace(${e}, '[[:space:],]+[a-z]{2}$', '')`;
  let e = base;
  for (let i = 0; i < 2; i++) { e = stripZip(e); e = stripState(e); }
  // final trailing punctuation/space trim
  return `regexp_replace(${e}, '[[:space:],]+$', '')`;
}

/** stable parcel key when both county_fips + ain are present, else null */
export function computeParcelKey(county_fips?: string | null, ain?: string | null): string | null {
  const f = (county_fips || '').trim();
  const n = (ain || '').trim();
  return f && n ? `${f}:${n}` : null;
}

const norm = (v: unknown) => (v == null || v === '' ? null : v);

/**
 * Record a broker/firm-of-record observation. Inserts a new history row when there is
 * no prior observation for the address_key, OR when broker_id/broker_name/firm_id/firm_name
 * differ from the latest. No-ops when unchanged. Skips entirely when both broker AND firm
 * are empty (nothing to record).
 *
 * Returns the inserted row id, or null if it was a no-op / skipped.
 */
export async function recordBrokerOfRecord(
  db: QueryFn,
  input: BrokerOfRecordInput,
): Promise<number | null> {
  const brokerId = norm(input.broker_id) as number | null;
  const firmId = norm(input.firm_id) as number | null;
  const brokerName = norm(input.broker_name) as string | null;
  const firmName = norm(input.firm_name) as string | null;

  // nothing to record if there's no broker AND no firm of record
  if (brokerId == null && firmId == null && brokerName == null && firmName == null) return null;

  const addressKey = computeAddressKey(input.address, input.city);
  if (!addressKey) return null; // can't key an empty address

  const parcelKey = computeParcelKey(input.county_fips, input.ain);

  // latest observation for this address_key
  const prev = await db<{
    broker_id: number | null;
    broker_name: string | null;
    firm_id: number | null;
    firm_name: string | null;
  }>(
    `SELECT broker_id, broker_name, firm_id, firm_name
       FROM broker_of_record_history
      WHERE address_key = $1
      ORDER BY observed_at DESC
      LIMIT 1`,
    [addressKey],
  );

  if (prev.rows.length) {
    const p = prev.rows[0];
    // NOTE: pg returns BIGINT columns (broker_id, firm_id) as STRINGS, while callers pass
    // numbers — so compare id fields as strings to avoid a spurious "changed" on every run.
    const idEq = (a: unknown, b: unknown) =>
      (a == null ? null : String(a)) === (b == null ? null : String(b));
    const same =
      idEq(p.broker_id, brokerId) &&
      idEq(p.firm_id, firmId) &&
      (p.broker_name ?? null) === (brokerName ?? null) &&
      (p.firm_name ?? null) === (firmName ?? null);
    if (same) return null; // unchanged — no-op
  }

  const ins = await db<{ id: number }>(
    `INSERT INTO broker_of_record_history
       (address_key, parcel_key, county_fips, ain, address, city, state_code,
        broker_id, broker_name, firm_id, firm_name, source, source_url)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
     RETURNING id`,
    [
      addressKey,
      parcelKey,
      norm(input.county_fips),
      norm(input.ain),
      norm(input.address),
      norm(input.city),
      norm(input.state_code),
      brokerId,
      brokerName,
      firmId,
      firmName,
      norm(input.source),
      norm(input.source_url),
    ],
  );
  return ins.rows[0]?.id ?? null;
}