- Ajout du paramètre d'envoi automatique des attestations dans la page Paramètres - Ajout de la prévisualisation du modèle d'attestation avec génération PDF - Création de la page Historique des attestations dans la section Traçabilité - Création de la table historiqueAttestations pour tracer les envois - Ajout des procédures tRPC pour l'historique et la prévisualisation
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { getDb } from "./db";
|
|
import { attestations, configAttestation, inscriptions, apprenants, sequences, formations, datesFormation } from "../drizzle/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { storagePut } from "./storage";
|
|
import PDFDocument from "pdfkit";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import crypto from "crypto";
|
|
|
|
/**
|
|
* Génère une attestation de formation en PDF
|
|
*/
|
|
export async function genererAttestationPDF(inscriptionId: number): Promise<{ s3Key: string; pdfUrl: string }> {
|
|
const db = await getDb();
|
|
if (!db) {
|
|
throw new Error("Base de données non disponible");
|
|
}
|
|
|
|
// Récupérer les données de l'inscription
|
|
const [inscriptionData] = await db
|
|
.select({
|
|
inscription: inscriptions,
|
|
apprenant: apprenants,
|
|
sequence: sequences,
|
|
formation: formations,
|
|
})
|
|
.from(inscriptions)
|
|
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
|
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
|
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
|
.where(eq(inscriptions.id, inscriptionId))
|
|
.limit(1);
|
|
|
|
if (!inscriptionData) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
|
|
const { inscription, apprenant, sequence, formation } = inscriptionData;
|
|
|
|
// Récupérer les dates de la séquence
|
|
const dates = await db
|
|
.select()
|
|
.from(datesFormation)
|
|
.where(eq(datesFormation.sequenceId, sequence.id));
|
|
|
|
// Récupérer la configuration de l'attestation
|
|
const [config] = await db
|
|
.select()
|
|
.from(configAttestation)
|
|
.limit(1);
|
|
|
|
// Créer le PDF
|
|
const doc = new PDFDocument({
|
|
size: "A4",
|
|
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
|
});
|
|
|
|
const chunks: Buffer[] = [];
|
|
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
|
|
return new Promise((resolve, reject) => {
|
|
doc.on("end", async () => {
|
|
try {
|
|
const pdfBuffer = Buffer.concat(chunks);
|
|
|
|
// Générer une clé S3 unique
|
|
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
|
const s3Key = `attestations/${apprenant.id}-${sequence.id}-${randomSuffix}.pdf`;
|
|
|
|
// Upload vers S3
|
|
const { url } = await storagePut(s3Key, pdfBuffer, "application/pdf");
|
|
|
|
resolve({ s3Key, pdfUrl: url });
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
doc.on("error", reject);
|
|
|
|
// Construction du PDF
|
|
(async () => {
|
|
try {
|
|
// Logo en haut à droite si disponible
|
|
if (config?.logoUrl) {
|
|
try {
|
|
const logoResponse = await fetch(config.logoUrl);
|
|
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
|
|
doc.image(logoBuffer, doc.page.width - 150, 50, { width: 100 });
|
|
} catch (error) {
|
|
console.error("Erreur lors du chargement du logo:", error);
|
|
}
|
|
}
|
|
|
|
// Titre
|
|
doc.fontSize(24).font("Helvetica-Bold").text("ATTESTATION DE FORMATION", { align: "center" });
|
|
doc.moveDown(2);
|
|
|
|
// Texte personnalisable ou texte par défaut
|
|
const texteAttestation = config?.texteAttestation ||
|
|
"Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
|
|
|
|
// Remplacer les variables
|
|
const nomComplet = `${apprenant.prenom} ${apprenant.nom}`;
|
|
const dateDebut = dates.length > 0 ? format(new Date(dates[0].dateDebut), "dd MMMM yyyy", { locale: fr }) : "";
|
|
const dateFin = dates.length > 0 ? format(new Date(dates[dates.length - 1].dateFin), "dd MMMM yyyy", { locale: fr }) : "";
|
|
|
|
const texteRempli = texteAttestation
|
|
.replace(/\{\{nomComplet\}\}/g, nomComplet)
|
|
.replace(/\{\{nomFormation\}\}/g, formation.nom)
|
|
.replace(/\{\{dateDebut\}\}/g, dateDebut)
|
|
.replace(/\{\{dateFin\}\}/g, dateFin);
|
|
|
|
doc.fontSize(12).font("Helvetica").text(texteRempli, { align: "justify" });
|
|
doc.moveDown(2);
|
|
|
|
// Détails de la formation
|
|
doc.fontSize(10).font("Helvetica-Bold").text("Détails de la formation :", { underline: true });
|
|
doc.moveDown(0.5);
|
|
doc.font("Helvetica");
|
|
doc.text(`Formation : ${formation.nom}`);
|
|
doc.text(`Séquence : ${sequence.nom}`);
|
|
doc.text(`Lieu : ${sequence.lieu}`);
|
|
doc.moveDown(0.5);
|
|
doc.text("Dates :");
|
|
dates.forEach((date) => {
|
|
const dateStr = format(new Date(date.dateDebut), "dd/MM/yyyy", { locale: fr });
|
|
const heureDebut = format(new Date(date.dateDebut), "HH:mm");
|
|
const heureFin = format(new Date(date.dateFin), "HH:mm");
|
|
doc.text(` • ${dateStr} de ${heureDebut} à ${heureFin}`);
|
|
});
|
|
|
|
doc.moveDown(3);
|
|
|
|
// Signature
|
|
doc.fontSize(10);
|
|
doc.text(`Fait le ${format(new Date(), "dd MMMM yyyy", { locale: fr })}`, { align: "right" });
|
|
doc.moveDown(3);
|
|
|
|
// Signature si disponible
|
|
if (config?.signatureUrl) {
|
|
try {
|
|
const signatureResponse = await fetch(config.signatureUrl);
|
|
const signatureBuffer = Buffer.from(await signatureResponse.arrayBuffer());
|
|
const signatureX = doc.page.width - 200;
|
|
const signatureY = doc.y;
|
|
doc.image(signatureBuffer, signatureX, signatureY, { width: 150, height: 50 });
|
|
doc.moveDown(3);
|
|
} catch (error) {
|
|
console.error("Erreur lors du chargement de la signature:", error);
|
|
}
|
|
}
|
|
|
|
if (config?.nomSignataire) {
|
|
doc.text(config.nomSignataire, { align: "right" });
|
|
}
|
|
if (config?.fonctionSignataire) {
|
|
doc.text(config.fonctionSignataire, { align: "right" });
|
|
}
|
|
|
|
doc.end();
|
|
} catch (error) {
|
|
doc.end();
|
|
reject(error);
|
|
}
|
|
})();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enregistre une attestation dans la base de données
|
|
*/
|
|
export async function enregistrerAttestation(
|
|
inscriptionId: number,
|
|
apprenantId: number,
|
|
sequenceId: number,
|
|
s3Key: string,
|
|
pdfUrl: string
|
|
): Promise<number> {
|
|
const db = await getDb();
|
|
if (!db) {
|
|
throw new Error("Base de données non disponible");
|
|
}
|
|
|
|
const [result] = await db.insert(attestations).values({
|
|
inscriptionId,
|
|
apprenantId,
|
|
sequenceId,
|
|
s3Key,
|
|
pdfUrl,
|
|
emailEnvoye: false,
|
|
});
|
|
|
|
return result.insertId;
|
|
}
|
|
|
|
/**
|
|
* Récupère l'attestation d'un apprenant pour une séquence
|
|
*/
|
|
export async function getAttestation(apprenantId: number, sequenceId: number) {
|
|
const db = await getDb();
|
|
if (!db) return null;
|
|
|
|
const [attestation] = await db
|
|
.select()
|
|
.from(attestations)
|
|
.where(
|
|
and(
|
|
eq(attestations.apprenantId, apprenantId),
|
|
eq(attestations.sequenceId, sequenceId)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
return attestation || null;
|
|
}
|
|
|
|
/**
|
|
* Récupère toutes les attestations d'un apprenant
|
|
*/
|
|
export async function getAttestationsApprenant(apprenantId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
|
|
return db
|
|
.select({
|
|
attestation: attestations,
|
|
sequence: sequences,
|
|
formation: formations,
|
|
})
|
|
.from(attestations)
|
|
.innerJoin(sequences, eq(attestations.sequenceId, sequences.id))
|
|
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
|
.where(eq(attestations.apprenantId, apprenantId));
|
|
}
|
|
|
|
/**
|
|
* Marque une attestation comme envoyée par email
|
|
*/
|
|
export async function marquerAttestationEnvoyee(attestationId: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
|
|
await db
|
|
.update(attestations)
|
|
.set({
|
|
emailEnvoye: true,
|
|
dateEnvoiEmail: new Date(),
|
|
})
|
|
.where(eq(attestations.id, attestationId));
|
|
}
|
|
|
|
/**
|
|
* Récupère ou crée la configuration des attestations
|
|
*/
|
|
export async function getOrCreateConfigAttestation() {
|
|
const db = await getDb();
|
|
if (!db) return null;
|
|
|
|
let [config] = await db.select().from(configAttestation).limit(1);
|
|
|
|
if (!config) {
|
|
// Créer une configuration par défaut
|
|
await db.insert(configAttestation).values({
|
|
texteAttestation: "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.",
|
|
});
|
|
|
|
[config] = await db.select().from(configAttestation).limit(1);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Met à jour la configuration des attestations
|
|
*/
|
|
export async function updateConfigAttestation(data: {
|
|
logoS3Key?: string;
|
|
logoUrl?: string;
|
|
signatureS3Key?: string;
|
|
signatureUrl?: string;
|
|
nomSignataire?: string;
|
|
fonctionSignataire?: string;
|
|
texteAttestation?: string;
|
|
}) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
|
|
const [existing] = await db.select().from(configAttestation).limit(1);
|
|
|
|
if (existing) {
|
|
await db.update(configAttestation).set(data).where(eq(configAttestation.id, existing.id));
|
|
} else {
|
|
await db.insert(configAttestation).values(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère une prévisualisation du modèle d'attestation avec des données fictives
|
|
*/
|
|
export async function genererPreviewAttestation(): Promise<{ pdfUrl: string }> {
|
|
const db = await getDb();
|
|
if (!db) {
|
|
throw new Error("Base de données non disponible");
|
|
}
|
|
|
|
// Récupérer la configuration de l'attestation
|
|
const config = await getOrCreateConfigAttestation();
|
|
if (!config) {
|
|
throw new Error("Configuration d'attestation introuvable");
|
|
}
|
|
|
|
// Données fictives pour la prévisualisation
|
|
const donneesFictives = {
|
|
apprenant: {
|
|
nom: "Dupont",
|
|
prenom: "Jean",
|
|
email: "jean.dupont@example.com",
|
|
},
|
|
formation: {
|
|
nom: "Formation Manager Itinova",
|
|
nbJours: 5,
|
|
},
|
|
sequence: {
|
|
nom: "Groupe A",
|
|
lieu: "Paris",
|
|
},
|
|
dates: [
|
|
{ date: new Date("2026-02-10") },
|
|
{ date: new Date("2026-02-11") },
|
|
{ date: new Date("2026-02-12") },
|
|
{ date: new Date("2026-02-13") },
|
|
{ date: new Date("2026-02-14") },
|
|
],
|
|
};
|
|
|
|
// Créer le PDF
|
|
const doc = new PDFDocument({ size: "A4", margin: 50 });
|
|
const chunks: Buffer[] = [];
|
|
|
|
doc.on("data", (chunk) => chunks.push(chunk));
|
|
|
|
// En-tête avec logo si disponible
|
|
if (config.logoUrl) {
|
|
try {
|
|
const response = await fetch(config.logoUrl);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
doc.image(buffer, 50, 45, { width: 100 });
|
|
} catch (error) {
|
|
console.error("Erreur lors du chargement du logo:", error);
|
|
}
|
|
}
|
|
|
|
// Titre
|
|
doc
|
|
.fontSize(24)
|
|
.font("Helvetica-Bold")
|
|
.text("ATTESTATION DE FORMATION", 0, 150, { align: "center" });
|
|
|
|
doc.moveDown(2);
|
|
|
|
// Texte de l'attestation avec remplacement des variables
|
|
let texte = config.texteAttestation || "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
|
|
|
|
const dateDebut = format(donneesFictives.dates[0].date, "d MMMM yyyy", { locale: fr });
|
|
const dateFin = format(donneesFictives.dates[donneesFictives.dates.length - 1].date, "d MMMM yyyy", { locale: fr });
|
|
|
|
texte = texte
|
|
.replace(/\{\{nomComplet\}\}/g, `${donneesFictives.apprenant.prenom} ${donneesFictives.apprenant.nom}`)
|
|
.replace(/\{\{nomFormation\}\}/g, donneesFictives.formation.nom)
|
|
.replace(/\{\{dateDebut\}\}/g, dateDebut)
|
|
.replace(/\{\{dateFin\}\}/g, dateFin)
|
|
.replace(/\{\{lieu\}\}/g, donneesFictives.sequence.lieu)
|
|
.replace(/\{\{nbJours\}\}/g, donneesFictives.formation.nbJours.toString());
|
|
|
|
doc
|
|
.fontSize(12)
|
|
.font("Helvetica")
|
|
.text(texte, { align: "justify", lineGap: 5 });
|
|
|
|
doc.moveDown(2);
|
|
|
|
// Dates de formation
|
|
doc.fontSize(10).font("Helvetica-Bold").text("Dates de formation :");
|
|
doc.font("Helvetica");
|
|
donneesFictives.dates.forEach((d) => {
|
|
doc.text(`• ${format(d.date, "EEEE d MMMM yyyy", { locale: fr })}`, { indent: 20 });
|
|
});
|
|
|
|
doc.moveDown(2);
|
|
|
|
// Signature
|
|
doc.fontSize(10).font("Helvetica").text(`Fait à ${donneesFictives.sequence.lieu}, le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`);
|
|
|
|
doc.moveDown(1);
|
|
|
|
if (config.signatureUrl) {
|
|
try {
|
|
const response = await fetch(config.signatureUrl);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
doc.image(buffer, doc.x, doc.y, { width: 150 });
|
|
doc.moveDown(3);
|
|
} catch (error) {
|
|
console.error("Erreur lors du chargement de la signature:", error);
|
|
}
|
|
}
|
|
|
|
if (config.nomSignataire) {
|
|
doc.font("Helvetica-Bold").text(config.nomSignataire);
|
|
}
|
|
if (config.fonctionSignataire) {
|
|
doc.font("Helvetica").text(config.fonctionSignataire);
|
|
}
|
|
|
|
// Finaliser le PDF
|
|
doc.end();
|
|
|
|
await new Promise((resolve) => doc.on("end", resolve));
|
|
|
|
const pdfBuffer = Buffer.concat(chunks);
|
|
|
|
// Uploader sur S3
|
|
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
|
const s3Key = `attestations/preview/preview-${randomSuffix}.pdf`;
|
|
const { url: pdfUrl } = await storagePut(s3Key, pdfBuffer, "application/pdf");
|
|
|
|
return { pdfUrl };
|
|
}
|