// 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 { 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 { 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 { 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 { 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" }, }); } });