Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
180
server/associationGeo.ts
Normal file
180
server/associationGeo.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue