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; }; export class HelloAssoSyncClient { private accessTokenPromise: Promise | 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 : {}; 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(); 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(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(); for (const body of buildSearchBodies(entry, association)) { try { const response = await fetchHelloAssoJson("/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 { 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 { 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(`/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), }; }