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