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