180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
|
|
import type { AssociationGeoPrecision, AssociationGeoSource } from "@shared/associationGeo";
|
|
|
|
type CoordinateSet = {
|
|
latitude: string | null;
|
|
longitude: string | null;
|
|
geoSource: AssociationGeoSource | null;
|
|
externalSourceStatus: string;
|
|
externalSourceLabel: string | null;
|
|
};
|
|
|
|
function formatCoordinate(value: number | null) {
|
|
if (value === null || Number.isNaN(value)) return null;
|
|
return value.toFixed(6);
|
|
}
|
|
|
|
function buildAddressQuery(entry: AssociationDirectoryEntry, association?: Association | null) {
|
|
const adresse = association?.adresse || entry.adresse;
|
|
const codePostal = association?.codePostal || entry.codePostal;
|
|
const ville = association?.ville || entry.ville;
|
|
return [adresse, codePostal, ville].filter(Boolean).join(" ").trim();
|
|
}
|
|
|
|
async function geocodeAddress(query: string) {
|
|
const url = new URL("https://api-adresse.data.gouv.fr/search/");
|
|
url.searchParams.set("q", query);
|
|
url.searchParams.set("limit", "1");
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"User-Agent": "portail-associations/1.0",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Adresse API returned ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json() as {
|
|
features?: Array<{
|
|
geometry?: { coordinates?: [number, number] };
|
|
properties?: { label?: string };
|
|
}>;
|
|
};
|
|
|
|
const first = payload.features?.[0];
|
|
const coordinates = first?.geometry?.coordinates;
|
|
if (!coordinates || coordinates.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
latitude: coordinates[1],
|
|
longitude: coordinates[0],
|
|
label: first?.properties?.label || null,
|
|
};
|
|
}
|
|
|
|
async function fetchCommuneCenter(ville: string, codePostal?: string | null) {
|
|
const url = new URL("https://geo.api.gouv.fr/communes");
|
|
url.searchParams.set("nom", ville);
|
|
url.searchParams.set("fields", "nom,centre,code,codesPostaux");
|
|
url.searchParams.set("format", "json");
|
|
url.searchParams.set("geometry", "centre");
|
|
if (codePostal) {
|
|
url.searchParams.set("codePostal", codePostal);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"User-Agent": "portail-associations/1.0",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Geo API returned ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json() as Array<{
|
|
nom?: string;
|
|
centre?: { coordinates?: [number, number] };
|
|
}>;
|
|
|
|
const first = payload[0];
|
|
const coordinates = first?.centre?.coordinates;
|
|
if (!coordinates || coordinates.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
latitude: coordinates[1],
|
|
longitude: coordinates[0],
|
|
label: first?.nom || ville,
|
|
};
|
|
}
|
|
|
|
export async function computeAssociationDirectoryGeoUpdate(entry: AssociationDirectoryEntry, association?: Association | null) {
|
|
return computeAssociationDirectoryGeoUpdateForPrecision(
|
|
entry,
|
|
association,
|
|
(entry.geoPrecision as AssociationGeoPrecision | null) || "commune_center"
|
|
);
|
|
}
|
|
|
|
export async function computeAssociationDirectoryGeoUpdateForPrecision(
|
|
entry: AssociationDirectoryEntry,
|
|
association: Association | null | undefined,
|
|
preferredPrecision: AssociationGeoPrecision
|
|
) {
|
|
const updates: Partial<InsertAssociationDirectoryEntry> = {
|
|
geoLastSyncedAt: new Date(),
|
|
geoPrecision: preferredPrecision,
|
|
};
|
|
|
|
const addressQuery = buildAddressQuery(entry, association);
|
|
if (preferredPrecision === "exact_address" && addressQuery) {
|
|
try {
|
|
const geocoded = await geocodeAddress(addressQuery);
|
|
if (geocoded) {
|
|
updates.latitude = formatCoordinate(geocoded.latitude);
|
|
updates.longitude = formatCoordinate(geocoded.longitude);
|
|
updates.geoSource = "adresse_gouv";
|
|
updates.externalSourceStatus = "geocoded_from_address";
|
|
updates.externalSourceLabel = geocoded.label || "Adresse.data.gouv.fr";
|
|
return updates;
|
|
}
|
|
} catch {
|
|
// Fall back to commune center below.
|
|
}
|
|
}
|
|
|
|
const ville = association?.ville || entry.ville;
|
|
const codePostal = association?.codePostal || entry.codePostal;
|
|
if (ville) {
|
|
try {
|
|
const communeCenter = await fetchCommuneCenter(ville, codePostal);
|
|
if (communeCenter) {
|
|
updates.latitude = formatCoordinate(communeCenter.latitude);
|
|
updates.longitude = formatCoordinate(communeCenter.longitude);
|
|
updates.geoSource = "commune_center";
|
|
updates.externalSourceStatus = "commune_center_fallback";
|
|
updates.externalSourceLabel = communeCenter.label || ville;
|
|
return updates;
|
|
}
|
|
} catch {
|
|
// Fall through to unavailable state.
|
|
}
|
|
}
|
|
|
|
updates.latitude = null;
|
|
updates.longitude = null;
|
|
updates.geoSource = null;
|
|
updates.externalSourceStatus = "unresolved";
|
|
updates.externalSourceLabel = null;
|
|
return updates;
|
|
}
|
|
|
|
export function getPublicMapCoordinates(entry: AssociationDirectoryEntry) {
|
|
if (!entry.latitude || !entry.longitude) {
|
|
return null;
|
|
}
|
|
|
|
const latitude = Number(entry.latitude);
|
|
const longitude = Number(entry.longitude);
|
|
if (Number.isNaN(latitude) || Number.isNaN(longitude)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
latitude,
|
|
longitude,
|
|
precision: (
|
|
entry.geoPrecision === "hidden"
|
|
? (entry.geoSource === "commune_center" ? "commune_center" : "exact_address")
|
|
: entry.geoPrecision
|
|
) as AssociationGeoPrecision,
|
|
};
|
|
}
|