Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

View file

@ -0,0 +1,419 @@
import { createHash } from "node:crypto";
import * as XLSX from "xlsx";
import type { AssociationDirectoryEntry, InsertAssociation } from "../drizzle/schema";
import { TRPCError } from "@trpc/server";
import { normalizeLegalRepresentativeRole, serializeAssociationGovernance } from "@shared/associationGovernance";
type ParsedDirectoryRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
emailOfficielNormalise: string | null;
siret: string | null;
rna: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
siteWeb: string | null;
facebookUrl: string | null;
instagramUrl: string | null;
dateCreation: Date | null;
objetAssociation: string | null;
statutJuridique: "association_loi_1901" | "association_reconnue_utilite_publique" | "fondation" | "autre";
nomRepresentant: string | null;
fonctionRepresentant: string | null;
sourceFingerprint: string;
};
type PreviewRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
ville: string | null;
status: "valid" | "missing_email" | "duplicate_email";
message: string;
};
export type DuplicateEmailGroup = {
emailOfficielNormalise: string;
emailOfficiel: string;
rowNumbers: number[];
options: Array<{
sheetName: string;
rowNumber: number;
nomAssociation: string;
ville: string | null;
}>;
};
export type DirectoryPreviewResult = {
fileName: string;
totalRows: number;
validRows: number;
missingEmailRows: number;
duplicateEmailRows: number;
previewRows: PreviewRow[];
duplicateGroups: DuplicateEmailGroup[];
allRows: ParsedDirectoryRow[];
importableRows: ParsedDirectoryRow[];
};
const headerAliases: Record<string, string[]> = {
nomAssociation: ["nom association", "association", "nom", "raison sociale", "nom de la structure"],
emailOfficiel: ["email officiel", "email", "mail", "courriel", "adresse email"],
siret: ["siret", "numéro siret", "numero siret"],
rna: ["rna", "numéro rna", "numero rna"],
adresse: ["adresse", "adresse siège", "adresse siege", "adresse du siège"],
codePostal: ["code postal", "cp"],
ville: ["ville", "commune"],
telephone: ["telephone", "téléphone", "tel", "tél", "tel.", "port.", "port"],
siteWeb: ["site web", "site", "website", "url site"],
facebookUrl: ["facebook", "facebook url", "facebook link", "lien facebook", "url facebook"],
instagramUrl: ["instagram", "instagram url", "instagram link", "lien instagram", "url instagram"],
dateCreation: ["date création", "date creation", "creation", "date de création"],
objetAssociation: ["objet", "objet association", "activité", "activités", "activite"],
statutJuridique: ["statut", "statut juridique"],
nomRepresentant: ["nom représentant", "nom representant", "président", "president", "responsable", "president"],
fonctionRepresentant: ["fonction représentant", "fonction representant", "fonction", "qualité", "qualite", "secretaire", "secrétaire"],
};
function normalizeHeader(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase();
}
function cleanString(value: unknown) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? text : null;
}
function extractFirstEmail(value: unknown) {
const text = cleanString(value);
if (!text) return null;
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return match?.[0] ?? null;
}
function normalizeEmail(value: string | null) {
return value ? value.trim().toLowerCase() : null;
}
function normalizeSiret(value: string | null) {
return value ? value.replace(/\D/g, "") || null : null;
}
function normalizeRna(value: string | null) {
return value ? value.replace(/\s+/g, "").toUpperCase() : null;
}
function normalizeSiteWeb(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
function normalizeTelephone(value: string | null) {
if (!value) return null;
const first = value
.split(/[\/;,]/)
.map((part) => part.trim())
.find(Boolean);
return first ? first.slice(0, 20) : null;
}
function normalizeDate(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === "number") {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function normalizeStatut(value: string | null): ParsedDirectoryRow["statutJuridique"] {
if (!value) return "association_loi_1901";
const normalized = normalizeHeader(value);
if (normalized.includes("utilite publique")) return "association_reconnue_utilite_publique";
if (normalized.includes("fondation")) return "fondation";
if (normalized.includes("1901") || normalized.includes("association")) return "association_loi_1901";
return "autre";
}
function computeFingerprint(row: Omit<ParsedDirectoryRow, "sourceFingerprint">) {
return createHash("sha256").update(JSON.stringify(row)).digest("hex");
}
function getCommuneFromSheetName(sheetName: string) {
const normalized = normalizeHeader(sheetName).replace(/[-_]/g, " ");
if (normalized.includes("kourou")) return "Kourou";
if (normalized.includes("sinnamary")) return "Sinnamary";
if (normalized.includes("iracoubo")) return "Iracoubo";
if (normalized.includes("st elie") || normalized.includes("saint elie")) return "Saint-Élie";
return null;
}
function resolveColumnIndex(headers: string[], field: keyof typeof headerAliases) {
const aliases = headerAliases[field];
return headers.findIndex(header => aliases.includes(normalizeHeader(header)));
}
function readCell(row: unknown[], headers: string[], field: keyof typeof headerAliases) {
const index = resolveColumnIndex(headers, field);
if (index === -1) return null;
return row[index];
}
function resolveEmailValue(row: unknown[], headers: string[]) {
const direct = extractFirstEmail(readCell(row, headers, "emailOfficiel"));
if (direct) return direct;
for (const cell of row) {
const extracted = extractFirstEmail(cell);
if (extracted) return extracted;
}
return null;
}
export function parseAssociationDirectoryWorkbook(fileBuffer: Buffer, fileName: string): DirectoryPreviewResult {
const workbook = XLSX.read(fileBuffer, { type: "buffer", cellDates: true });
if (workbook.SheetNames.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier Excel ne contient aucune feuille exploitable" });
}
const importableRows: ParsedDirectoryRow[] = [];
const previewRows: PreviewRow[] = [];
const emailRowMap = new Map<string, number[]>();
let hasAnyUsableSheet = false;
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: null });
if (rows.length < 2) return;
const headers = (rows[0] || []).map(value => String(value ?? ""));
if (resolveColumnIndex(headers, "nomAssociation") === -1) return;
hasAnyUsableSheet = true;
const communeFromSheet = getCommuneFromSheetName(sheetName);
rows.slice(1).forEach((rawRow, index) => {
const rowNumber = index + 2;
const nomAssociation = cleanString(readCell(rawRow, headers, "nomAssociation"));
if (!nomAssociation) {
return;
}
const emailOfficiel = resolveEmailValue(rawRow, headers);
const emailOfficielNormalise = normalizeEmail(emailOfficiel);
const rawAdresse = cleanString(readCell(rawRow, headers, "adresse"));
const parsedRowBase = {
sheetName,
rowNumber,
nomAssociation,
emailOfficiel,
emailOfficielNormalise,
siret: normalizeSiret(cleanString(readCell(rawRow, headers, "siret"))),
rna: normalizeRna(cleanString(readCell(rawRow, headers, "rna"))),
adresse: rawAdresse && extractFirstEmail(rawAdresse) ? null : rawAdresse,
codePostal: cleanString(readCell(rawRow, headers, "codePostal")),
ville: communeFromSheet || cleanString(readCell(rawRow, headers, "ville")),
telephone: normalizeTelephone(cleanString(readCell(rawRow, headers, "telephone"))),
siteWeb: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "siteWeb"))),
facebookUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "facebookUrl"))),
instagramUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "instagramUrl"))),
dateCreation: normalizeDate(readCell(rawRow, headers, "dateCreation")),
objetAssociation: cleanString(readCell(rawRow, headers, "objetAssociation")),
statutJuridique: normalizeStatut(cleanString(readCell(rawRow, headers, "statutJuridique"))),
nomRepresentant: cleanString(readCell(rawRow, headers, "nomRepresentant")),
fonctionRepresentant: cleanString(readCell(rawRow, headers, "fonctionRepresentant")),
};
const parsedRow: ParsedDirectoryRow = {
...parsedRowBase,
sourceFingerprint: computeFingerprint(parsedRowBase),
};
importableRows.push(parsedRow);
if (emailOfficielNormalise) {
const refs = emailRowMap.get(emailOfficielNormalise) || [];
refs.push(rowNumber);
emailRowMap.set(emailOfficielNormalise, refs);
}
});
});
if (!hasAnyUsableSheet || importableRows.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le fichier Excel ne contient pas de feuille exploitable avec une colonne de nom d'association",
});
}
importableRows.forEach(row => {
const duplicateRows = row.emailOfficielNormalise ? emailRowMap.get(row.emailOfficielNormalise) || [] : [];
if (!row.emailOfficielNormalise) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "missing_email",
message: `Feuille ${row.sheetName} : email officiel manquant, la ligne ne pourra pas être rattachée automatiquement`,
});
return;
}
if (duplicateRows.length > 1) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "duplicate_email",
message: `Feuille ${row.sheetName} : email dupliqué dans le fichier (lignes ${duplicateRows.join(", ")})`,
});
return;
}
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "valid",
message: `Feuille ${row.sheetName} : ligne prête à être importée`,
});
});
const importableRowKeys = new Set(
previewRows
.filter(row => row.status !== "duplicate_email")
.map(row => `${row.rowNumber}::${row.nomAssociation}`)
);
const duplicateGroups: DuplicateEmailGroup[] = Array.from(emailRowMap.entries())
.filter(([, rowNumbers]) => rowNumbers.length > 1)
.map(([emailOfficielNormalise, rowNumbers]) => {
const options = importableRows
.filter(row => row.emailOfficielNormalise === emailOfficielNormalise)
.map(row => ({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
ville: row.ville,
}));
return {
emailOfficielNormalise,
emailOfficiel: options.length > 0 ? importableRows.find(row => row.emailOfficielNormalise === emailOfficielNormalise)?.emailOfficiel || emailOfficielNormalise : emailOfficielNormalise,
rowNumbers,
options,
};
});
return {
fileName,
totalRows: importableRows.length,
validRows: previewRows.filter(row => row.status === "valid").length,
missingEmailRows: previewRows.filter(row => row.status === "missing_email").length,
duplicateEmailRows: previewRows.filter(row => row.status === "duplicate_email").length,
previewRows,
duplicateGroups,
allRows: importableRows,
importableRows: importableRows.filter(row => importableRowKeys.has(`${row.rowNumber}::${row.nomAssociation}`)),
};
}
export function resolveAssociationDirectoryImportRows(
preview: DirectoryPreviewResult,
duplicateSelections?: Record<string, number>,
) {
const selectedDuplicateKeys = new Set<string>();
Object.entries(duplicateSelections || {}).forEach(([email, rowNumber]) => {
const numericRow = Number(rowNumber);
if (Number.isFinite(numericRow) && numericRow > 0) {
selectedDuplicateKeys.add(`${email}::${numericRow}`);
}
});
return preview.allRows.filter((row) => {
if (!row.emailOfficielNormalise) {
return true;
}
const isDuplicate = preview.duplicateGroups.some(group => group.emailOfficielNormalise === row.emailOfficielNormalise);
if (!isDuplicate) {
return true;
}
return selectedDuplicateKeys.has(`${row.emailOfficielNormalise}::${row.rowNumber}`);
});
}
export function createAssociationProfileFromDirectoryEntry(userId: number, entry: AssociationDirectoryEntry): InsertAssociation {
const governance = entry.nomRepresentant || entry.fonctionRepresentant
? serializeAssociationGovernance({
representantLegal: {
nom: entry.nomRepresentant || "",
prenom: "",
email: "",
telephone: "",
fonction: normalizeLegalRepresentativeRole(entry.fonctionRepresentant),
},
membres: [],
})
: null;
return {
userId,
sourceDirectoryEntryId: entry.id,
nomAssociation: entry.nomAssociation,
siret: entry.siret ?? null,
rna: entry.rna ?? null,
thematique: entry.thematique ?? null,
adresse: entry.adresse ?? null,
codePostal: entry.codePostal ?? null,
ville: entry.ville ?? null,
telephone: entry.telephone ?? null,
emailContact: entry.emailOfficiel ?? null,
siteWeb: entry.siteWeb ?? null,
facebookUrl: entry.facebookUrl ?? null,
instagramUrl: entry.instagramUrl ?? null,
dateCreation: entry.dateCreation ?? null,
objetAssociation: entry.objetAssociation ?? null,
statutJuridique: entry.statutJuridique ?? "association_loi_1901",
nomRepresentant: entry.nomRepresentant ?? null,
fonctionRepresentant: entry.fonctionRepresentant ?? null,
gouvernance: governance,
profileComplete: Boolean(entry.nomAssociation && entry.adresse && entry.ville),
isActive: true,
};
}

View file

@ -0,0 +1,183 @@
import type { AssociationDirectoryEntry } from "../drizzle/schema";
export type AssociationDirectoryMatchInput = {
nomAssociation?: string | null;
email?: string | null;
siret?: string | null;
rna?: string | null;
ville?: string | null;
};
export type AssociationDirectoryMatchCandidate = Pick<
AssociationDirectoryEntry,
"id" | "nomAssociation" | "emailOfficiel" | "siret" | "rna" | "ville"
>;
export type AssociationDirectoryMatchResult = {
status: "matched" | "ambiguous" | "none";
matchedEntry?: AssociationDirectoryMatchCandidate;
candidates: AssociationDirectoryMatchCandidate[];
reason: string;
};
function normalizeText(value?: string | null) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
export function normalizeDirectoryEmail(value?: string | null) {
const trimmed = String(value || "").trim().toLowerCase();
return trimmed || null;
}
export function normalizeDirectorySiret(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "");
return digits || null;
}
export function normalizeDirectoryRna(value?: string | null) {
const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase();
return normalized || null;
}
function candidateScore(candidate: AssociationDirectoryMatchCandidate, input: AssociationDirectoryMatchInput) {
const localName = normalizeText(input.nomAssociation);
const localCity = normalizeText(input.ville);
const localEmail = normalizeDirectoryEmail(input.email);
const localSiret = normalizeDirectorySiret(input.siret);
const localRna = normalizeDirectoryRna(input.rna);
const candidateName = normalizeText(candidate.nomAssociation);
const candidateCity = normalizeText(candidate.ville);
const candidateEmail = normalizeDirectoryEmail(candidate.emailOfficiel);
const candidateSiret = normalizeDirectorySiret(candidate.siret);
const candidateRna = normalizeDirectoryRna(candidate.rna);
let score = 0;
const exactSiret = Boolean(localSiret && candidateSiret && localSiret === candidateSiret);
const exactRna = Boolean(localRna && candidateRna && localRna === candidateRna);
const exactEmail = Boolean(localEmail && candidateEmail && localEmail === candidateEmail);
const exactName = Boolean(localName && candidateName && localName === candidateName);
const exactCity = Boolean(localCity && candidateCity && localCity === candidateCity);
if (exactSiret) score += 300;
if (exactRna) score += 260;
if (exactEmail) score += 220;
if (exactName) score += 120;
if (exactCity) score += 25;
if (!exactName && localName && candidateName) {
if (candidateName.includes(localName) || localName.includes(candidateName)) {
score += 40;
}
}
return { score, exactSiret, exactRna, exactEmail, exactName, exactCity };
}
export function matchAssociationDirectoryEntry(
entries: AssociationDirectoryMatchCandidate[],
input: AssociationDirectoryMatchInput
): AssociationDirectoryMatchResult {
const normalizedName = normalizeText(input.nomAssociation);
const normalizedEmail = normalizeDirectoryEmail(input.email);
const normalizedSiret = normalizeDirectorySiret(input.siret);
const normalizedRna = normalizeDirectoryRna(input.rna);
const normalizedCity = normalizeText(input.ville);
if (!normalizedName && !normalizedEmail && !normalizedSiret && !normalizedRna) {
return {
status: "none",
candidates: [],
reason: "Aucun identifiant exploitable n'a été fourni pour rechercher une fiche du bordereau.",
};
}
const exactSiret = entries.filter((entry) => normalizeDirectorySiret(entry.siret) === normalizedSiret && normalizedSiret);
if (exactSiret.length === 1) {
return { status: "matched", matchedEntry: exactSiret[0], candidates: exactSiret, reason: "Correspondance validée par le SIRET." };
}
if (exactSiret.length > 1) {
return { status: "ambiguous", candidates: exactSiret, reason: "Plusieurs fiches du bordereau portent le même SIRET." };
}
const exactRna = entries.filter((entry) => normalizeDirectoryRna(entry.rna) === normalizedRna && normalizedRna);
if (exactRna.length === 1) {
return { status: "matched", matchedEntry: exactRna[0], candidates: exactRna, reason: "Correspondance validée par le RNA." };
}
if (exactRna.length > 1) {
return { status: "ambiguous", candidates: exactRna, reason: "Plusieurs fiches du bordereau portent le même RNA." };
}
const exactEmail = entries.filter((entry) => normalizeDirectoryEmail(entry.emailOfficiel) === normalizedEmail && normalizedEmail);
if (exactEmail.length === 1) {
return { status: "matched", matchedEntry: exactEmail[0], candidates: exactEmail, reason: "Correspondance validée par l'email officiel." };
}
if (exactEmail.length > 1) {
return { status: "ambiguous", candidates: exactEmail, reason: "Plusieurs fiches du bordereau utilisent le même email officiel." };
}
const scored = entries
.map((entry) => ({ entry, ...candidateScore(entry, input) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
const exactNameAndCity = scored.filter((entry) => entry.exactName && entry.exactCity);
if (exactNameAndCity.length === 1) {
return {
status: "matched",
matchedEntry: exactNameAndCity[0].entry,
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Correspondance validée par le nom et la commune.",
};
}
if (exactNameAndCity.length > 1) {
return {
status: "ambiguous",
candidates: exactNameAndCity.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau correspondent au même nom dans cette commune.",
};
}
const exactNameOnly = scored.filter((entry) => entry.exactName);
if (exactNameOnly.length === 1) {
return {
status: "matched",
matchedEntry: exactNameOnly[0].entry,
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Correspondance validée par le nom de l'association.",
};
}
if (exactNameOnly.length > 1) {
return {
status: "ambiguous",
candidates: exactNameOnly.map((entry) => entry.entry),
reason: "Plusieurs fiches du bordereau portent le même nom.",
};
}
const closeCandidates = scored
.filter((entry) => entry.score >= 40)
.map((entry) => entry.entry)
.slice(0, 5);
if (closeCandidates.length > 0) {
return {
status: "ambiguous",
candidates: closeCandidates,
reason: normalizedCity
? "Des rapprochements partiels ont été trouvés, mais aucun n'est assez fiable pour lier automatiquement cette association."
: "Des rapprochements potentiels ont été trouvés, mais une validation humaine reste nécessaire.",
};
}
return {
status: "none",
candidates: [],
reason: "Aucune fiche du bordereau ne correspond de façon fiable à cette association.",
};
}

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

View file

@ -0,0 +1,442 @@
import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema";
export const HELLOASSO_SETTINGS_KEY = "system.associationDirectory.helloasso";
export type HelloAssoSettings = {
enabled: boolean;
clientId: string;
clientSecret: string;
};
export type HelloAssoSettingsPublic = {
enabled: boolean;
clientId: string;
clientSecretConfigured: boolean;
};
type HelloAssoTokenResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
};
type HelloAssoDirectoryItem = {
action?: string | null;
record?: {
url?: string | null;
organizationSlug?: string | null;
} | null;
};
type HelloAssoDirectoryResponse = {
data?: HelloAssoDirectoryItem[] | null;
pagination?: {
continuationToken?: string | null;
} | null;
};
type HelloAssoOrganizationPublic = {
facebookPage?: string | null;
longDescription?: string | null;
webSite?: string | null;
address?: string | null;
rnaNumber?: string | null;
name?: string | null;
city?: string | null;
zipCode?: string | null;
description?: string | null;
updateDate?: string | null;
url?: string | null;
organizationSlug?: string | null;
};
type CandidateScore = {
slug: string;
detail: HelloAssoOrganizationPublic;
score: number;
exactRna: boolean;
exactName: boolean;
exactCity: boolean;
exactZipCode: boolean;
};
export type HelloAssoSyncResult = {
matched: boolean;
reason: string;
slug?: string;
candidateCount: number;
updates: Partial<InsertAssociationDirectoryEntry>;
};
export class HelloAssoSyncClient {
private accessTokenPromise: Promise<string> | null = null;
constructor(private readonly settings: HelloAssoSettings) {}
async getAccessToken() {
if (!this.accessTokenPromise) {
this.accessTokenPromise = fetchHelloAssoAccessToken(this.settings);
}
return this.accessTokenPromise;
}
}
export function sanitizeHelloAssoSettings(rawValue?: unknown): HelloAssoSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
return {
enabled: source.enabled !== false,
clientId: typeof source.clientId === "string" ? source.clientId.trim() : "",
clientSecret: typeof source.clientSecret === "string" ? source.clientSecret.trim() : "",
};
}
export function serializeHelloAssoSettingsPublic(settings: HelloAssoSettings): HelloAssoSettingsPublic {
return {
enabled: settings.enabled,
clientId: settings.clientId,
clientSecretConfigured: settings.clientSecret.length > 0,
};
}
function normalizeText(value: string | null | undefined) {
return (value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
}
function cleanDigits(value: string | null | undefined) {
return (value || "").replace(/\D/g, "");
}
function normalizeRna(value: string | null | undefined) {
const trimmed = (value || "").trim().toUpperCase();
return /^W\d{9}$/.test(trimmed) ? trimmed : null;
}
function normalizeZipCode(value: string | null | undefined) {
const digits = cleanDigits(value);
return digits.length >= 5 ? digits.slice(0, 5) : null;
}
function parseDate(value: string | null | undefined) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function normalizeOptionalUrl(value: string | null | undefined) {
const trimmed = String(value || "").trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function normalizeOptionalText(value: string | null | undefined) {
const trimmed = String(value || "").trim();
return trimmed || null;
}
function buildSearchBodies(entry: AssociationDirectoryEntry, association?: Association | null) {
const name = normalizeOptionalText(association?.nomAssociation || entry.nomAssociation);
const city = normalizeOptionalText(association?.ville || entry.ville);
const zipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const variants = [
{
name,
...(city ? { cities: [city] } : {}),
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
...(city ? { cities: [city] } : {}),
},
{
name,
...(zipCode ? { zipCodes: [zipCode] } : {}),
},
{
name,
},
];
const seen = new Set<string>();
return variants.filter((variant) => {
if (!variant.name) return false;
const key = JSON.stringify(variant);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function fetchHelloAssoJson<T>(path: string, token: string, init?: RequestInit) {
const response = await fetch(`https://api.helloasso.com/v5${path}`, {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(init?.headers || {}),
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`HelloAsso HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
}
return await response.json() as T;
}
async function fetchHelloAssoAccessToken(settings: HelloAssoSettings) {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: settings.clientId,
client_secret: settings.clientSecret,
});
const response = await fetch("https://api.helloasso.com/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const raw = await response.text().catch(() => "");
throw new Error(
response.status === 401 || response.status === 403
? "Identifiants HelloAsso invalides ou non autorisés"
: `Impossible d'obtenir un jeton HelloAsso (${response.status})${raw ? `: ${raw.slice(0, 160)}` : ""}`
);
}
const payload = await response.json() as HelloAssoTokenResponse;
if (!payload.access_token) {
throw new Error("HelloAsso n'a pas renvoyé de jeton d'accès exploitable");
}
return payload.access_token;
}
async function searchHelloAssoDirectory(
token: string,
entry: AssociationDirectoryEntry,
association?: Association | null
) {
const slugs = new Set<string>();
for (const body of buildSearchBodies(entry, association)) {
try {
const response = await fetchHelloAssoJson<HelloAssoDirectoryResponse>("/directory/organizations?pageSize=8", token, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
for (const item of response.data || []) {
const slug = item.record?.organizationSlug?.trim();
if (!slug) continue;
if ((item.action || "").toLowerCase() === "delete") continue;
slugs.add(slug);
}
if (slugs.size > 0) {
break;
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
if (message.includes("403")) {
throw new Error("Le client HelloAsso doit disposer du privilège OrganizationOpenDirectory pour interroger le répertoire.");
}
throw error;
}
}
return Array.from(slugs);
}
function scoreHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
detail: HelloAssoOrganizationPublic
): CandidateScore {
const localName = normalizeText(association?.nomAssociation || entry.nomAssociation);
const localCity = normalizeText(association?.ville || entry.ville);
const localZipCode = normalizeZipCode(association?.codePostal || entry.codePostal);
const localRna = normalizeRna(association?.rna || entry.rna);
const remoteName = normalizeText(detail.name);
const remoteCity = normalizeText(detail.city);
const remoteZipCode = normalizeZipCode(detail.zipCode);
const remoteRna = normalizeRna(detail.rnaNumber);
const exactRna = Boolean(localRna && remoteRna && localRna === remoteRna);
const exactName = Boolean(localName && remoteName && localName === remoteName);
const exactCity = Boolean(localCity && remoteCity && localCity === remoteCity);
const exactZipCode = Boolean(localZipCode && remoteZipCode && localZipCode === remoteZipCode);
let score = 0;
if (exactRna) score += 200;
if (exactName) score += 90;
else if (remoteName && (remoteName.includes(localName) || localName.includes(remoteName))) score += 35;
if (exactCity) score += 20;
if (exactZipCode) score += 20;
if (normalizeOptionalUrl(detail.webSite) && normalizeOptionalUrl(detail.webSite) === normalizeOptionalUrl(association?.siteWeb || entry.siteWeb)) {
score += 30;
}
return {
slug: detail.organizationSlug || "",
detail,
score,
exactRna,
exactName,
exactCity,
exactZipCode,
};
}
function pickHelloAssoCandidate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
details: HelloAssoOrganizationPublic[]
) {
const scored = details
.filter((detail) => Boolean(detail.organizationSlug))
.map((detail) => scoreHelloAssoCandidate(entry, association, detail))
.sort((a, b) => b.score - a.score);
if (scored.length === 0) return null;
const exactRna = scored.filter((candidate) => candidate.exactRna);
if (exactRna.length === 1) return exactRna[0];
const exactNameAndLocation = scored.filter((candidate) => candidate.exactName && (candidate.exactCity || candidate.exactZipCode));
if (exactNameAndLocation.length === 1) return exactNameAndLocation[0];
const exactNameOnly = scored.filter((candidate) => candidate.exactName);
if (exactNameOnly.length === 1) return exactNameOnly[0];
const [best, second] = scored;
if (best && best.score >= 120 && (!second || best.score - second.score >= 20)) {
return best;
}
return null;
}
function buildHelloAssoUpdates(detail: HelloAssoOrganizationPublic): Partial<InsertAssociationDirectoryEntry> {
const description = normalizeOptionalText(detail.longDescription) || normalizeOptionalText(detail.description);
const address = normalizeOptionalText(detail.address);
const city = normalizeOptionalText(detail.city);
const zipCode = normalizeZipCode(detail.zipCode);
const webSite = normalizeOptionalUrl(detail.webSite);
const facebookPage = normalizeOptionalUrl(detail.facebookPage);
const rna = normalizeRna(detail.rnaNumber);
const updateDate = parseDate(detail.updateDate);
const slug = normalizeOptionalText(detail.organizationSlug);
return {
...(rna ? { rna } : {}),
...(address ? { adresse: address } : {}),
...(city ? { ville: city } : {}),
...(zipCode ? { codePostal: zipCode } : {}),
...(webSite ? { siteWeb: webSite } : {}),
...(facebookPage ? { facebookUrl: facebookPage } : {}),
...(description ? { objetAssociation: description } : {}),
externalSourceStatus: "helloasso_synced",
externalSourceLabel: slug ? `HelloAsso · ${slug}` : "HelloAsso",
...(updateDate ? { registryLastUpdatedAt: updateDate } : {}),
};
}
export async function computeAssociationDirectoryHelloAssoUpdate(
entry: AssociationDirectoryEntry,
association: Association | null | undefined,
settings: HelloAssoSettings,
client?: HelloAssoSyncClient
): Promise<HelloAssoSyncResult> {
if (!settings.enabled) {
return {
matched: false,
reason: "La synchronisation HelloAsso est désactivée.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_disabled",
externalSourceLabel: "HelloAsso",
},
};
}
if (!settings.clientId || !settings.clientSecret) {
return {
matched: false,
reason: "Les identifiants HelloAsso ne sont pas configurés.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_not_configured",
externalSourceLabel: "HelloAsso",
},
};
}
const syncClient = client || new HelloAssoSyncClient(settings);
const token = await syncClient.getAccessToken();
const slugs = await searchHelloAssoDirectory(token, entry, association);
if (slugs.length === 0) {
return {
matched: false,
reason: "Aucun organisme HelloAsso compatible n'a été trouvé pour cette association.",
candidateCount: 0,
updates: {
externalSourceStatus: "helloasso_no_match",
externalSourceLabel: "HelloAsso",
},
};
}
const details = await Promise.all(
slugs.map(async (slug) => {
try {
return await fetchHelloAssoJson<HelloAssoOrganizationPublic>(`/organizations/${encodeURIComponent(slug)}`, token);
} catch {
return null;
}
})
);
const matched = pickHelloAssoCandidate(entry, association, details.filter(Boolean) as HelloAssoOrganizationPublic[]);
if (!matched) {
return {
matched: false,
reason: "Des résultats HelloAsso ont été trouvés, mais aucun rapprochement n'est assez fiable pour mettre à jour la fiche automatiquement.",
candidateCount: details.filter(Boolean).length,
updates: {
externalSourceStatus: "helloasso_ambiguous_match",
externalSourceLabel: "HelloAsso",
},
};
}
return {
matched: true,
reason: matched.exactRna
? "Correspondance HelloAsso validée par le RNA."
: matched.exactName && (matched.exactCity || matched.exactZipCode)
? "Correspondance HelloAsso validée par le nom et la localisation."
: "Correspondance HelloAsso validée par le nom de l'association.",
slug: matched.slug,
candidateCount: details.filter(Boolean).length,
updates: buildHelloAssoUpdates(matched.detail),
};
}