← back to Professional Directory

agents/ingest-agent/importers/npi.js

373 lines

#!/usr/bin/env node
/**
 * NPI bulk importer — Stage 1 of the Professional Directory build.
 *
 * Streams the NPPES NPI bulk CSV (downloaded separately to ./data/nppes/),
 * filters to state=CA + LA County ZIPs, and upserts:
 *   - Type 1 (individual)     → professionals
 *   - Type 2 (organization)   → organizations
 * Plus professional_locations, phones, professional_specialties, raw_records.
 *
 * Sources: https://download.cms.gov/nppes/NPI_Files.html  (public domain, no key)
 *
 * Smoke mode (SMOKE=1) reads agents/ingest-agent/importers/__fixtures__/npi_smoke.csv
 * — 5 synthetic LA-county rows — so the pipeline can be exercised end-to-end
 * without downloading the ~6 GB bulk file.
 */
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { parse } = require('csv-parse');
const { pool, query, withTx } = require('../../shared/db');
const { laZipSet } = require('../../shared/la-zips');

const SMOKE = process.env.SMOKE === '1';
const FIXTURE = path.join(__dirname, '__fixtures__/npi_smoke.csv');
const DATA_DIR = path.resolve(__dirname, '../../../data/nppes');

const SOURCE_NAME = 'NPPES NPI Registry (Bulk)';

// ─── Helpers ────────────────────────────────────────────────────────────────

function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); }

function clean(s) {
  if (s === undefined || s === null) return null;
  const t = String(s).trim();
  return t.length === 0 ? null : t;
}

function zip5(z) {
  if (!z) return null;
  const m = String(z).match(/\d{5}/);
  return m ? m[0] : null;
}

function fullName(row) {
  const parts = [
    row['Provider Name Prefix Text'],
    row['Provider First Name'],
    row['Provider Middle Name'],
    row['Provider Last Name (Legal Name)'],
    row['Provider Name Suffix Text'],
  ].map(clean).filter(Boolean);
  return parts.join(' ');
}

function pickLicense(row) {
  // Find the first CA license. NPI rows can have license_1 .. license_15.
  for (let i = 1; i <= 15; i++) {
    const n = clean(row[`Provider License Number_${i}`]);
    const s = clean(row[`Provider License Number State Code_${i}`]);
    if (n && s === 'CA') return { license_number: n };
  }
  return { license_number: null };
}

function pickTaxonomies(row) {
  const out = [];
  for (let i = 1; i <= 15; i++) {
    const code = clean(row[`Healthcare Provider Taxonomy Code_${i}`]);
    if (!code) continue;
    const isPrimary = clean(row[`Healthcare Provider Primary Taxonomy Switch_${i}`]) === 'Y';
    out.push({ code, is_primary: isPrimary });
  }
  // Prefer the primary; fall back to first.
  out.sort((a, b) => Number(b.is_primary) - Number(a.is_primary));
  return out;
}

function pickPracticeAddress(row) {
  return {
    address: [clean(row['Provider First Line Business Practice Location Address']),
              clean(row['Provider Second Line Business Practice Location Address'])]
              .filter(Boolean).join(', ') || null,
    city:    clean(row['Provider Business Practice Location Address City Name']),
    state:   clean(row['Provider Business Practice Location Address State Name']),
    zip:     zip5(row['Provider Business Practice Location Address Postal Code']),
    phone:   clean(row['Provider Business Practice Location Address Telephone Number']),
  };
}

function specialtyName(taxonomyCode, taxonomyMap) {
  return taxonomyMap.get(taxonomyCode) || null;
}

// ─── Upserts ────────────────────────────────────────────────────────────────

async function upsertProfessional(client, row, src) {
  const npi = clean(row['NPI']);
  if (!npi) return null;

  const license = pickLicense(row);
  const taxonomies = pickTaxonomies(row);
  const primaryTax = taxonomies[0]?.code || null;

  const enumDate = clean(row['Provider Enumeration Date']);
  // CMS NPI dates are MM/DD/YYYY
  const issueDate = enumDate ? toIsoDate(enumDate) : null;

  const params = [
    fullName(row),
    clean(row['Provider First Name']),
    clean(row['Provider Last Name (Legal Name)']),
    clean(row['Provider Middle Name']),
    clean(row['Provider Name Suffix Text']),
    clean(row['Provider Credential Text']),
    license.license_number,
    null, // license_type — filled by DCA/MBC stages
    null, // license_status
    issueDate,
    npi,
    clean(row['Provider Gender Code']),
    primaryTax,
    0.30, // initial NPI-only confidence; raised by later stages
  ];
  if (process.env.NPI_DEBUG === '1') {
    console.log('[npi] params lengths =', params.map(p => p === null ? 'null' : `'${p}'(${String(p).length})`));
  }
  const r = await client.query(`
    INSERT INTO professionals (
      full_name, first_name, last_name, middle_name, suffix, title,
      license_number, license_type, license_status, license_issue_date,
      npi_number, gender, primary_specialty,
      source_confidence_score
    ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
    ON CONFLICT (npi_number) DO UPDATE SET
      full_name           = EXCLUDED.full_name,
      first_name          = EXCLUDED.first_name,
      last_name           = EXCLUDED.last_name,
      middle_name         = EXCLUDED.middle_name,
      license_number      = COALESCE(EXCLUDED.license_number, professionals.license_number),
      primary_specialty   = COALESCE(EXCLUDED.primary_specialty, professionals.primary_specialty),
      updated_at          = NOW()
    RETURNING id
  `, params);
  return r.rows[0].id;
}

async function upsertOrganization(client, row, src) {
  const npi = clean(row['NPI']);
  if (!npi) return null;

  const orgName = clean(row['Provider Organization Name (Legal Business Name)']);
  if (!orgName) return null;

  const addr = pickPracticeAddress(row);

  const r = await client.query(`
    INSERT INTO organizations (
      name, type, address, city, state, zip, county, phone, npi_number
    ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
    ON CONFLICT (npi_number) DO UPDATE SET
      name    = EXCLUDED.name,
      address = COALESCE(EXCLUDED.address, organizations.address),
      city    = COALESCE(EXCLUDED.city,    organizations.city),
      zip     = COALESCE(EXCLUDED.zip,     organizations.zip),
      phone   = COALESCE(EXCLUDED.phone,   organizations.phone),
      updated_at = NOW()
    RETURNING id
  `, [
    orgName,
    'medical_group', // best guess until Stage 4 fills facility-type rows
    addr.address,
    addr.city,
    addr.state,
    addr.zip,
    'Los Angeles',
    addr.phone,
    npi,
  ]);
  return r.rows[0].id;
}

async function upsertProfessionalLocation(client, professionalId, row, sourceUrl) {
  if (!professionalId) return;
  const addr = pickPracticeAddress(row);
  if (!addr.address && !addr.zip) return;

  await client.query(`
    INSERT INTO professional_locations (
      professional_id, role, address, phone, source_url, is_primary, last_verified_at
    ) VALUES ($1,$2,$3,$4,$5,TRUE,NOW())
  `, [professionalId, 'attending', addr.address, addr.phone, sourceUrl]);

  if (addr.phone) {
    await client.query(`
      INSERT INTO phones (professional_id, phone, phone_type, source_url, last_verified_at)
      VALUES ($1,$2,'office',$3,NOW())
    `, [professionalId, addr.phone, sourceUrl]);
  }
}

async function attachSpecialties(client, professionalId, row, taxonomyMap) {
  if (!professionalId) return;
  const taxonomies = pickTaxonomies(row);
  for (const tax of taxonomies) {
    // Make sure a specialties row exists for this taxonomy code.
    const name = specialtyName(tax.code, taxonomyMap) || tax.code;
    const sp = await client.query(`
      INSERT INTO specialties (name, taxonomy_code)
      VALUES ($1,$2)
      ON CONFLICT (taxonomy_code) DO UPDATE SET name = EXCLUDED.name
      RETURNING id
    `, [name, tax.code]);
    await client.query(`
      INSERT INTO professional_specialties (professional_id, specialty_id, source, confidence_score)
      VALUES ($1,$2,'NPI',$3)
      ON CONFLICT (professional_id, specialty_id) DO UPDATE SET source = EXCLUDED.source
    `, [professionalId, sp.rows[0].id, tax.is_primary ? 0.90 : 0.60]);
  }
}

async function stashRaw(client, sourceId, row, entityType, entityId) {
  const json = JSON.stringify(row);
  const hash = sha256(json + '|' + entityType + '|' + (entityId || ''));
  await client.query(`
    INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, hash)
    VALUES ($1,$2,$3,$4,$5::jsonb,$6)
    ON CONFLICT (hash) DO NOTHING
  `, [
    sourceId,
    'https://download.cms.gov/nppes/NPI_Files.html',
    entityType,
    entityId,
    json,
    hash,
  ]);
}

function toIsoDate(s) {
  // Try MM/DD/YYYY first (NPPES default), then YYYY-MM-DD.
  const us = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
  if (us) return `${us[3]}-${us[1]}-${us[2]}`;
  const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  return iso ? s : null;
}

// ─── Main ───────────────────────────────────────────────────────────────────

async function loadTaxonomyMap() {
  // Populated by seed_specialties — fall back to empty map.
  const r = await query('SELECT taxonomy_code, name FROM specialties WHERE taxonomy_code IS NOT NULL', []);
  return new Map(r.rows.map(r => [r.taxonomy_code, r.name]));
}

async function getSourceId() {
  const r = await query('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
  if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
  return r.rows[0].id;
}

async function startJob(sourceId, label) {
  const r = await query(`
    INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
    VALUES ($1,$2,'running',NOW())
    RETURNING id
  `, [sourceId, label]);
  return r.rows[0].id;
}

async function finishJob(jobId, fields) {
  const sets = [];
  const params = [];
  let i = 1;
  for (const [k, v] of Object.entries(fields)) {
    sets.push(`${k} = $${i++}`); params.push(v);
  }
  sets.push(`finished_at = NOW()`);
  params.push(jobId);
  await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
}

function pickInputCsv() {
  if (SMOKE) return FIXTURE;
  if (!fs.existsSync(DATA_DIR)) {
    throw new Error(`Bulk NPI dir missing: ${DATA_DIR}\n` +
      `Download the latest npidata_pfile_*.csv from\n` +
      `  https://download.cms.gov/nppes/NPI_Files.html\n` +
      `and place it in ${DATA_DIR}/.\n` +
      `For a quick smoke test instead, run:  SMOKE=1 npm run ingest:npi`);
  }
  const csvs = fs.readdirSync(DATA_DIR)
    .filter(f => /^npidata_pfile.*\.csv$/i.test(f))
    .filter(f => !/_fileheader\.csv$/i.test(f))   // exclude the 12KB header-only file
    .sort();
  if (csvs.length === 0) throw new Error(`No npidata_pfile_*.csv in ${DATA_DIR}`);
  return path.join(DATA_DIR, csvs[csvs.length - 1]);
}

async function main() {
  console.log(`[npi] mode=${SMOKE ? 'smoke' : 'bulk'}`);
  const inputCsv = pickInputCsv();
  console.log(`[npi] input = ${inputCsv}`);

  const sourceId = await getSourceId();
  const jobId = await startJob(sourceId, SMOKE ? 'npi:smoke' : 'npi:bulk');
  const taxonomyMap = await loadTaxonomyMap();
  const laZips = await laZipSet();

  let found = 0, inserted = 0, updated = 0, skipped = 0;

  const parser = fs.createReadStream(inputCsv).pipe(parse({
    columns: true,
    skip_empty_lines: true,
    relax_column_count: true,
    bom: true,
  }));

  for await (const row of parser) {
    found++;

    const entityType = clean(row['Entity Type Code']); // '1' or '2'
    const stateCode  = clean(row['Provider Business Practice Location Address State Name']);
    const z          = zip5(row['Provider Business Practice Location Address Postal Code']);

    if (stateCode && stateCode !== 'CA') { skipped++; continue; }
    if (z && !laZips.has(z))             { skipped++; continue; }
    if (!stateCode && !z)                { skipped++; continue; } // no usable address

    try {
      await withTx(async (client) => {
        if (entityType === '1') {
          const id = await upsertProfessional(client, row, SOURCE_NAME);
          await upsertProfessionalLocation(client, id, row, 'https://download.cms.gov/nppes/NPI_Files.html');
          await attachSpecialties(client, id, row, taxonomyMap);
          await stashRaw(client, sourceId, row, 'professional', id);
          inserted++;
        } else if (entityType === '2') {
          const id = await upsertOrganization(client, row, SOURCE_NAME);
          await stashRaw(client, sourceId, row, 'organization', id);
          inserted++;
        } else {
          skipped++;
        }
      });
    } catch (e) {
      console.error(`[npi] row error npi=${row['NPI']}: ${e.message} (col=${e.column}, schemaTable=${e.table})`);
      skipped++;
    }

    if (found % 5000 === 0) {
      console.log(`[npi] scanned=${found} kept=${inserted} skipped=${skipped}`);
    }
  }

  await finishJob(jobId, {
    status: 'done',
    records_found: found,
    records_inserted: inserted,
    records_updated: updated,
    records_skipped: skipped,
  });

  console.log(`[npi] done. scanned=${found} kept=${inserted} skipped=${skipped}`);
  await pool.end();
}

main().catch(async (err) => {
  console.error('[npi] fatal:', err);
  try { await pool.end(); } catch (_) {}
  process.exit(1);
});