Checkpoint: Phase 1 : Système d'attestations de formation automatiques

 Infrastructure backend complète :
- Tables attestations et configAttestation en base de données
- Service de génération PDF (attestationService.ts) avec PDFKit
- Procédures tRPC pour générer, récupérer et configurer les attestations
- Stockage S3 des PDF générés

 Interface d'administration :
- Page de configuration des attestations (/admin/attestations)
- Personnalisation du texte avec variables dynamiques
- Champs pour signataire (nom et fonction)
- Placeholders pour logo et signature (à implémenter en phase 2)

📋 À faire en Phase 2 :
- Upload des images (logo/signature) vers S3
- Génération automatique après validation de présence
- Envoi automatique par email aux apprenants
- Espace apprenant pour télécharger les attestations
This commit is contained in:
Manus
2026-01-07 06:35:12 -05:00
parent 29b35fceaa
commit 2eb352206f
14 changed files with 3098 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
{
"query": "SELECT \n type,\n emailDestinataire,\n sujet,\n dateEnvoi,\n statut,\n sequenceId,\n apprenantId\nFROM logsNotifications \nWHERE type = 'remerciement'\nORDER BY dateEnvoi DESC\nLIMIT 50;",
"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 SELECT \n type,\n emailDestinataire,\n sujet,\n dateEnvoi,\n statut,\n sequenceId,\n apprenantId\nFROM logsNotifications \nWHERE type = 'remerciement'\nORDER BY dateEnvoi DESC\nLIMIT 50;",
"rows": [
{
"type": "remerciement",
"emailDestinataire": "alerte2.test@example.com",
"sujet": "Merci pour votre participation - Formation manager Itinova",
"dateEnvoi": "2025-12-18 11:23:55",
"statut": "success",
"sequenceId": "30001",
"apprenantId": "90002"
},
{
"type": "remerciement",
"emailDestinataire": "test.alerte@example.com",
"sujet": "Merci pour votre participation - Formation manager Itinova",
"dateEnvoi": "2025-12-18 09:42:30",
"statut": "success",
"sequenceId": "30001",
"apprenantId": "90001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation manager Itinova",
"dateEnvoi": "2025-12-18 09:42:27",
"statut": "success",
"sequenceId": "30001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:16:59",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:16:32",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:14:32",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:14:25",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:00:43",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 21:00:40",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:34:28",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:34:00",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:33:28",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:33:21",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:26:15",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:26:13",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:51",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:49",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:31",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:28",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:14",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:25:12",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "o.pareige@itinova.org",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:24:58",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "240001"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:24:55",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
},
{
"type": "remerciement",
"emailDestinataire": "opareige@gmail.com",
"sujet": "Merci pour votre participation - Formation Jetski",
"dateEnvoi": "2025-12-15 20:24:49",
"statut": "success",
"sequenceId": "330001",
"apprenantId": "1"
}
],
"messages": [],
"stdout": "type\temailDestinataire\tsujet\tdateEnvoi\tstatut\tsequenceId\tapprenantId\nremerciement\talerte2.test@example.com\tMerci pour votre participation - Formation manager Itinova\t2025-12-18 11:23:55\tsuccess\t30001\t90002\nremerciement\ttest.alerte@example.com\tMerci pour votre participation - Formation manager Itinova\t2025-12-18 09:42:30\tsuccess\t30001\t90001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation manager Itinova\t2025-12-18 09:42:27\tsuccess\t30001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:16:59\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:16:32\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:14:32\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:14:25\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:00:43\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 21:00:40\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:34:28\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:34:00\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:33:28\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:33:21\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:26:15\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:26:13\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:51\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:49\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:31\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:28\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:14\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:25:12\tsuccess\t330001\t1\nremerciement\to.pareige@itinova.org\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:24:58\tsuccess\t330001\t240001\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:24:55\tsuccess\t330001\t1\nremerciement\topareige@gmail.com\tMerci pour votre participation - Formation Jetski\t2025-12-15 20:24:49\tsuccess\t330001\t1\n",
"stderr": "",
"execution_time_ms": 168
}

View File

@@ -0,0 +1,24 @@
{
"query": "SELECT \n emailDestinataire,\n sequenceId,\n COUNT(*) as nb_envois,\n MIN(dateEnvoi) as premier_envoi,\n MAX(dateEnvoi) as dernier_envoi\nFROM logsNotifications \nWHERE type = 'remerciement' AND statut = 'success'\nGROUP BY emailDestinataire, sequenceId\nHAVING COUNT(*) > 1\nORDER BY nb_envois DESC, dernier_envoi DESC;",
"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 SELECT \n emailDestinataire,\n sequenceId,\n COUNT(*) as nb_envois,\n MIN(dateEnvoi) as premier_envoi,\n MAX(dateEnvoi) as dernier_envoi\nFROM logsNotifications \nWHERE type = 'remerciement' AND statut = 'success'\nGROUP BY emailDestinataire, sequenceId\nHAVING COUNT(*) > 1\nORDER BY nb_envois DESC, dernier_envoi DESC;",
"rows": [
{
"emailDestinataire": "opareige@gmail.com",
"sequenceId": "330001",
"nb_envois": "11",
"premier_envoi": "2025-12-15 20:24:49",
"dernier_envoi": "2025-12-15 21:16:32"
},
{
"emailDestinataire": "o.pareige@itinova.org",
"sequenceId": "330001",
"nb_envois": "10",
"premier_envoi": "2025-12-15 20:24:58",
"dernier_envoi": "2025-12-15 21:16:59"
}
],
"messages": [],
"stdout": "emailDestinataire\tsequenceId\tnb_envois\tpremier_envoi\tdernier_envoi\nopareige@gmail.com\t330001\t11\t2025-12-15 20:24:49\t2025-12-15 21:16:32\no.pareige@itinova.org\t330001\t10\t2025-12-15 20:24:58\t2025-12-15 21:16:59\n",
"stderr": "",
"execution_time_ms": 61
}

View File

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE IF NOT EXISTS `attestations` (\n `id` int AUTO_INCREMENT NOT NULL,\n `inscriptionId` int NOT NULL,\n `apprenantId` int NOT NULL,\n `sequenceId` int NOT NULL,\n `s3Key` varchar(500) NOT NULL,\n `pdfUrl` varchar(1000) NOT NULL,\n `dateGeneration` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `emailEnvoye` boolean NOT NULL DEFAULT false,\n `dateEnvoiEmail` timestamp NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `attestations_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE IF NOT EXISTS `configAttestation` (\n `id` int AUTO_INCREMENT NOT NULL,\n `logoS3Key` varchar(500),\n `logoUrl` varchar(1000),\n `signatureS3Key` varchar(500),\n `signatureUrl` varchar(1000),\n `nomSignataire` varchar(255),\n `fonctionSignataire` varchar(255),\n `texteAttestation` text,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updatedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n CONSTRAINT `configAttestation_id` PRIMARY KEY(`id`)\n);",
"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 CREATE TABLE IF NOT EXISTS `attestations` (\n `id` int AUTO_INCREMENT NOT NULL,\n `inscriptionId` int NOT NULL,\n `apprenantId` int NOT NULL,\n `sequenceId` int NOT NULL,\n `s3Key` varchar(500) NOT NULL,\n `pdfUrl` varchar(1000) NOT NULL,\n `dateGeneration` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `emailEnvoye` boolean NOT NULL DEFAULT false,\n `dateEnvoiEmail` timestamp NULL,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `attestations_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE IF NOT EXISTS `configAttestation` (\n `id` int AUTO_INCREMENT NOT NULL,\n `logoS3Key` varchar(500),\n `logoUrl` varchar(1000),\n `signatureS3Key` varchar(500),\n `signatureUrl` varchar(1000),\n `nomSignataire` varchar(255),\n `fonctionSignataire` varchar(255),\n `texteAttestation` text,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `updatedAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n CONSTRAINT `configAttestation_id` PRIMARY KEY(`id`)\n);",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 665
}

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

@@ -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,218 @@
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 { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Upload, 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: any) {
toast.error("Erreur lors de l'enregistrement : " + error.message);
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="text-center py-8">Chargement...</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Configuration des Attestations</h1>
<p className="text-muted-foreground">
Personnalisez le template des attestations de formation
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Logo et signature */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Logo
</CardTitle>
<CardDescription>
Logo affiché en haut de l'attestation
</CardDescription>
</CardHeader>
<CardContent>
{config?.logoUrl ? (
<div className="space-y-4">
<img
src={config.logoUrl}
alt="Logo"
className="max-w-full h-auto border rounded"
/>
<Button type="button" variant="outline" size="sm">
Changer le logo
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucun logo configuré
</p>
<Button type="button" variant="outline" size="sm">
Uploader un logo
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Signature
</CardTitle>
<CardDescription>
Signature affichée en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent>
{config?.signatureUrl ? (
<div className="space-y-4">
<img
src={config.signatureUrl}
alt="Signature"
className="max-w-full h-auto border rounded"
/>
<Button type="button" variant="outline" size="sm">
Changer la signature
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<FileText className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Aucune signature configurée
</p>
<Button type="button" variant="outline" size="sm">
Uploader une signature
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Informations du signataire */}
<Card>
<CardHeader>
<CardTitle>Informations du signataire</CardTitle>
<CardDescription>
Nom et fonction affichés en bas de l'attestation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-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>
</div>
</CardContent>
</Card>
{/* Texte de l'attestation */}
<Card>
<CardHeader>
<CardTitle>Texte de l'attestation</CardTitle>
<CardDescription>
Personnalisez le texte principal de l'attestation. Variables disponibles :
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} nomComplet {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} nomFormation {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} dateDebut {"}"}{"}"}
</code>
<code className="ml-2 text-xs bg-muted px-2 py-1 rounded">
{"{"}{"{"} dateFin {"}"}{"}"}
</code>
</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}
/>
</CardContent>
</Card>
{/* Bouton de sauvegarde */}
<div className="flex justify-end">
<Button type="submit" disabled={updateConfig.isPending}>
<Save className="h-4 w-4 mr-2" />
{updateConfig.isPending ? "Enregistrement..." : "Enregistrer la configuration"}
</Button>
</div>
</form>
</div>
</DashboardLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1765830109012,
"tag": "0023_jittery_gorgon",
"breakpoints": true
},
{
"idx": 24,
"version": "5",
"when": 1767770858914,
"tag": "0024_panoramic_goliath",
"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,283 @@
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))
.orderBy(datesFormation.dateDebut);
// 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 {
// Logo (si configuré)
if (config?.logoUrl) {
// TODO: télécharger et insérer le logo
// doc.image(logoPath, 50, 45, { width: 100 });
}
// 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(1);
if (config?.signatureUrl) {
// TODO: télécharger et insérer la signature
// doc.image(signaturePath, 400, doc.y, { width: 100 });
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))
.orderBy(attestations.dateGeneration);
}
/**
* 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,105 @@ export const appRouter = router({
});
}),
}),
// ===== ATTESTATIONS =====
attestations: router({
// Générer une attestation pour une inscription
generer: adminProcedure
.input(z.object({ inscriptionId: z.number() }))
.mutation(async ({ input }) => {
const attestationService = await import("./attestationService");
const { getDb } = await import("./db");
const { inscriptions, apprenants, sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Base de données non disponible");
// Récupérer les infos de l'inscription
const [inscriptionData] = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
sequence: sequences,
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.where(eq(inscriptions.id, input.inscriptionId))
.limit(1);
if (!inscriptionData) {
throw new Error("Inscription introuvable");
}
// Vérifier si une attestation existe déjà
const existingAttestation = await attestationService.getAttestation(
inscriptionData.apprenant.id,
inscriptionData.sequence.id
);
if (existingAttestation) {
return existingAttestation;
}
// Générer le PDF
const { s3Key, pdfUrl } = await attestationService.genererAttestationPDF(input.inscriptionId);
// Enregistrer dans la base
await attestationService.enregistrerAttestation(
input.inscriptionId,
inscriptionData.apprenant.id,
inscriptionData.sequence.id,
s3Key,
pdfUrl
);
return { s3Key, pdfUrl };
}),
// 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 attestationService = await import("./attestationService");
return attestationService.getAttestation(input.apprenantId, input.sequenceId);
}),
// Récupérer toutes les attestations d'un apprenant
listByApprenant: publicProcedure
.input(z.object({ apprenantId: z.number() }))
.query(async ({ input }) => {
const attestationService = await import("./attestationService");
return attestationService.getAttestationsApprenant(input.apprenantId);
}),
// Configuration des attestations
getConfig: adminProcedure
.query(async () => {
const attestationService = await import("./attestationService");
return attestationService.getOrCreateConfigAttestation();
}),
updateConfig: adminProcedure
.input(z.object({
logoS3Key: z.string().optional(),
logoUrl: z.string().optional(),
signatureS3Key: z.string().optional(),
signatureUrl: z.string().optional(),
nomSignataire: z.string().optional(),
fonctionSignataire: z.string().optional(),
texteAttestation: z.string().optional(),
}))
.mutation(async ({ input }) => {
const attestationService = await import("./attestationService");
await attestationService.updateConfigAttestation(input);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;

20
todo.md
View File

@@ -442,3 +442,23 @@
- [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
## Attestations de formation et Espace apprenant (16/12/2025)
- [x] Système d'attestations de formation automatiques (Phase 1)
- [x] Table attestations en base de données
- [x] Génération PDF avec template personnalisable
- [x] Service backend de génération PDF (attestationService.ts)
- [x] Procédures tRPC pour les attestations
- [x] Page de configuration des attestations
- [x] Stockage S3 des PDF générés
- [ ] Upload logo et signature pour les attestations (Phase 2)
- [ ] Génération automatique après validation de présence (Phase 2)
- [ ] Envoi automatique par email (Phase 2)
- [ ] Espace apprenant personnalisé (Phase 2)
- [ ] Système d'authentification apprenant (email/mot de passe)
- [ ] Page de connexion apprenant
- [ ] Tableau de bord apprenant
- [ ] Historique des formations suivies
- [ ] Téléchargement des attestations
- [ ] Téléchargement des supports de formation
- [ ] Réinscription rapide aux nouvelles sessions