import type { Association, AssociationDirectoryEntry, InsertAssociationDirectoryEntry } from "../drizzle/schema"; import { ENV } from "./_core/env"; export type ReferenceProposalChange = { field: string; label: string; currentValue: string; proposedValue: string; }; export type ReferenceProposalResult = { hasChanges: boolean; updates: Partial; changes: ReferenceProposalChange[]; sourceLabel: string | null; summary: string; }; type DjepvaAssociationResponse = { data?: { association?: { rna?: string | null; siret_siege?: string | null; active?: boolean | null; date_creation?: string | null; objet?: string | null; adresse_siege?: { code_postal?: string | null; commune?: string | null; numero_voie?: string | null; type_voie?: string | null; libelle_voie?: string | null; } | null; forme_juridique?: { libelle?: string | null; } | null; reconnue_utilite_publique?: boolean | null; } | null; meta?: { date_derniere_mise_a_jour_sirene?: string | null; date_derniere_mise_a_jour_rna?: string | null; } | null; } | null; }; type SearchApiResult = { siren?: string | null; nom_complet?: string | null; nom_raison_sociale?: string | null; date_creation?: string | null; date_mise_a_jour?: string | null; date_mise_a_jour_insee?: string | null; etat_administratif?: string | null; nature_juridique?: string | null; siege?: { siret?: string | null; adresse?: string | null; code_postal?: string | null; libelle_commune?: string | null; } | null; complements?: { identifiant_association?: string | null; est_association?: boolean | null; } | null; }; type SearchApiResponse = { results?: SearchApiResult[]; }; 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 normalizeSiret(value: string | null | undefined) { const digits = cleanDigits(value); return digits.length === 14 ? digits : null; } function normalizeSiren(value: string | null | undefined) { const digits = cleanDigits(value); return digits.length === 9 ? digits : null; } function parseDate(value: string | null | undefined) { if (!value) return null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function maxDate(...values: Array) { const dates = values.filter((value): value is Date => Boolean(value)); if (dates.length === 0) return null; return new Date(Math.max(...dates.map((date) => date.getTime()))); } function buildAddressLabel(parts: Array) { const cleaned = parts.map((part) => (part || "").trim()).filter(Boolean); return cleaned.length > 0 ? cleaned.join(" ") : null; } function mapAssociationStatus(active: boolean | null | undefined, etatAdministratif?: string | null) { if (typeof active === "boolean") { return active ? "active" : "inactive"; } if (etatAdministratif === "A") return "active"; if (etatAdministratif === "C" || etatAdministratif === "F") return "inactive"; return null; } function mapStatutJuridique(libelle?: string | null, reconnueUtilitePublique?: boolean | null) { const normalized = normalizeText(libelle); if (reconnueUtilitePublique) { return "association_reconnue_utilite_publique" as const; } if (normalized.includes("fondation")) { return "fondation" as const; } if (normalized.includes("association")) { return "association_loi_1901" as const; } return null; } async function fetchJson(url: URL | string, init?: RequestInit) { const response = await fetch(url, init); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json() as T; } async function fetchDjepvaAssociation(identifier: string) { if (!ENV.entrepriseApiToken) { return null; } const url = `https://entreprise.api.gouv.fr/v4/djepva/api-association/associations/${encodeURIComponent(identifier)}`; try { return await fetchJson(url, { headers: { Accept: "application/json", Authorization: `Bearer ${ENV.entrepriseApiToken}`, "User-Agent": "portail-associations/1.0", }, }); } catch { return null; } } function scoreSearchResult(result: SearchApiResult, targetName: string, targetCity?: string | null) { let score = 0; const resultName = normalizeText(result.nom_raison_sociale || result.nom_complet); const normalizedTargetCity = normalizeText(targetCity); const resultCity = normalizeText(result.siege?.libelle_commune); if (result.complements?.est_association) score += 10; if (resultName === targetName) score += 100; else if (resultName.includes(targetName) || targetName.includes(resultName)) score += 40; if (normalizedTargetCity && resultCity && normalizedTargetCity === resultCity) score += 20; if (result.siege?.siret) score += 5; return score; } async function searchAssociationInSirene(entry: AssociationDirectoryEntry, association?: Association | null) { const knownSiret = normalizeSiret(association?.siret || entry.siret); const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret); const query = knownSiret || knownSiren || association?.nomAssociation || entry.nomAssociation; if (!query) return null; const url = new URL("https://recherche-entreprises.api.gouv.fr/search"); url.searchParams.set("q", query); url.searchParams.set("page", "1"); url.searchParams.set("per_page", "10"); url.searchParams.set("est_association", "true"); if (!knownSiret && !knownSiren) { const codePostal = association?.codePostal || entry.codePostal; if (codePostal) { url.searchParams.set("code_postal", codePostal); } } try { const payload = await fetchJson(url, { headers: { Accept: "application/json", "User-Agent": "portail-associations/1.0", }, }); const results = payload.results || []; if (results.length === 0) return null; if (knownSiret) { return results.find((result) => normalizeSiret(result.siege?.siret) === knownSiret) || results[0]; } if (knownSiren) { return results.find((result) => normalizeSiren(result.siren) === knownSiren) || results[0]; } const targetName = normalizeText(association?.nomAssociation || entry.nomAssociation); const targetCity = association?.ville || entry.ville; return [...results].sort((a, b) => scoreSearchResult(b, targetName, targetCity) - scoreSearchResult(a, targetName, targetCity))[0]; } catch { return null; } } export async function computeAssociationDirectoryReferenceUpdate( entry: AssociationDirectoryEntry, association?: Association | null ) { const knownRna = normalizeRna(association?.rna || entry.rna); const knownSiret = normalizeSiret(association?.siret || entry.siret); const knownSiren = normalizeSiren(knownSiret ? knownSiret.slice(0, 9) : association?.siret || entry.siret); const djepvaIdentifier = knownRna || knownSiren; const [djepva, searchResult] = await Promise.all([ djepvaIdentifier ? fetchDjepvaAssociation(djepvaIdentifier) : Promise.resolve(null), searchAssociationInSirene(entry, association), ]); const djepvaAssociation = djepva?.data?.association || null; const djepvaMeta = djepva?.data?.meta || null; const resolvedRna = normalizeRna(djepvaAssociation?.rna) || normalizeRna(searchResult?.complements?.identifiant_association) || knownRna; const resolvedSiret = normalizeSiret(djepvaAssociation?.siret_siege) || normalizeSiret(searchResult?.siege?.siret) || knownSiret; const resolvedStatus = mapAssociationStatus(djepvaAssociation?.active, searchResult?.etat_administratif) || entry.associationStatus || null; const registryLastUpdatedAt = maxDate( parseDate(djepvaMeta?.date_derniere_mise_a_jour_rna), parseDate(djepvaMeta?.date_derniere_mise_a_jour_sirene), parseDate(searchResult?.date_mise_a_jour_insee), parseDate(searchResult?.date_mise_a_jour) ); const inferredDateCreation = parseDate(djepvaAssociation?.date_creation) || parseDate(searchResult?.date_creation) || entry.dateCreation || null; const inferredAddress = buildAddressLabel([ djepvaAssociation?.adresse_siege?.numero_voie, djepvaAssociation?.adresse_siege?.type_voie, djepvaAssociation?.adresse_siege?.libelle_voie, ]) || searchResult?.siege?.adresse || entry.adresse || null; const inferredCodePostal = djepvaAssociation?.adresse_siege?.code_postal || searchResult?.siege?.code_postal || entry.codePostal || null; const inferredVille = djepvaAssociation?.adresse_siege?.commune || searchResult?.siege?.libelle_commune || entry.ville || null; const resolvedStatutJuridique = mapStatutJuridique(djepvaAssociation?.forme_juridique?.libelle, djepvaAssociation?.reconnue_utilite_publique) || entry.statutJuridique || null; const sourceLabels = [ djepvaAssociation ? "API RNA (DJEPVA)" : null, searchResult ? "API SIRENE / Recherche d’entreprises" : null, ].filter(Boolean); const hasAnyReference = Boolean(resolvedRna || resolvedSiret || resolvedStatus || registryLastUpdatedAt); const updates: Partial = { rna: resolvedRna, siret: resolvedSiret, associationStatus: resolvedStatus, registryLastUpdatedAt, referenceLastCheckedAt: new Date(), referenceStatus: hasAnyReference ? "reference_data_synced" : "reference_data_unresolved", referenceSourceLabel: sourceLabels.length > 0 ? sourceLabels.join(" + ") : null, dateCreation: inferredDateCreation, adresse: inferredAddress, codePostal: inferredCodePostal, ville: inferredVille, }; if (resolvedStatutJuridique) { updates.statutJuridique = resolvedStatutJuridique; } if (djepvaAssociation?.objet && !entry.objetAssociation) { updates.objetAssociation = djepvaAssociation.objet; } return updates; } const proposalFieldLabels: Record = { rna: "RNA", siret: "SIRET", associationStatus: "Statut", dateCreation: "Date de création", adresse: "Adresse", codePostal: "Code postal", ville: "Ville", statutJuridique: "Statut juridique", objetAssociation: "Objet de l'association", }; function normalizeComparableDate(value: unknown) { if (!value) return ""; const date = value instanceof Date ? value : new Date(String(value)); if (Number.isNaN(date.getTime())) return ""; return date.toISOString().slice(0, 10); } function normalizeComparableValue(field: string, value: unknown) { if (value == null) return ""; if (field.toLowerCase().includes("date")) { return normalizeComparableDate(value); } if (typeof value === "string") { return value.trim(); } return String(value); } function formatProposalValue(field: string, value: unknown) { if (value == null || value === "") return "Non renseigné"; if (field.toLowerCase().includes("date")) { const normalized = normalizeComparableDate(value); return normalized || "Non renseigné"; } return String(value); } export async function computeAssociationDirectoryReferenceProposal( entry: AssociationDirectoryEntry, association?: Association | null ): Promise { const updates = await computeAssociationDirectoryReferenceUpdate(entry, association); const proposalFields = Object.keys(proposalFieldLabels); const changes: ReferenceProposalChange[] = proposalFields .filter((field) => field in updates) .map((field) => { const currentValue = normalizeComparableValue(field, (entry as Record)[field]); const proposedValue = normalizeComparableValue(field, (updates as Record)[field]); if (!proposedValue || currentValue === proposedValue) { return null; } return { field, label: proposalFieldLabels[field], currentValue: formatProposalValue(field, (entry as Record)[field]), proposedValue: formatProposalValue(field, (updates as Record)[field]), }; }) .filter(Boolean) as ReferenceProposalChange[]; const sourceLabel = updates.referenceSourceLabel || null; const summary = changes.length ? `${changes.length} champ(s) à revoir : ${changes.slice(0, 4).map((change) => change.label).join(", ")}${changes.length > 4 ? "…" : ""}` : sourceLabel ? `Aucune différence utile détectée malgré une lecture via ${sourceLabel}.` : "Aucune donnée de référence exploitable n'a été trouvée."; return { hasChanges: changes.length > 0, updates, changes, sourceLabel, summary, }; }