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,402 @@
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<InsertAssociationDirectoryEntry>;
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<Date | null>) {
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<string | null | undefined>) {
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<T>(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<DjepvaAssociationResponse>(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<SearchApiResponse>(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 dentreprises" : null,
].filter(Boolean);
const hasAnyReference = Boolean(resolvedRna || resolvedSiret || resolvedStatus || registryLastUpdatedAt);
const updates: Partial<InsertAssociationDirectoryEntry> = {
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<string, string> = {
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<ReferenceProposalResult> {
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<string, unknown>)[field]);
const proposedValue = normalizeComparableValue(field, (updates as Record<string, unknown>)[field]);
if (!proposedValue || currentValue === proposedValue) {
return null;
}
return {
field,
label: proposalFieldLabels[field],
currentValue: formatProposalValue(field, (entry as Record<string, unknown>)[field]),
proposedValue: formatProposalValue(field, (updates as Record<string, unknown>)[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,
};
}