← back to Directory Core

src/geo.ts

170 lines

// directory-core / geo
//
// Pure geographic primitives + ZIP centroid lookup.
//
// Source: extracted from `animals/src/lib/{auth.js,server/community.js,lib/geo.js}` 2026-05-04.
// Animals had `haversineMi` duplicated in two files; lawyer-directory used PG's
// earthdistance extension and km units; restaurant-directory rolled its own.
// This module is the canonical impl — both miles and km, both verticals can use.
//
// What's IN this module (vertical-neutral):
//   - haversineMi / haversineKm — pure great-circle distance
//   - geocodeZip — Nominatim-backed US ZIP → centroid, with cache-table support
//   - LatLng type
//
// What's NOT in this module (vertical-specific, stays in consumer):
//   - backfillKnownZips (references vertical tables like app_users, marketplace_listings)
//   - per-vertical "near me" SQL templates
//
// The `geocodeZip` function takes an optional `cache` adapter so it can write
// to a `zip_centroids` table if the consumer maintains one. If `cache` is
// omitted, every call hits Nominatim — fine for one-shots but not for
// production loops. ALWAYS pass cache in production.

export interface LatLng {
  lat: number;
  lng: number;
}

const EARTH_RADIUS_MILES = 3958.8;
const EARTH_RADIUS_KM = 6371.0088;
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';

/**
 * Great-circle distance in MILES between two points using the haversine formula.
 * Inputs are degrees (positive lat = north, positive lng = east).
 */
export function haversineMi(a: LatLng, b: LatLng): number {
  return haversine(a, b, EARTH_RADIUS_MILES);
}

/**
 * Great-circle distance in KILOMETERS between two points using the haversine formula.
 */
export function haversineKm(a: LatLng, b: LatLng): number {
  return haversine(a, b, EARTH_RADIUS_KM);
}

function haversine(a: LatLng, b: LatLng, R: number): number {
  const toRad = (d: number) => (Number(d) * Math.PI) / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLng = toRad(b.lng - a.lng);
  const sinHalfLat = Math.sin(dLat / 2);
  const sinHalfLng = Math.sin(dLng / 2);
  const x =
    sinHalfLat * sinHalfLat +
    Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinHalfLng * sinHalfLng;
  return 2 * R * Math.asin(Math.sqrt(x));
}

// ── ZIP centroid lookup ─────────────────────────────────────────────────────

const DEFAULT_UA = 'directory-core-zipgeocode/0.2 (contact: steveabramsdesigns@gmail.com)';

export interface ZipCacheAdapter {
  /** Returns the cached centroid, or null on miss. */
  get(zip: string): Promise<LatLng | null>;
  /** Writes the centroid. Should be idempotent (ON CONFLICT DO NOTHING). */
  put(zip: string, coords: LatLng, source: string): Promise<void>;
}

export interface GeocodeOpts {
  cache?: ZipCacheAdapter;
  userAgent?: string;
  timeoutMs?: number;
  /** Inject your fetch impl for testability. Defaults to globalThis.fetch. */
  fetchImpl?: typeof fetch;
}

export interface GeocodeResult extends LatLng {
  source: 'cache' | 'nominatim';
}

/**
 * Geocode a US ZIP to {lat, lng} via cache (if provided), then Nominatim.
 * Returns null on invalid ZIP, network failure, or no match.
 *
 * Politeness: callers responsible for ≤1 req/s when running in a loop. This
 * function does NOT sleep between calls — that's a consumer concern (see
 * compliance.fetchCompliant for the rate-limited variant pattern).
 */
export async function geocodeZip(zip: unknown, opts: GeocodeOpts = {}): Promise<GeocodeResult | null> {
  if (typeof zip !== 'string' && typeof zip !== 'number') return null;
  const z = String(zip).trim();
  if (!/^[0-9]{5}$/.test(z)) return null;

  // 1. Cache hit?
  if (opts.cache) {
    const hit = await opts.cache.get(z);
    if (hit) return { ...hit, source: 'cache' };
  }

  // 2. Hit Nominatim with a US-scoped query.
  const fetchFn = opts.fetchImpl ?? globalThis.fetch;
  const url = new URL(NOMINATIM_URL);
  url.searchParams.set('q', `${z}, USA`);
  url.searchParams.set('format', 'json');
  url.searchParams.set('limit', '1');
  url.searchParams.set('countrycodes', 'us');

  let coords: LatLng | null = null;
  try {
    const r = await fetchFn(url.toString(), {
      headers: { 'user-agent': opts.userAgent ?? DEFAULT_UA, accept: 'application/json' },
      signal: AbortSignal.timeout(opts.timeoutMs ?? 6000),
    });
    if (!r.ok) return null;
    const arr = (await r.json()) as Array<{ lat?: string; lon?: string }>;
    const hit = Array.isArray(arr) && arr[0];
    if (!hit || !hit.lat || !hit.lon) return null;
    coords = { lat: parseFloat(hit.lat), lng: parseFloat(hit.lon) };
  } catch {
    return null;
  }

  // 3. Cache the result. Failures are non-fatal.
  if (opts.cache) {
    try { await opts.cache.put(z, coords, 'nominatim'); } catch { /* ignore */ }
  }

  return { ...coords, source: 'nominatim' };
}

/**
 * Convenience: build a ZipCacheAdapter from a `query`-style PG helper.
 * Assumes the consumer's DB has a `zip_centroids(zip, latitude, longitude, source)` table.
 *
 * Usage:
 *   import { query } from 'directory-core/db';
 *   import { geocodeZip, makePgZipCache } from 'directory-core/geo';
 *   const cache = makePgZipCache(query);
 *   await geocodeZip('90210', { cache });
 */
export function makePgZipCache(
  query: <T = unknown>(text: string, params: unknown[]) => Promise<{ rows: T[] }>,
  table = 'zip_centroids'
): ZipCacheAdapter {
  // Audit P1-c 2026-05-04: gate the table identifier — interpolated raw into SQL.
  // Inline regex (geo.ts shouldn't need _ident.js for this single check).
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(table)) {
    throw new Error(`makePgZipCache: refusing unsafe table name: ${JSON.stringify(table)}`);
  }
  return {
    async get(zip: string) {
      const r = await query<{ lat: number; lng: number }>(
        `SELECT latitude::float8 AS lat, longitude::float8 AS lng FROM ${table} WHERE zip = $1`,
        [zip]
      );
      const row = r.rows[0];
      return row ? { lat: row.lat, lng: row.lng } : null;
    },
    async put(zip: string, coords: LatLng, source: string) {
      await query(
        `INSERT INTO ${table} (zip, latitude, longitude, source)
         VALUES ($1, $2, $3, $4) ON CONFLICT (zip) DO NOTHING`,
        [zip, coords.lat, coords.lng, source]
      );
    },
  };
}