← 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 };
}