mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user