Checkpoint: Système d'attestations de formation implémenté

 Infrastructure backend :
- Tables attestations et configAttestation en base de données
- Service de génération PDF avec PDFKit (attestationService.ts)
- Génération automatique de PDF avec template personnalisable
- Upload automatique vers S3 avec URLs publiques
- Procédures tRPC pour générer, récupérer et configurer

 Interface utilisateur :
- Page de configuration des attestations (/admin/attestations)
- Personnalisation du texte avec variables dynamiques (nomComplet, nomFormation, dateDebut, dateFin)
- Configuration du signataire (nom et fonction)
- Bouton "Générer" dans la page de validation des présences formateur
- Génération automatique avec ouverture du PDF dans un nouvel onglet

 Fonctionnalités :
- Vérification des doublons (une seule attestation par apprenant/séquence)
- Génération uniquement pour les apprenants marqués comme présents
- PDF professionnel avec détails de la formation et dates
- Stockage permanent sur S3

📋 Améliorations futures possibles :
- Upload de logo et signature personnalisés
- Envoi automatique par email aux apprenants
- Espace apprenant pour télécharger les attestations
This commit is contained in:
Manus
2026-01-07 10:06:29 -05:00
parent cc3badffa3
commit 023711df92
15 changed files with 6691 additions and 2 deletions

View File

@@ -0,0 +1,13 @@
{
"query": "SHOW TABLES LIKE 'attestations';",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SHOW TABLES LIKE 'attestations';",
"rows": [
{
"Tables_in_7PAT67UmWoxv8vwp8Bbcv6 (attestations)": "attestations"
}
],
"messages": [],
"stdout": "Tables_in_7PAT67UmWoxv8vwp8Bbcv6 (attestations)\nattestations\n",
"stderr": "",
"execution_time_ms": 53
}

View File

@@ -29,6 +29,7 @@ import AdminRappelsHistorique from "./pages/admin/AdminRappelsHistorique";
import AdminRappelsStats from "./pages/admin/AdminRappelsStats";
import AdminFormateurs from "./pages/admin/AdminFormateurs";
import AdminNotifications from "./pages/admin/AdminNotifications";
import AdminAttestations from "./pages/admin/AdminAttestations";
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
import Inscription from "./pages/Inscription";
import Login from "./pages/Login";
@@ -53,6 +54,7 @@ function Router() {
<Route path={"/admin/users"} component={AdminUsers} />
<Route path={"/admin/formateurs"} component={AdminFormateurs} />
<Route path={"/admin/notifications"} component={AdminNotifications} />
<Route path={"/admin/attestations"} component={AdminAttestations} />
<Route path={"/admin/email-templates"} component={AdminEmailTemplates} />
<Route path={"/admin/email-config"} component={AdminEmailConfig} />
<Route path={"/admin/calendrier"} component={AdminCalendrier} />

View File

@@ -21,7 +21,7 @@ import {
} from "@/components/ui/sidebar";
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -57,6 +57,7 @@ const menuSections = [
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
{ icon: Mail, label: "Notifications", path: "/admin/notifications" },
{ icon: FileText, label: "Attestations", path: "/admin/attestations" },
]
},
{

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { FileText, Save } from "lucide-react";
export default function AdminAttestations() {
const { data: config, isLoading, refetch } = trpc.attestations.getConfig.useQuery();
const updateConfig = trpc.attestations.updateConfig.useMutation();
const [formData, setFormData] = useState({
nomSignataire: "",
fonctionSignataire: "",
texteAttestation: "",
});
// Mettre à jour le formulaire quand les données sont chargées
useEffect(() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
});
}
}, [config]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateConfig.mutateAsync(formData);
toast.success("Configuration enregistrée avec succès");
refetch();
} catch (error) {
toast.error("Erreur lors de l'enregistrement");
console.error(error);
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Chargement...</p>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="container max-w-4xl py-8">
<div className="flex items-center gap-3 mb-6">
<FileText className="h-8 w-8 text-primary" />
<div>
<h1 className="text-3xl font-bold">Configuration des attestations</h1>
<p className="text-muted-foreground">
Personnalisez le contenu et la signature des attestations de formation
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informations du signataire</CardTitle>
<CardDescription>
Ces informations apparaîtront en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nomSignataire">Nom du signataire</Label>
<Input
id="nomSignataire"
value={formData.nomSignataire}
onChange={(e) =>
setFormData({ ...formData, nomSignataire: e.target.value })
}
placeholder="Ex: Jean Dupont"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fonctionSignataire">Fonction du signataire</Label>
<Input
id="fonctionSignataire"
value={formData.fonctionSignataire}
onChange={(e) =>
setFormData({ ...formData, fonctionSignataire: e.target.value })
}
placeholder="Ex: Directeur de la formation"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Texte de l'attestation</CardTitle>
<CardDescription>
Personnalisez le texte principal de l'attestation. Utilisez les variables suivantes :
<ul className="list-disc list-inside mt-2 space-y-1">
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{nomComplet}}'}</code> - Nom complet de l'apprenant</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{nomFormation}}'}</code> - Nom de la formation</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{dateDebut}}'}</code> - Date de début de la formation</li>
<li><code className="bg-muted px-1 py-0.5 rounded">{'{{dateFin}}'}</code> - Date de fin de la formation</li>
</ul>
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={formData.texteAttestation}
onChange={(e) =>
setFormData({ ...formData, texteAttestation: e.target.value })
}
placeholder="Nous attestons que {{nomComplet}} a suivi avec assiduité la formation &quot;{{nomFormation}}&quot; organisée du {{dateDebut}} au {{dateFin}}."
rows={6}
className="font-serif"
/>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
if (config) {
setFormData({
nomSignataire: config.nomSignataire || "",
fonctionSignataire: config.fonctionSignataire || "",
texteAttestation: config.texteAttestation || "",
});
}
}}
>
Annuler
</Button>
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="mr-2 h-4 w-4" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</form>
</div>
</DashboardLayout>
);
}

View File

@@ -13,9 +13,48 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trpc } from "@/lib/trpc";
import { Calendar, FileText, MapPin, Upload, Users } from "lucide-react";
import { Calendar, FileText, MapPin, Upload, Users, Award } from "lucide-react";
import { useParams } from "wouter";
import { toast } from "sonner";
import { useState } from "react";
function AttestationButton({ inscriptionId, present }: { inscriptionId: number; present: boolean }) {
const [isGenerating, setIsGenerating] = useState(false);
const genererAttestation = trpc.attestations.generer.useMutation();
const handleGenerer = async () => {
if (!present) {
toast.error("L'apprenant doit être marqué comme présent pour générer une attestation");
return;
}
setIsGenerating(true);
try {
const result = await genererAttestation.mutateAsync({ inscriptionId });
if (result.success && result.attestation) {
toast.success(result.message || "Attestation générée avec succès");
// Ouvrir le PDF dans un nouvel onglet
window.open(result.attestation.pdfUrl, "_blank");
}
} catch (error: any) {
toast.error(error.message || "Erreur lors de la génération de l'attestation");
} finally {
setIsGenerating(false);
}
};
return (
<Button
size="sm"
variant="outline"
onClick={handleGenerer}
disabled={!present || isGenerating}
>
<Award className="h-4 w-4 mr-1" />
{isGenerating ? "Génération..." : "Générer"}
</Button>
);
}
export default function FormateurSequence() {
const { id } = useParams();
@@ -189,6 +228,7 @@ export default function FormateurSequence() {
<TableHead>Établissement</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-center">Présent</TableHead>
<TableHead className="text-center">Attestation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -224,6 +264,9 @@ export default function FormateurSequence() {
}
/>
</TableCell>
<TableCell className="text-center">
<AttestationButton inscriptionId={apprenant.inscriptionId} present={apprenant.statut === "confirmee"} />
</TableCell>
</TableRow>
))}
</TableBody>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,27 @@
"when": 1765830109012,
"tag": "0023_jittery_gorgon",
"breakpoints": true
},
{
"idx": 24,
"version": "5",
"when": 1767770858914,
"tag": "0024_panoramic_goliath",
"breakpoints": true
},
{
"idx": 25,
"version": "5",
"when": 1767795835957,
"tag": "0025_curious_wrecker",
"breakpoints": true
},
{
"idx": 26,
"version": "5",
"when": 1767798385842,
"tag": "0026_panoramic_king_bedlam",
"breakpoints": true
}
]
}

View File

@@ -520,3 +520,58 @@ export const logsNotifications = mysqlTable("logsNotifications", {
export type LogNotification = typeof logsNotifications.$inferSelect;
export type InsertLogNotification = typeof logsNotifications.$inferInsert;
/**
* Table des attestations de formation
* Stocke les attestations générées pour chaque apprenant ayant terminé une formation
*/
export const attestations = mysqlTable("attestations", {
id: int("id").autoincrement().primaryKey(),
/** ID de l'inscription concernée */
inscriptionId: int("inscriptionId").notNull(),
/** ID de l'apprenant */
apprenantId: int("apprenantId").notNull(),
/** ID de la séquence */
sequenceId: int("sequenceId").notNull(),
/** Clé S3 du PDF généré */
s3Key: varchar("s3Key", { length: 500 }).notNull(),
/** URL publique du PDF */
pdfUrl: varchar("pdfUrl", { length: 1000 }).notNull(),
/** Date de génération de l'attestation */
dateGeneration: timestamp("dateGeneration").defaultNow().notNull(),
/** Statut de l'envoi par email */
emailEnvoye: boolean("emailEnvoye").default(false).notNull(),
/** Date d'envoi de l'email */
dateEnvoiEmail: timestamp("dateEnvoiEmail"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type Attestation = typeof attestations.$inferSelect;
export type InsertAttestation = typeof attestations.$inferInsert;
/**
* Table de configuration des attestations
* Stocke le logo, la signature et le template personnalisable
*/
export const configAttestation = mysqlTable("configAttestation", {
id: int("id").autoincrement().primaryKey(),
/** Clé S3 du logo */
logoS3Key: varchar("logoS3Key", { length: 500 }),
/** URL du logo */
logoUrl: varchar("logoUrl", { length: 1000 }),
/** Clé S3 de la signature */
signatureS3Key: varchar("signatureS3Key", { length: 500 }),
/** URL de la signature */
signatureUrl: varchar("signatureUrl", { length: 1000 }),
/** Nom du signataire */
nomSignataire: varchar("nomSignataire", { length: 255 }),
/** Fonction du signataire */
fonctionSignataire: varchar("fonctionSignataire", { length: 255 }),
/** Texte personnalisable de l'attestation */
texteAttestation: text("texteAttestation"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ConfigAttestation = typeof configAttestation.$inferSelect;
export type InsertConfigAttestation = typeof configAttestation.$inferInsert;

View File

@@ -50,6 +50,7 @@
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@types/pdfkit": "^0.17.4",
"axios": "^1.12.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
@@ -74,6 +75,7 @@
"next-themes": "^0.4.6",
"nodemailer": "^7.0.11",
"openai": "^4.67.0",
"pdfkit": "^0.17.2",
"react": "^19.1.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.1",

124
pnpm-lock.yaml generated
View File

@@ -127,6 +127,9 @@ importers:
'@trpc/server':
specifier: ^11.6.0
version: 11.6.0(typescript@5.9.3)
'@types/pdfkit':
specifier: ^0.17.4
version: 0.17.4
axios:
specifier: ^1.12.0
version: 1.12.2
@@ -199,6 +202,9 @@ importers:
openai:
specifier: ^4.67.0
version: 4.104.0(zod@4.1.12)
pdfkit:
specifier: ^0.17.2
version: 0.17.2
react:
specifier: ^19.1.1
version: 19.2.0
@@ -2294,6 +2300,9 @@ packages:
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'}
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
'@tailwindcss/node@4.1.14':
resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==}
@@ -2761,6 +2770,9 @@ packages:
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/pdfkit@0.17.4':
resolution: {integrity: sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -2917,6 +2929,10 @@ packages:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2954,6 +2970,9 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserslist@4.26.3:
resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3047,6 +3066,10 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -3139,6 +3162,9 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
@@ -3375,6 +3401,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -3668,6 +3697,9 @@ packages:
debug:
optional: true
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
@@ -3916,6 +3948,9 @@ packages:
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
jpeg-exif@1.1.4:
resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4041,6 +4076,9 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@@ -4472,6 +4510,9 @@ packages:
package-manager-detector@1.5.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -4508,6 +4549,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pdfkit@0.17.2:
resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
@@ -4524,6 +4568,9 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
png-js@1.0.0:
resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==}
pnpm@10.18.0:
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
engines: {node: '>=18.12'}
@@ -4798,6 +4845,9 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
rgbcolor@1.0.1:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
@@ -4990,6 +5040,9 @@ packages:
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -5074,6 +5127,12 @@ packages:
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -7981,6 +8040,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@swc/helpers@0.5.18':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.1.14':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -8500,6 +8563,10 @@ snapshots:
'@types/pako@2.0.4': {}
'@types/pdfkit@0.17.4':
dependencies:
'@types/node': 24.7.0
'@types/qs@6.14.0': {}
'@types/raf@3.4.3':
@@ -8689,6 +8756,8 @@ snapshots:
base64-arraybuffer@1.0.2:
optional: true
base64-js@0.0.8: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.12: {}
@@ -8738,6 +8807,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
browserslist@4.26.3:
dependencies:
baseline-browser-mapping: 2.8.12
@@ -8838,6 +8911,8 @@ snapshots:
dependencies:
clsx: 2.1.1
clone@2.1.2: {}
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
@@ -8917,6 +8992,8 @@ snapshots:
crelt@1.0.6: {}
crypto-js@4.2.0: {}
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
@@ -9154,6 +9231,8 @@ snapshots:
dependencies:
dequal: 2.0.3
dfa@1.2.0: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -9444,6 +9523,18 @@ snapshots:
follow-redirects@1.15.11: {}
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.18
brotli: 1.3.3
clone: 2.1.2
dfa: 1.2.0
fast-deep-equal: 3.1.3
restructure: 3.0.2
tiny-inflate: 1.0.3
unicode-properties: 1.4.1
unicode-trie: 2.0.0
form-data-encoder@1.7.2: {}
form-data@4.0.4:
@@ -9752,6 +9843,8 @@ snapshots:
jose@6.1.0: {}
jpeg-exif@1.1.4: {}
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -9877,6 +9970,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -10503,6 +10601,8 @@ snapshots:
package-manager-detector@1.5.0: {}
pako@0.2.9: {}
pako@1.0.11: {}
pako@2.1.0: {}
@@ -10535,6 +10635,14 @@ snapshots:
pathval@2.0.1: {}
pdfkit@0.17.2:
dependencies:
crypto-js: 4.2.0
fontkit: 2.0.4
jpeg-exif: 1.1.4
linebreak: 1.1.0
png-js: 1.0.0
performance-now@2.1.0:
optional: true
@@ -10554,6 +10662,8 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
png-js@1.0.0: {}
pnpm@10.18.0: {}
points-on-curve@0.2.0: {}
@@ -10928,6 +11038,8 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
restructure@3.0.2: {}
rgbcolor@1.0.1:
optional: true
@@ -11180,6 +11292,8 @@ snapshots:
utrie: 1.0.2
optional: true
tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -11239,6 +11353,16 @@ snapshots:
undici-types@7.14.0: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3

View File

@@ -0,0 +1,269 @@
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
try {
// 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);
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);
}
}

View File

@@ -1736,6 +1736,98 @@ export const appRouter = router({
});
}),
}),
// Attestations de formation
attestations: router({
// Générer une attestation pour une inscription
generer: adminProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.mutation(async ({ input }) => {
const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService");
const db = await import("./db").then(m => m.getDb());
if (!db) throw new Error("Base de données non disponible");
// Vérifier si l'attestation existe déjà
const { inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (!inscription) {
throw new Error("Inscription introuvable");
}
const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId);
if (attestationExistante) {
return {
success: true,
attestation: attestationExistante,
message: "Attestation déjà générée",
};
}
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Enregistrer dans la base
const attestationId = await enregistrerAttestation(
input.inscriptionId,
inscription.apprenantId,
inscription.sequenceId,
s3Key,
pdfUrl
);
const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId);
return {
success: true,
attestation,
message: "Attestation générée avec succès",
};
}),
// Récupérer l'attestation d'un apprenant pour une séquence
get: publicProcedure
.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestation } = await import("./attestationService");
return getAttestation(input.apprenantId, input.sequenceId);
}),
// Récupérer toutes les attestations d'un apprenant
listApprenant: publicProcedure
.input(z.object({
apprenantId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestationsApprenant } = await import("./attestationService");
return getAttestationsApprenant(input.apprenantId);
}),
// Récupérer la configuration des attestations
getConfig: adminProcedure
.query(async () => {
const { getOrCreateConfigAttestation } = await import("./attestationService");
return getOrCreateConfigAttestation();
}),
// Mettre à jour la configuration des attestations
updateConfig: adminProcedure
.input(z.object({
nomSignataire: z.string().optional(),
fonctionSignataire: z.string().optional(),
texteAttestation: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { updateConfigAttestation } = await import("./attestationService");
await updateConfigAttestation(input);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;

View File

@@ -442,3 +442,11 @@
- [x] Interface de gestion des emails formateurs dans Configuration
- [x] Tableau de bord des notifications avec historique des emails envoyés
- [x] Lien vers questionnaire de satisfaction dans l'email de remerciement
## Réimplémentation attestations (16/12/2025)
- [x] Recréer les tables en base de données
- [x] Recréer le service de génération PDF
- [x] Recréer les procédures tRPC
- [x] Créer la page de configuration
- [x] Intégrer le bouton dans la page de validation des présences
- [x] Tester la génération complète