← back to Professional Directory
scripts/link_locations_to_orgs.js
94 lines
#!/usr/bin/env node
/**
* Backfill professional_locations.organization_id.
*
* The NPI bulk importer writes a professional_locations row per Type-1 NPI
* but never sets organization_id (Type-1 rows don't carry an org NPI). This
* leaves every ZIP/neighborhood query returning 0 doctors per org.
*
* Strategy — two passes, conservative first:
* 1. addr_norm + phone match (single best org per location)
* 2. addr_norm only, but only when exactly one org sits at that addr_norm
*
* Re-runnable: only touches rows where organization_id IS NULL.
*/
const { pool, query } = require('../agents/shared/db');
async function pass1Strict() {
console.log('[link] pass 1: strict (addr_norm + phone) match…');
const r = await query(`
WITH cand AS (
SELECT pl.id AS pl_id, o.id AS org_id,
ROW_NUMBER() OVER (
PARTITION BY pl.id
ORDER BY o.id
) AS rn
FROM professional_locations pl
JOIN organizations o
ON lower(regexp_replace(pl.address, '[^a-zA-Z0-9]', '', 'g')) =
lower(regexp_replace(o.address, '[^a-zA-Z0-9]', '', 'g'))
AND pl.phone = o.phone
WHERE pl.organization_id IS NULL
AND pl.address IS NOT NULL
AND o.address IS NOT NULL
AND o.opted_out = FALSE
)
UPDATE professional_locations pl
SET organization_id = cand.org_id
FROM cand
WHERE pl.id = cand.pl_id AND cand.rn = 1
RETURNING pl.id
`, []);
console.log(`[link] pass 1 linked ${r.rowCount} locations`);
return r.rowCount;
}
async function pass2AddrUnique() {
console.log('[link] pass 2: addr_norm match where exactly one org at that addr_norm…');
const r = await query(`
WITH org_singletons AS (
SELECT lower(regexp_replace(address, '[^a-zA-Z0-9]', '', 'g')) AS a_norm,
MIN(id) AS org_id,
COUNT(*) AS n
FROM organizations
WHERE address IS NOT NULL AND opted_out = FALSE
GROUP BY 1
HAVING COUNT(*) = 1
)
UPDATE professional_locations pl
SET organization_id = s.org_id
FROM org_singletons s
WHERE pl.organization_id IS NULL
AND pl.address IS NOT NULL
AND lower(regexp_replace(pl.address, '[^a-zA-Z0-9]', '', 'g')) = s.a_norm
RETURNING pl.id
`, []);
console.log(`[link] pass 2 linked ${r.rowCount} locations`);
return r.rowCount;
}
async function summary() {
const r = await query(`
SELECT
(SELECT COUNT(*) FROM professional_locations) AS total,
(SELECT COUNT(*) FROM professional_locations WHERE organization_id IS NOT NULL) AS linked,
(SELECT COUNT(*) FROM professional_locations WHERE organization_id IS NULL) AS unlinked
`, []);
const row = r.rows[0];
const pct = ((Number(row.linked) / Number(row.total)) * 100).toFixed(1);
console.log(`[link] total=${row.total} linked=${row.linked} (${pct}%) unlinked=${row.unlinked}`);
}
async function main() {
await pass1Strict();
await pass2AddrUnique();
await summary();
await pool.end();
}
main().catch(async (err) => {
console.error('[link] fatal:', err);
try { await pool.end(); } catch (_) {}
process.exit(1);
});