← back to Directory Core
src/match.ts
175 lines
// directory-core / match
//
// Geographic-anchor + bounding-box primitives shared by every directory's
// "match-providers-near-this-lead" feature.
//
// Source: extracted from `lawyer-directory-builder/src/lib/match_firms.ts` 2026-05-04.
// The original file fused these primitives with lawyer-specific column choices
// (organizations.type='law_firm', attorney_count, marketing_score, site_audits
// join). This module exports JUST the vertical-neutral pieces; each consumer
// composes its own matching SQL on top.
//
// What's IN this module:
// - BoundingBox + the canonical CA / LA bounds Steve uses
// - EmptyReason discriminator for honest empty-state UX
// - resolveZipAnchor(query, zip, opts) — exact-zip → 3-digit-prefix fallback
//
// What's NOT in this module:
// - The actual provider-ranking SQL (every vertical has different score columns)
// - Per-vertical defaults (LA centroid, lawyer's attorney_count tiebreaker)
//
// The architect's review (2026-05-04) flagged the original `matchFirmsForLead`
// as too schema-coupled to extract. We're extracting only the generic geographic
// resolution layer beneath it. Each vertical writes ~15 lines of its own ranking
// SQL on top.
import type { LatLng } from './geo.js';
import { assertSafeIdent } from './_ident.js';
export interface BoundingBox {
latMin: number;
latMax: number;
lngMin: number;
lngMax: number;
}
/** California bounding box. Used as a hard filter against bad state-column data. */
export const BBOX_CA: BoundingBox = {
latMin: 32.5, latMax: 42.05, lngMin: -124.5, lngMax: -114.0,
};
/** Continental US bounding box (excludes AK/HI/territories). */
export const BBOX_US_CONTINENTAL: BoundingBox = {
latMin: 24.5, latMax: 49.5, lngMin: -125.0, lngMax: -66.5,
};
/** Default fallback centroid for CA-only verticals when a ZIP doesn't resolve. */
export const CENTROID_LA: LatLng = { lat: 34.0522, lng: -118.2437 };
/**
* Why is the match result empty? Front-end uses this to render an honest
* empty state instead of misleading "widen your search radius" copy.
*/
export type EmptyReason =
| 'ok' // results returned, no error
| 'no_zip' // caller didn't supply a ZIP
| 'no_anchor' // ZIP given but no records exist in that ZIP or 3-digit prefix
| 'no_providers_in_radius'; // anchor exists but radius cap excluded everyone
/** Generic query function shape — matches pg's `pool.query` and directory-core/db's `query`. */
export type QueryFn = <T = unknown>(text: string, params: unknown[]) => Promise<{ rows: T[] }>;
export interface ResolveZipOpts {
/** Table to compute the centroid from. Must have `lat`, `lng`, and `zip` columns. */
table: string;
/** Optional additional WHERE clause appended after `zip = $1`. Example: `"type='law_firm'"`. */
whereExtra?: string;
/** Bounding box hard-filter to defend against bad lat/lng data. Default: BBOX_CA. */
bbox?: BoundingBox;
}
/**
* Resolve a ZIP code to a geographic centroid by averaging the lat/lng of
* existing records in that ZIP. Two-step fallback:
* 1. Exact ZIP match → centroid of all records in that ZIP
* 2. First-3-digit ZIP prefix (same SCF region) → centroid of all records in that prefix
* 3. null → caller decides (fallback centroid, return empty, etc.)
*
* This is preferable to a ZIP-centroids API for established directories: if
* you already have N records geocoded in that ZIP, their centroid is more
* accurate than the postal-service centroid (which can be miles off in
* sprawling rural ZIPs).
*
* Returns null if neither exact nor prefix match yields any records.
*/
export async function resolveZipAnchor(
query: QueryFn,
zip: string | null | undefined,
opts: ResolveZipOpts,
): Promise<LatLng | null> {
if (!zip || typeof zip !== 'string') return null;
const z = zip.trim();
if (!/^[0-9]{3,5}$/.test(z)) return null;
// Audit P1-c 2026-05-04: opts.table is interpolated into SQL — gate it.
// opts.whereExtra remains documented as caller-supplied SQL (its whole point
// is to inject a vertical-specific predicate); callers are expected to keep
// it as a hard-coded literal, never thread request data through it.
assertSafeIdent(opts.table, 'resolveZipAnchor.table');
const bbox = opts.bbox ?? BBOX_CA;
const extra = opts.whereExtra ? `AND (${opts.whereExtra})` : '';
// Step 1: exact ZIP match
if (z.length === 5) {
const r1 = await query<{ lat: number | null; lng: number | null; n: number }>(
`SELECT AVG(lat)::float8 AS lat, AVG(lng)::float8 AS lng, COUNT(*)::int AS n
FROM ${opts.table}
WHERE zip = $1
AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5
${extra}`,
[z, bbox.latMin, bbox.latMax, bbox.lngMin, bbox.lngMax],
);
const row = r1.rows[0];
if (row && row.n > 0 && row.lat !== null && row.lng !== null) {
return { lat: row.lat, lng: row.lng };
}
}
// Step 2: 3-digit ZIP prefix
const prefix = z.slice(0, 3);
if (prefix.length === 3) {
const r2 = await query<{ lat: number | null; lng: number | null; n: number }>(
`SELECT AVG(lat)::float8 AS lat, AVG(lng)::float8 AS lng, COUNT(*)::int AS n
FROM ${opts.table}
WHERE zip LIKE $1
AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5
${extra}`,
[prefix + '%', bbox.latMin, bbox.latMax, bbox.lngMin, bbox.lngMax],
);
const row = r2.rows[0];
if (row && row.n > 0 && row.lat !== null && row.lng !== null) {
return { lat: row.lat, lng: row.lng };
}
}
return null;
}
/**
* SQL fragment that computes great-circle distance in km between a fixed
* (lat, lng) pair (passed as $latParam, $lngParam) and a row's (lat, lng)
* columns (named by `latCol` and `lngCol`).
*
* Use as a SELECT expression to add a distance_km column without needing
* the PostGIS or earthdistance extensions:
*
* const sql = `SELECT id, ${haversineKmSql('o.lat', 'o.lng', '$1', '$2')} AS distance_km
* FROM organizations o ORDER BY distance_km LIMIT $3`;
* await query(sql, [centroid.lat, centroid.lng, 5]);
*/
export function haversineKmSql(latCol: string, lngCol: string, latParam: string, lngParam: string): string {
return `(2 * 6371 * ASIN(SQRT(
POW(SIN(RADIANS((${latCol} - ${latParam}) / 2)), 2) +
COS(RADIANS(${latParam})) * COS(RADIANS(${latCol})) *
POW(SIN(RADIANS((${lngCol} - ${lngParam}) / 2)), 2)
)))::float8`;
}
/**
* SQL fragment that asserts a row's (lat, lng) falls within a bounding box.
* Use as a WHERE-clause predicate. Caller binds the four bounds as the
* specified positional params.
*
* `WHERE ${withinBoundingBoxSql('o.lat', 'o.lng', '$1', '$2', '$3', '$4')}`
* query.params: [bbox.latMin, bbox.latMax, bbox.lngMin, bbox.lngMax]
*/
export function withinBoundingBoxSql(
latCol: string, lngCol: string,
latMinParam: string, latMaxParam: string,
lngMinParam: string, lngMaxParam: string,
): string {
return `${latCol} BETWEEN ${latMinParam} AND ${latMaxParam} AND ` +
`${lngCol} BETWEEN ${lngMinParam} AND ${lngMaxParam}`;
}