Files
acmcc/supabase/functions/geocode/index.ts
2026-06-01 20:19:26 -04:00

279 lines
9.5 KiB
TypeScript

// Geocode an address with precision-aware fallback chain:
// 1. Mapbox Geocoding v6 (rooftop, very precise — primary)
// 2. US Census Geocoder (rooftop interpolation, US-only fallback)
// 3. Nominatim (OSM) — only accept if result includes a house_number
// 4. Photon (Komoot OSM) — only accept if housenumber present
// Caches results in public.address_geocodes so subsequent lookups are instant.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
function normalize(addr: string): string {
return addr
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,#]/g, "")
.trim();
}
interface GeoResult {
lat: number;
lng: number;
display_name: string;
source: string;
precision: "rooftop" | "interpolated" | "street" | "approximate";
}
// Mapbox Geocoding API v6 — highest precision; address-level results include
// match_code data and a precise rooftop point.
async function viaMapbox(
address: string,
options?: { context?: string | null },
): Promise<GeoResult | null> {
const token =
Deno.env.get("MAPBOX_SECRET_TOKEN") || Deno.env.get("MAPBOX_PUBLIC_TOKEN");
if (!token) return null;
const query = options?.context?.trim()
? `${address}, ${options.context.trim()}`
: address;
const params = new URLSearchParams({
q: query,
limit: "1",
autocomplete: "false",
types: "address,street",
access_token: token,
});
const url = `https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`;
try {
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
if (!r.ok) return null;
const data = await r.json();
const feat = data?.features?.[0];
if (!feat) return null;
const [lng, lat] = feat.geometry?.coordinates || [];
if (typeof lat !== "number" || typeof lng !== "number") return null;
const props = feat.properties || {};
const featureType: string = props.feature_type || "";
// address = rooftop, street = street centerline, place/locality = city, etc.
let precision: GeoResult["precision"] = "approximate";
if (featureType === "address") precision = "rooftop";
else if (featureType === "street") precision = "street";
else if (featureType === "block" || featureType === "secondary_address")
precision = "interpolated";
return {
lat,
lng,
display_name: props.full_address || props.name || address,
source: "mapbox",
precision,
};
} catch (_e) {
return null;
}
}
// Census returns interpolated rooftop coordinates from TIGER address ranges.
async function viaCensus(address: string): Promise<GeoResult | null> {
const url = `https://geocoding.geo.census.gov/geocoder/locations/onelineaddress?address=${encodeURIComponent(
address,
)}&benchmark=Public_AR_Current&format=json`;
try {
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
if (!r.ok) return null;
const data = await r.json();
const match = data?.result?.addressMatches?.[0];
if (!match?.coordinates) return null;
return {
lat: match.coordinates.y,
lng: match.coordinates.x,
display_name: match.matchedAddress || address,
source: "census",
precision: "interpolated",
};
} catch (_e) {
return null;
}
}
async function viaNominatim(address: string): Promise<GeoResult | null> {
const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=5&q=${encodeURIComponent(
address,
)}`;
try {
const r = await fetch(url, {
headers: {
"User-Agent": "lovable-geocode/1.0 (contact: support@avria.cloud)",
"Accept-Language": "en",
},
});
if (!r.ok) return null;
const data = await r.json();
if (!Array.isArray(data) || !data.length) return null;
const withHouse = data.find(
(d: any) => d?.address?.house_number || d?.class === "building",
);
const pick = withHouse || data[0];
const precision: GeoResult["precision"] = withHouse
? "rooftop"
: pick?.type === "residential" || pick?.class === "highway"
? "street"
: "approximate";
return {
lat: parseFloat(pick.lat),
lng: parseFloat(pick.lon),
display_name: pick.display_name,
source: "nominatim",
precision,
};
} catch (_e) {
return null;
}
}
async function viaPhoton(address: string): Promise<GeoResult | null> {
const url = `https://photon.komoot.io/api/?limit=5&q=${encodeURIComponent(address)}`;
try {
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
if (!r.ok) return null;
const data = await r.json();
const features: any[] = data?.features || [];
if (!features.length) return null;
const withHouse = features.find((f) => f?.properties?.housenumber);
const pick = withHouse || features[0];
const [lng, lat] = pick.geometry?.coordinates || [];
if (typeof lat !== "number" || typeof lng !== "number") return null;
const p = pick.properties || {};
const display = [
p.housenumber && p.street ? `${p.housenumber} ${p.street}` : p.street || p.name,
p.city || p.town || p.village,
p.state,
p.postcode,
]
.filter(Boolean)
.join(", ");
const precision: GeoResult["precision"] = withHouse
? "rooftop"
: p.osm_key === "highway"
? "street"
: "approximate";
return {
lat,
lng,
display_name: display || address,
source: "photon",
precision,
};
} catch (_e) {
return null;
}
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
try {
const body = await req.json().catch(() => ({}));
const address: string | undefined = body?.address;
const context = typeof body?.context === "string" ? body.context.trim() : "";
const forceRefresh: boolean = body?.refresh === true;
if (!address || typeof address !== "string" || address.trim().length < 3) {
return new Response(JSON.stringify({ error: "Missing address" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const cleaned = address.trim();
const effectiveQuery = context ? `${cleaned}, ${context}` : cleaned;
const key = normalize(effectiveQuery);
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
if (!forceRefresh) {
const { data: cached } = await supabase
.from("address_geocodes")
.select("lat,lng,display_name,source,not_found")
.eq("address_key", key)
.maybeSingle();
if (cached) {
// Skip cache entries from older non-Mapbox sources so they re-resolve
// through the more precise Mapbox provider.
const sourceStr = String(cached.source || "");
const isMapboxCache = sourceStr.startsWith("mapbox");
if (isMapboxCache || cached.not_found) {
if (cached.not_found || cached.lat == null) {
return new Response(JSON.stringify({ result: null, cached: true }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(
JSON.stringify({
result: {
lat: cached.lat,
lng: cached.lng,
display_name: cached.display_name,
source: cached.source,
},
cached: true,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
}
}
// Provider chain — Mapbox first (most precise), then free fallbacks.
const mapbox = await viaMapbox(cleaned, { context });
let result: GeoResult | null = mapbox;
if (!result || result.precision === "approximate") {
const census = await viaCensus(effectiveQuery);
if (census && (!result || census.precision === "interpolated")) result = census;
}
if (!result || result.precision === "approximate") {
const nom = await viaNominatim(effectiveQuery);
if (nom && (nom.precision === "rooftop" || !result)) result = nom;
}
if (!result || (result.precision !== "rooftop" && result.precision !== "interpolated")) {
const photon = await viaPhoton(effectiveQuery);
if (photon && (photon.precision === "rooftop" || !result)) result = photon;
}
await supabase
.from("address_geocodes")
.upsert(
{
address_key: key,
address: cleaned,
lat: result?.lat ?? null,
lng: result?.lng ?? null,
display_name: result?.display_name ?? null,
source: result?.source
? `${result.source}:${result.precision}`
: null,
not_found: !result,
},
{ onConflict: "address_key" },
);
return new Response(JSON.stringify({ result, cached: false }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: msg }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});