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,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),
};
}