Checkpoint: Améliorations de la gestion des attestations de formation :

- 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
This commit is contained in:
Manus
2026-01-12 13:54:18 -05:00
parent 61f28781f0
commit ce64c72f00
13 changed files with 563 additions and 19 deletions

View File

@@ -294,3 +294,137 @@ export async function updateConfigAttestation(data: {
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 };
}

93
server/attestationsDb.ts Normal file
View File

@@ -0,0 +1,93 @@
import { desc, eq } from "drizzle-orm";
import { getDb } from "./db";
import { historiqueAttestations, sequences, apprenants, formations } from "../drizzle/schema";
/**
* Enregistrer un envoi d'attestation dans l'historique
*/
export async function enregistrerEnvoiAttestation(data: {
sequenceId: number;
apprenantId: number;
statut: "envoye" | "erreur";
messageErreur?: string;
urlAttestation?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(historiqueAttestations).values({
sequenceId: data.sequenceId,
apprenantId: data.apprenantId,
statut: data.statut,
messageErreur: data.messageErreur || null,
urlAttestation: data.urlAttestation || null,
});
}
/**
* Récupérer l'historique des envois d'attestations
* Avec les informations de la séquence, formation et apprenant
*/
export async function getHistoriqueAttestations() {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
sequence: {
id: sequences.id,
nom: sequences.nom,
},
formation: {
id: formations.id,
nom: formations.nom,
},
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(sequences, eq(historiqueAttestations.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}
/**
* Récupérer l'historique des attestations pour une séquence spécifique
*/
export async function getHistoriqueAttestationsBySequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.where(eq(historiqueAttestations.sequenceId, sequenceId))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}

View File

@@ -35,6 +35,7 @@ export async function updateParametres(
delaiExpirationQR: number;
dureeValiditeToken: number;
notificationsActives: boolean;
envoiAutomatiqueAttestations: boolean;
}>,
userId: number,
userName: string

View File

@@ -1478,6 +1478,7 @@ export const appRouter = router({
delaiExpirationQR: z.number().int().positive().optional(),
dureeValiditeToken: z.number().int().positive().optional(),
notificationsActives: z.boolean().optional(),
envoiAutomatiqueAttestations: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { updateParametres } = await import("./parametresDb");
@@ -2092,6 +2093,30 @@ export const appRouter = router({
await updateConfigAttestation(input);
return { success: true };
}),
// Récupérer l'historique des envois d'attestations
historique: adminProcedure
.query(async () => {
const { getHistoriqueAttestations } = await import("./attestationsDb");
return getHistoriqueAttestations();
}),
// Récupérer l'historique pour une séquence spécifique
historiqueBySequence: adminProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
return getHistoriqueAttestationsBySequence(input.sequenceId);
}),
// Générer une prévisualisation du modèle d'attestation
previsualiser: adminProcedure
.mutation(async () => {
const { genererPreviewAttestation } = await import("./attestationService");
return genererPreviewAttestation();
}),
}),
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====