portail-associations/exports/annuaires-integration-20260605/server/associationGeo.ts

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,
};
}