← back to Nationalrealestate

src/ingest/brokers/ct_dcp.ts

70 lines

/**
 * CT — DCP "Real Estate Salesperson" full credential roster on data.ct.gov
 * (eqtn-rppv; 99,396 rows probed 2026-07-26). Wave-3 upgrade from the old
 * affiliation-only feed 6tja-6vdt (18,281 ACTIVE salesperson↔broker pairs, no
 * status, no city): eqtn-rppv is the FULL salesperson credential DB — every
 * status (ACTIVE 20.7k / INACTIVE 52k / EXPIRED APPLICATION 22.8k / LAPSED /
 * RETIRED / SUSPENDED / REVOKED …), mailing city+state, and the sponsoring
 * broker linkage (`supervising_broker` name + `broker_license` #, populated on
 * 22,812 rows). Same `source='ct_dcp'` + license_no = fullcredentialcode
 * ("RES.0000000"), which matches the old feed's source_id shape, so this
 * upserts over the existing CT rows in place (enriches status/city, adds the
 * inactive/expired long tail) — no double-count.
 *
 * Salespersons ONLY (credentialtype RES). The broker-side REB credential roster
 * is a separate DCP extract — a follow-up, not this dataset.
 */
import { download } from '../run.ts';
import { parseCsvObjects } from './csv.ts';
import type { BrokerRow, FetchedFile, StateAdapter } from './types.ts';

const URL = 'https://data.ct.gov/resource/eqtn-rppv.csv'
  + '?$order=fullcredentialcode&$limit=200000';

/** Non-individual credential holders (a few dozen) → brokerage firm rows. */
const ENTITY = /^(BUSINESS|CORPORATION|PARTNERSHIP|LIMITED LIABILITY COMPANY)$/i;

export const ctDcp: StateAdapter = {
  state: 'CT',
  source: 'ct_dcp',
  url: URL,
  async fetch() {
    return [await download(URL, 'ct_dcp_salesperson_full_roster.csv')];
  },
  *parse(files: FetchedFile[]): Generator<BrokerRow> {
    for (const o of parseCsvObjects(files[0].path)) {
      // Normalize the credential code to the canonical "RES.#######" shape: a
      // small tail of rows carry a legacy Socrata suffix ("RES.0769651.INACTIVE")
      // — strip it so source_id stays stable if upstream ever cleans the field
      // (else re-ingest would INSERT a duplicate instead of upserting). Require a
      // real credential number so the handful of digitless "RES" stubs are dropped
      // rather than collapsing to one shared key.
      const lic = (o.fullcredentialcode || '').replace(/\.[A-Za-z]+$/, '').trim();
      if (!lic || !/\d/.test(lic)) continue;
      const status = o.status || null;
      const city = o.city || null;
      const st = (o.state || '').toUpperCase() || null;
      const type = (o.type || '').toUpperCase();
      if (ENTITY.test(type)) {
        const name = o.businessname || o.name;
        if (!name) continue;
        yield {
          kind: 'firm', name, license_no: lic,
          license_type: o.credential || null, license_status: status,
          city, state_code: st,
        };
      } else {
        const name = o.name;
        if (!name) continue;
        yield {
          kind: 'broker', name, license_no: lic,
          license_type: o.credential || 'Salesperson', license_status: status,
          city, state_code: st,
          firm_name: o.supervising_broker || null,
          firm_license_no: o.supervising_broker ? (o.broker_license || null) : null,
        };
      }
    }
  },
};