mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
9.5 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|