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,419 @@
import { createHash } from "node:crypto";
import * as XLSX from "xlsx";
import type { AssociationDirectoryEntry, InsertAssociation } from "../drizzle/schema";
import { TRPCError } from "@trpc/server";
import { normalizeLegalRepresentativeRole, serializeAssociationGovernance } from "@shared/associationGovernance";
type ParsedDirectoryRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
emailOfficielNormalise: string | null;
siret: string | null;
rna: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
siteWeb: string | null;
facebookUrl: string | null;
instagramUrl: string | null;
dateCreation: Date | null;
objetAssociation: string | null;
statutJuridique: "association_loi_1901" | "association_reconnue_utilite_publique" | "fondation" | "autre";
nomRepresentant: string | null;
fonctionRepresentant: string | null;
sourceFingerprint: string;
};
type PreviewRow = {
sheetName: string;
rowNumber: number;
nomAssociation: string;
emailOfficiel: string | null;
ville: string | null;
status: "valid" | "missing_email" | "duplicate_email";
message: string;
};
export type DuplicateEmailGroup = {
emailOfficielNormalise: string;
emailOfficiel: string;
rowNumbers: number[];
options: Array<{
sheetName: string;
rowNumber: number;
nomAssociation: string;
ville: string | null;
}>;
};
export type DirectoryPreviewResult = {
fileName: string;
totalRows: number;
validRows: number;
missingEmailRows: number;
duplicateEmailRows: number;
previewRows: PreviewRow[];
duplicateGroups: DuplicateEmailGroup[];
allRows: ParsedDirectoryRow[];
importableRows: ParsedDirectoryRow[];
};
const headerAliases: Record<string, string[]> = {
nomAssociation: ["nom association", "association", "nom", "raison sociale", "nom de la structure"],
emailOfficiel: ["email officiel", "email", "mail", "courriel", "adresse email"],
siret: ["siret", "numéro siret", "numero siret"],
rna: ["rna", "numéro rna", "numero rna"],
adresse: ["adresse", "adresse siège", "adresse siege", "adresse du siège"],
codePostal: ["code postal", "cp"],
ville: ["ville", "commune"],
telephone: ["telephone", "téléphone", "tel", "tél", "tel.", "port.", "port"],
siteWeb: ["site web", "site", "website", "url site"],
facebookUrl: ["facebook", "facebook url", "facebook link", "lien facebook", "url facebook"],
instagramUrl: ["instagram", "instagram url", "instagram link", "lien instagram", "url instagram"],
dateCreation: ["date création", "date creation", "creation", "date de création"],
objetAssociation: ["objet", "objet association", "activité", "activités", "activite"],
statutJuridique: ["statut", "statut juridique"],
nomRepresentant: ["nom représentant", "nom representant", "président", "president", "responsable", "president"],
fonctionRepresentant: ["fonction représentant", "fonction representant", "fonction", "qualité", "qualite", "secretaire", "secrétaire"],
};
function normalizeHeader(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase();
}
function cleanString(value: unknown) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? text : null;
}
function extractFirstEmail(value: unknown) {
const text = cleanString(value);
if (!text) return null;
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return match?.[0] ?? null;
}
function normalizeEmail(value: string | null) {
return value ? value.trim().toLowerCase() : null;
}
function normalizeSiret(value: string | null) {
return value ? value.replace(/\D/g, "") || null : null;
}
function normalizeRna(value: string | null) {
return value ? value.replace(/\s+/g, "").toUpperCase() : null;
}
function normalizeSiteWeb(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
return `https://${value}`;
}
function normalizeTelephone(value: string | null) {
if (!value) return null;
const first = value
.split(/[\/;,]/)
.map((part) => part.trim())
.find(Boolean);
return first ? first.slice(0, 20) : null;
}
function normalizeDate(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value;
}
if (typeof value === "number") {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function normalizeStatut(value: string | null): ParsedDirectoryRow["statutJuridique"] {
if (!value) return "association_loi_1901";
const normalized = normalizeHeader(value);
if (normalized.includes("utilite publique")) return "association_reconnue_utilite_publique";
if (normalized.includes("fondation")) return "fondation";
if (normalized.includes("1901") || normalized.includes("association")) return "association_loi_1901";
return "autre";
}
function computeFingerprint(row: Omit<ParsedDirectoryRow, "sourceFingerprint">) {
return createHash("sha256").update(JSON.stringify(row)).digest("hex");
}
function getCommuneFromSheetName(sheetName: string) {
const normalized = normalizeHeader(sheetName).replace(/[-_]/g, " ");
if (normalized.includes("kourou")) return "Kourou";
if (normalized.includes("sinnamary")) return "Sinnamary";
if (normalized.includes("iracoubo")) return "Iracoubo";
if (normalized.includes("st elie") || normalized.includes("saint elie")) return "Saint-Élie";
return null;
}
function resolveColumnIndex(headers: string[], field: keyof typeof headerAliases) {
const aliases = headerAliases[field];
return headers.findIndex(header => aliases.includes(normalizeHeader(header)));
}
function readCell(row: unknown[], headers: string[], field: keyof typeof headerAliases) {
const index = resolveColumnIndex(headers, field);
if (index === -1) return null;
return row[index];
}
function resolveEmailValue(row: unknown[], headers: string[]) {
const direct = extractFirstEmail(readCell(row, headers, "emailOfficiel"));
if (direct) return direct;
for (const cell of row) {
const extracted = extractFirstEmail(cell);
if (extracted) return extracted;
}
return null;
}
export function parseAssociationDirectoryWorkbook(fileBuffer: Buffer, fileName: string): DirectoryPreviewResult {
const workbook = XLSX.read(fileBuffer, { type: "buffer", cellDates: true });
if (workbook.SheetNames.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier Excel ne contient aucune feuille exploitable" });
}
const importableRows: ParsedDirectoryRow[] = [];
const previewRows: PreviewRow[] = [];
const emailRowMap = new Map<string, number[]>();
let hasAnyUsableSheet = false;
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return;
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: null });
if (rows.length < 2) return;
const headers = (rows[0] || []).map(value => String(value ?? ""));
if (resolveColumnIndex(headers, "nomAssociation") === -1) return;
hasAnyUsableSheet = true;
const communeFromSheet = getCommuneFromSheetName(sheetName);
rows.slice(1).forEach((rawRow, index) => {
const rowNumber = index + 2;
const nomAssociation = cleanString(readCell(rawRow, headers, "nomAssociation"));
if (!nomAssociation) {
return;
}
const emailOfficiel = resolveEmailValue(rawRow, headers);
const emailOfficielNormalise = normalizeEmail(emailOfficiel);
const rawAdresse = cleanString(readCell(rawRow, headers, "adresse"));
const parsedRowBase = {
sheetName,
rowNumber,
nomAssociation,
emailOfficiel,
emailOfficielNormalise,
siret: normalizeSiret(cleanString(readCell(rawRow, headers, "siret"))),
rna: normalizeRna(cleanString(readCell(rawRow, headers, "rna"))),
adresse: rawAdresse && extractFirstEmail(rawAdresse) ? null : rawAdresse,
codePostal: cleanString(readCell(rawRow, headers, "codePostal")),
ville: communeFromSheet || cleanString(readCell(rawRow, headers, "ville")),
telephone: normalizeTelephone(cleanString(readCell(rawRow, headers, "telephone"))),
siteWeb: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "siteWeb"))),
facebookUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "facebookUrl"))),
instagramUrl: normalizeSiteWeb(cleanString(readCell(rawRow, headers, "instagramUrl"))),
dateCreation: normalizeDate(readCell(rawRow, headers, "dateCreation")),
objetAssociation: cleanString(readCell(rawRow, headers, "objetAssociation")),
statutJuridique: normalizeStatut(cleanString(readCell(rawRow, headers, "statutJuridique"))),
nomRepresentant: cleanString(readCell(rawRow, headers, "nomRepresentant")),
fonctionRepresentant: cleanString(readCell(rawRow, headers, "fonctionRepresentant")),
};
const parsedRow: ParsedDirectoryRow = {
...parsedRowBase,
sourceFingerprint: computeFingerprint(parsedRowBase),
};
importableRows.push(parsedRow);
if (emailOfficielNormalise) {
const refs = emailRowMap.get(emailOfficielNormalise) || [];
refs.push(rowNumber);
emailRowMap.set(emailOfficielNormalise, refs);
}
});
});
if (!hasAnyUsableSheet || importableRows.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le fichier Excel ne contient pas de feuille exploitable avec une colonne de nom d'association",
});
}
importableRows.forEach(row => {
const duplicateRows = row.emailOfficielNormalise ? emailRowMap.get(row.emailOfficielNormalise) || [] : [];
if (!row.emailOfficielNormalise) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "missing_email",
message: `Feuille ${row.sheetName} : email officiel manquant, la ligne ne pourra pas être rattachée automatiquement`,
});
return;
}
if (duplicateRows.length > 1) {
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "duplicate_email",
message: `Feuille ${row.sheetName} : email dupliqué dans le fichier (lignes ${duplicateRows.join(", ")})`,
});
return;
}
previewRows.push({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
ville: row.ville,
status: "valid",
message: `Feuille ${row.sheetName} : ligne prête à être importée`,
});
});
const importableRowKeys = new Set(
previewRows
.filter(row => row.status !== "duplicate_email")
.map(row => `${row.rowNumber}::${row.nomAssociation}`)
);
const duplicateGroups: DuplicateEmailGroup[] = Array.from(emailRowMap.entries())
.filter(([, rowNumbers]) => rowNumbers.length > 1)
.map(([emailOfficielNormalise, rowNumbers]) => {
const options = importableRows
.filter(row => row.emailOfficielNormalise === emailOfficielNormalise)
.map(row => ({
sheetName: row.sheetName,
rowNumber: row.rowNumber,
nomAssociation: row.nomAssociation,
ville: row.ville,
}));
return {
emailOfficielNormalise,
emailOfficiel: options.length > 0 ? importableRows.find(row => row.emailOfficielNormalise === emailOfficielNormalise)?.emailOfficiel || emailOfficielNormalise : emailOfficielNormalise,
rowNumbers,
options,
};
});
return {
fileName,
totalRows: importableRows.length,
validRows: previewRows.filter(row => row.status === "valid").length,
missingEmailRows: previewRows.filter(row => row.status === "missing_email").length,
duplicateEmailRows: previewRows.filter(row => row.status === "duplicate_email").length,
previewRows,
duplicateGroups,
allRows: importableRows,
importableRows: importableRows.filter(row => importableRowKeys.has(`${row.rowNumber}::${row.nomAssociation}`)),
};
}
export function resolveAssociationDirectoryImportRows(
preview: DirectoryPreviewResult,
duplicateSelections?: Record<string, number>,
) {
const selectedDuplicateKeys = new Set<string>();
Object.entries(duplicateSelections || {}).forEach(([email, rowNumber]) => {
const numericRow = Number(rowNumber);
if (Number.isFinite(numericRow) && numericRow > 0) {
selectedDuplicateKeys.add(`${email}::${numericRow}`);
}
});
return preview.allRows.filter((row) => {
if (!row.emailOfficielNormalise) {
return true;
}
const isDuplicate = preview.duplicateGroups.some(group => group.emailOfficielNormalise === row.emailOfficielNormalise);
if (!isDuplicate) {
return true;
}
return selectedDuplicateKeys.has(`${row.emailOfficielNormalise}::${row.rowNumber}`);
});
}
export function createAssociationProfileFromDirectoryEntry(userId: number, entry: AssociationDirectoryEntry): InsertAssociation {
const governance = entry.nomRepresentant || entry.fonctionRepresentant
? serializeAssociationGovernance({
representantLegal: {
nom: entry.nomRepresentant || "",
prenom: "",
email: "",
telephone: "",
fonction: normalizeLegalRepresentativeRole(entry.fonctionRepresentant),
},
membres: [],
})
: null;
return {
userId,
sourceDirectoryEntryId: entry.id,
nomAssociation: entry.nomAssociation,
siret: entry.siret ?? null,
rna: entry.rna ?? null,
thematique: entry.thematique ?? null,
adresse: entry.adresse ?? null,
codePostal: entry.codePostal ?? null,
ville: entry.ville ?? null,
telephone: entry.telephone ?? null,
emailContact: entry.emailOfficiel ?? null,
siteWeb: entry.siteWeb ?? null,
facebookUrl: entry.facebookUrl ?? null,
instagramUrl: entry.instagramUrl ?? null,
dateCreation: entry.dateCreation ?? null,
objetAssociation: entry.objetAssociation ?? null,
statutJuridique: entry.statutJuridique ?? "association_loi_1901",
nomRepresentant: entry.nomRepresentant ?? null,
fonctionRepresentant: entry.fonctionRepresentant ?? null,
gouvernance: governance,
profileComplete: Boolean(entry.nomAssociation && entry.adresse && entry.ville),
isActive: true,
};
}