Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue