← back to Lawyer Directory Builder
src/lib/match_firms.ts
155 lines
/**
* Match a lead to top-N firms.
*
* Algorithm:
* 1. Compute the zip's centroid from our existing geocoded firms in that zip
* (we have lat/lng on ~7,500 firms — most CA zips are covered). Fall back
* to LA County centroid if the zip is unknown.
* 2. Rank candidate firms by:
* - distance from the lead's zip centroid (asc) — primary key
* - has-website (a real online presence makes contact easier)
* - attorney_count (bigger firms field calls faster)
* marketing_score is intentionally NOT in the public-facing sort — it
* creates an "ranked by site quality" appearance that contradicts the
* /find-a-lawyer pact ("no fees from firms"). marketing_score remains
* available on each firm row for admin triage / audit display only.
* 3. Return the top N firm IDs (with metadata for display).
*
* Practice area is stored on the lead but NOT filtered against
* organizations.practice_areas because that column is mostly NULL today —
* admin reviews the queue and can manually re-route or veto matches.
*
* Refactored 2026-05-04: the geographic-anchor + bbox + haversine SQL fragment
* pieces moved to directory-core/match. The lawyer-specific ranking SQL
* (organizations.type='law_firm', site_audits join, attorney_count tiebreaker)
* stays here — those are intrinsic to this vertical's schema.
*/
import { query } from '../db/pool.ts';
import {
resolveZipAnchor,
haversineKmSql,
BBOX_CA,
CENTROID_LA,
type EmptyReason as CoreEmptyReason,
type QueryFn,
} from 'directory-core/match';
// Local query handle typed to directory-core's QueryFn shape. pool.ts's
// generic-constrained `query<T extends QueryResultRow>` is structurally
// compatible (its return type has `rows: T[]`) but the narrower generic
// constraint trips strict variance — this cast erases that without changing
// runtime behavior. resolveZipAnchor only reads `.rows` off the result.
const q: QueryFn = query as unknown as QueryFn;
export type MatchedFirm = {
id: number;
name: string;
address: string | null;
city: string | null;
zip: string | null;
phone: string | null;
website: string | null;
firm_size_band: string | null;
attorney_count: number | null;
marketing_score: number | null;
distance_km: number | null;
};
export async function matchFirmsForLead(opts: {
zip: string | null;
state?: string; // default 'CA'
limit?: number; // default 5
radiusKm?: number; // default 80 (~50 mi). null = no cap.
}): Promise<MatchedFirm[]> {
const limit = opts.limit ?? 5;
const radiusKm = opts.radiusKm === undefined ? 80 : opts.radiusKm;
// Find zip centroid via directory-core. Two-step fallback (exact ZIP →
// 3-digit prefix) so we don't claim a 400-mile-away LA firm is "near" a
// Truckee user. If no ZIP given, fall back to LA County centroid; if ZIP
// given but no anchor resolves, return empty.
let centroid: { lat: number; lng: number } | null;
if (opts.zip) {
centroid = await resolveZipAnchor(q, opts.zip, {
table: 'organizations',
whereExtra: "type='law_firm'",
bbox: BBOX_CA,
});
} else {
centroid = CENTROID_LA;
}
if (!centroid) return [];
// Distance via the great-circle haversine, in km. SQL fragment from
// directory-core/match — same arithmetic the file used inline before.
const distExpr = haversineKmSql('o.lat', 'o.lng', '$1', '$2');
const r = await query<MatchedFirm>(`
SELECT
o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
o.firm_size_band, o.attorney_count,
(SELECT marketing_score FROM site_audits a
WHERE a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
ORDER BY a.audited_at DESC LIMIT 1) AS marketing_score,
${distExpr} AS distance_km
FROM organizations o
WHERE o.type='law_firm'
AND o.lat BETWEEN $3 AND $4
AND o.lng BETWEEN $5 AND $6
ORDER BY
distance_km ASC,
(o.website IS NOT NULL) DESC,
o.attorney_count DESC NULLS LAST
LIMIT $7
`, [centroid.lat, centroid.lng,
BBOX_CA.latMin, BBOX_CA.latMax, BBOX_CA.lngMin, BBOX_CA.lngMax,
limit]);
if (radiusKm == null) return r.rows;
return r.rows.filter(f => f.distance_km == null || f.distance_km <= radiusKm);
}
/**
* Lawyer-vertical alias of directory-core's vertical-neutral EmptyReason.
* Swaps `'no_providers_in_radius'` → `'no_firms_in_radius'` so existing
* consumers in src/server/leads.ts continue to compile unchanged.
*/
export type EmptyReason = Exclude<CoreEmptyReason, 'no_providers_in_radius'> | 'no_firms_in_radius';
/**
* Same as matchFirmsForLead, but reports WHY the result is empty so the UI
* can render an honest empty state. Three distinct cases:
* - no_zip: caller didn't supply a ZIP at all
* - no_anchor: ZIP given but no exact-match or 3-prefix-region firm exists
* (we can't compute proximity at all — "widen radius" is a lie)
* - no_firms_in_radius: anchor exists but the radius cap excluded everyone
*/
export async function matchFirmsWithReason(opts: {
zip: string | null;
limit?: number;
radiusKm?: number;
}): Promise<{ firms: MatchedFirm[]; reason: EmptyReason; anchor: { lat: number; lng: number } | null }> {
const limit = opts.limit ?? 5;
const radiusKm = opts.radiusKm === undefined ? 80 : opts.radiusKm;
if (!opts.zip) {
const firms = await matchFirmsForLead({ zip: null, limit, radiusKm });
return { firms, reason: firms.length ? 'ok' : 'no_zip', anchor: CENTROID_LA };
}
// Resolve anchor via the same shared primitive matchFirmsForLead uses.
const anchor = await resolveZipAnchor(q, opts.zip, {
table: 'organizations',
whereExtra: "type='law_firm'",
bbox: BBOX_CA,
});
if (!anchor) {
return { firms: [], reason: 'no_anchor', anchor: null };
}
const firms = await matchFirmsForLead({ zip: opts.zip, limit, radiusKm });
if (firms.length === 0) {
return { firms: [], reason: 'no_firms_in_radius', anchor };
}
return { firms, reason: 'ok', anchor };
}