442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
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),
|
|
};
|
|
}
|