diff --git a/.manus/db/db-query-1765830115158.json b/.manus/db/db-query-1765830115158.json new file mode 100644 index 0000000..f637344 --- /dev/null +++ b/.manus/db/db-query-1765830115158.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE IF NOT EXISTS `logsNotifications` (\n `id` int AUTO_INCREMENT NOT NULL,\n `type` enum('remerciement','notification_formateur_inscription','notification_formateur_annulation','alerte_capacite','notification_liste_attente') NOT NULL,\n `sequenceId` int,\n `apprenantId` int,\n `formateurId` int,\n `emailDestinataire` varchar(320) NOT NULL,\n `sujet` varchar(500) NOT NULL,\n `dateEnvoi` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `statut` enum('success','failed') NOT NULL,\n `messageErreur` text,\n `metadata` text,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `logsNotifications_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 `logsNotifications` (\n `id` int AUTO_INCREMENT NOT NULL,\n `type` enum('remerciement','notification_formateur_inscription','notification_formateur_annulation','alerte_capacite','notification_liste_attente') NOT NULL,\n `sequenceId` int,\n `apprenantId` int,\n `formateurId` int,\n `emailDestinataire` varchar(320) NOT NULL,\n `sujet` varchar(500) NOT NULL,\n `dateEnvoi` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n `statut` enum('success','failed') NOT NULL,\n `messageErreur` text,\n `metadata` text,\n `createdAt` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n CONSTRAINT `logsNotifications_id` PRIMARY KEY(`id`)\n);", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 273 +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 0dbf97c..561de3f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -27,6 +27,8 @@ import AdminQuestionnaireAnalyse from "./pages/AdminQuestionnaireAnalyse"; import AdminQuestionnaireSuivi from "./pages/AdminQuestionnaireSuivi"; 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 QuestionnaireReponse from "./pages/QuestionnaireReponse"; import Inscription from "./pages/Inscription"; import Login from "./pages/Login"; @@ -49,6 +51,8 @@ function Router() { + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 730d01c..b9026e7 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -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 } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -47,6 +47,7 @@ const menuSections = [ title: "Configuration", items: [ { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, + { icon: User, label: "Formateurs", path: "/admin/formateurs" }, { icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" }, { icon: Bell, label: "Rappels", path: "/admin/rappels", subItems: [ { label: "Historique", path: "/admin/rappels/historique" }, @@ -55,6 +56,7 @@ const menuSections = [ { icon: ClipboardList, label: "Questionnaires", path: "/admin/questionnaires" }, { 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" }, ] }, { diff --git a/client/src/pages/admin/AdminFormateurs.tsx b/client/src/pages/admin/AdminFormateurs.tsx new file mode 100644 index 0000000..19c3739 --- /dev/null +++ b/client/src/pages/admin/AdminFormateurs.tsx @@ -0,0 +1,422 @@ +import { useState } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { Plus, Pencil, Trash2, Mail, User, AlertCircle, CheckCircle2 } from "lucide-react"; + +export default function AdminFormateurs() { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedFormateur, setSelectedFormateur] = useState<{ + id: number; + nom: string; + email: string | null; + } | null>(null); + const [formData, setFormData] = useState({ + nom: "", + email: "", + }); + + const { data: formateurs, isLoading, refetch } = trpc.formateurs.list.useQuery(); + const createMutation = trpc.formateurs.create.useMutation({ + onSuccess: () => { + toast.success("Formateur créé avec succès"); + setIsCreateDialogOpen(false); + setFormData({ nom: "", email: "" }); + refetch(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + const updateMutation = trpc.formateurs.update.useMutation({ + onSuccess: () => { + toast.success("Formateur mis à jour avec succès"); + setIsEditDialogOpen(false); + setSelectedFormateur(null); + refetch(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + const deleteMutation = trpc.formateurs.delete.useMutation({ + onSuccess: () => { + toast.success("Formateur supprimé avec succès"); + setIsDeleteDialogOpen(false); + setSelectedFormateur(null); + refetch(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + + const handleCreate = () => { + if (!formData.nom.trim()) { + toast.error("Le nom est obligatoire"); + return; + } + createMutation.mutate({ + nom: formData.nom.trim(), + email: formData.email.trim() || undefined, + }); + }; + + const handleEdit = () => { + if (!selectedFormateur) return; + if (!formData.nom.trim()) { + toast.error("Le nom est obligatoire"); + return; + } + updateMutation.mutate({ + id: selectedFormateur.id, + nom: formData.nom.trim(), + email: formData.email.trim() || null, + }); + }; + + const handleDelete = () => { + if (!selectedFormateur) return; + deleteMutation.mutate({ id: selectedFormateur.id }); + }; + + const openEditDialog = (formateur: { id: number; nom: string; email: string | null }) => { + setSelectedFormateur(formateur); + setFormData({ + nom: formateur.nom, + email: formateur.email || "", + }); + setIsEditDialogOpen(true); + }; + + const openDeleteDialog = (formateur: { id: number; nom: string; email: string | null }) => { + setSelectedFormateur(formateur); + setIsDeleteDialogOpen(true); + }; + + const formateursSansEmail = formateurs?.filter((f) => !f.email) || []; + const formateursAvecEmail = formateurs?.filter((f) => f.email) || []; + + return ( + +
+
+
+

Gestion des Formateurs

+

+ Gérez les formateurs et leurs adresses email pour les notifications +

+
+ +
+ + {/* Alerte si des formateurs n'ont pas d'email */} + {formateursSansEmail.length > 0 && ( + + + + + Formateurs sans email ({formateursSansEmail.length}) + + + Ces formateurs ne recevront pas les notifications d'inscription et d'annulation. + Ajoutez leur adresse email pour activer les notifications. + + + +
+ {formateursSansEmail.map((f) => ( + openEditDialog(f)} + > + {f.nom} + + ))} +
+
+
+ )} + + {/* Statistiques */} +
+ + + + Total formateurs + + + +
+ + {formateurs?.length || 0} +
+
+
+ + + + Avec email configuré + + + +
+ + {formateursAvecEmail.length} +
+
+
+ + + + Sans email (pas de notifications) + + + +
+ + {formateursSansEmail.length} +
+
+
+
+ + {/* Tableau des formateurs */} + + + Liste des formateurs + + Cliquez sur un formateur pour modifier ses informations + + + + {isLoading ? ( +
+ Chargement... +
+ ) : formateurs?.length === 0 ? ( +
+ Aucun formateur enregistré +
+ ) : ( + + + + Nom + Email + Notifications + Actions + + + + {formateurs?.map((formateur) => ( + + {formateur.nom} + + {formateur.email ? ( +
+ + {formateur.email} +
+ ) : ( + Non renseigné + )} +
+ + {formateur.email ? ( + + + Activées + + ) : ( + + + Désactivées + + )} + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+ + {/* Dialog de création */} + + + + Ajouter un formateur + + Renseignez les informations du nouveau formateur. L'email est optionnel mais + nécessaire pour recevoir les notifications. + + +
+
+ + setFormData({ ...formData, nom: e.target.value })} + placeholder="Jean Dupont" + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="jean.dupont@exemple.fr" + /> +

+ L'email permet de recevoir les notifications d'inscription et d'annulation. +

+
+
+ + + + +
+
+ + {/* Dialog de modification */} + + + + Modifier le formateur + + Modifiez les informations du formateur. L'email est nécessaire pour recevoir + les notifications. + + +
+
+ + setFormData({ ...formData, nom: e.target.value })} + placeholder="Jean Dupont" + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="jean.dupont@exemple.fr" + /> +

+ Sans email, le formateur ne recevra pas les notifications d'inscription et + d'annulation. +

+
+
+ + + + +
+
+ + {/* Dialog de suppression */} + + + + Supprimer le formateur ? + + Êtes-vous sûr de vouloir supprimer le formateur "{selectedFormateur?.nom}" ? + Cette action est irréversible. + + + + Annuler + + Supprimer + + + + +
+
+ ); +} diff --git a/client/src/pages/admin/AdminNotifications.tsx b/client/src/pages/admin/AdminNotifications.tsx new file mode 100644 index 0000000..128ff63 --- /dev/null +++ b/client/src/pages/admin/AdminNotifications.tsx @@ -0,0 +1,497 @@ +import { useState } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { + Mail, + CheckCircle2, + XCircle, + TrendingUp, + Send, + AlertCircle, + Filter, + RefreshCw, + BarChart3, +} from "lucide-react"; + +const notificationTypeLabels: Record = { + remerciement: "Remerciement post-formation", + notification_formateur_inscription: "Notification formateur (inscription)", + notification_formateur_annulation: "Notification formateur (annulation)", + alerte_capacite: "Alerte capacité atteinte", + notification_liste_attente: "Notification liste d'attente", +}; + +const notificationTypeColors: Record = { + remerciement: "bg-blue-100 text-blue-800", + notification_formateur_inscription: "bg-green-100 text-green-800", + notification_formateur_annulation: "bg-orange-100 text-orange-800", + alerte_capacite: "bg-red-100 text-red-800", + notification_liste_attente: "bg-purple-100 text-purple-800", +}; + +export default function AdminNotifications() { + const [filters, setFilters] = useState({ + type: "", + statut: "", + email: "", + dateDebut: "", + dateFin: "", + }); + const [page, setPage] = useState(0); + const limit = 20; + + const { data: historiqueData, isLoading: isLoadingHistorique, refetch } = trpc.notifications.historique.useQuery({ + type: filters.type as any || undefined, + statut: filters.statut as any || undefined, + email: filters.email || undefined, + dateDebut: filters.dateDebut || undefined, + dateFin: filters.dateFin || undefined, + limit, + offset: page * limit, + }); + + const { data: statsData, isLoading: isLoadingStats } = trpc.notifications.statistiques.useQuery({ + dateDebut: filters.dateDebut || undefined, + dateFin: filters.dateFin || undefined, + }); + + const totalPages = Math.ceil((historiqueData?.total || 0) / limit); + + const handleFilterChange = (key: string, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value })); + setPage(0); + }; + + const resetFilters = () => { + setFilters({ + type: "", + statut: "", + email: "", + dateDebut: "", + dateFin: "", + }); + setPage(0); + }; + + return ( + +
+
+
+

Tableau de bord des Notifications

+

+ Historique et statistiques de tous les emails envoyés +

+
+ +
+ + + + + + Historique + + + + Statistiques + + + + + {isLoadingStats ? ( +
Chargement...
+ ) : statsData ? ( + <> + {/* Cartes de statistiques globales */} +
+ + + + Total envoyés + + + +
+ + {statsData.global.total} +
+
+
+ + + + Réussis + + + +
+ + {statsData.global.success} +
+
+
+ + + + Échoués + + + +
+ + {statsData.global.failed} +
+
+
+ + + + Taux de succès + + + +
+ + {statsData.global.tauxSucces}% +
+
+
+
+ + {/* Statistiques par type */} + + + Répartition par type de notification + + Nombre d'emails envoyés par catégorie + + + + + + + Type + Total + Réussis + Échoués + Taux de succès + + + + {statsData.parType.map((stat) => ( + + + + {notificationTypeLabels[stat.type] || stat.type} + + + {stat.total} + {stat.success} + {stat.failed} + + {stat.total > 0 ? Math.round((stat.success / stat.total) * 100) : 0}% + + + ))} + {statsData.parType.length === 0 && ( + + + Aucune donnée disponible + + + )} + +
+
+
+ + {/* Évolution par jour */} + + + Évolution des envois + + Nombre d'emails envoyés par jour + + + + + + + Date + Total + Réussis + Échoués + + + + {statsData.evolutionParJour.map((jour) => ( + + + {format(new Date(jour.date), "EEEE d MMMM yyyy", { locale: fr })} + + {jour.total} + {jour.success} + {jour.failed} + + ))} + {statsData.evolutionParJour.length === 0 && ( + + + Aucune donnée disponible + + + )} + +
+
+
+ + ) : ( +
+ Aucune statistique disponible +
+ )} +
+ + + {/* Filtres */} + + + + + Filtres + + + +
+
+ + +
+
+ + +
+
+ + handleFilterChange("email", e.target.value)} + /> +
+
+ + handleFilterChange("dateDebut", e.target.value)} + /> +
+
+ + handleFilterChange("dateFin", e.target.value)} + /> +
+
+
+ +
+
+
+ + {/* Tableau historique */} + + + Historique des envois + + {historiqueData?.total || 0} notification(s) trouvée(s) + + + + {isLoadingHistorique ? ( +
Chargement...
+ ) : historiqueData?.logs.length === 0 ? ( +
+ Aucune notification trouvée +
+ ) : ( + <> + + + + Date + Type + Destinataire + Sujet + Formation / Séquence + Statut + + + + {historiqueData?.logs.map((log) => ( + + + {format(new Date(log.dateEnvoi), "dd/MM/yyyy HH:mm", { locale: fr })} + + + + {notificationTypeLabels[log.type] || log.type} + + + +
+ {log.emailDestinataire} + {log.apprenantNom && log.apprenantPrenom && ( + + {log.apprenantPrenom} {log.apprenantNom} + + )} + {log.formateurNom && ( + + Formateur: {log.formateurNom} + + )} +
+
+ + {log.sujet} + + + {log.formationNom && log.sequenceNom ? ( +
+ {log.formationNom} + + {log.sequenceNom} + +
+ ) : ( + - + )} +
+ + {log.statut === "success" ? ( + + + Envoyé + + ) : ( +
+ + + Échec + + {log.messageErreur && ( + + {log.messageErreur.substring(0, 50)}... + + )} +
+ )} +
+
+ ))} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page + 1} sur {totalPages} +

+
+ + +
+
+ )} + + )} +
+
+
+
+
+
+ ); +} diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000..10cfbe3 --- /dev/null +++ b/drizzle/meta/0023_snapshot.json @@ -0,0 +1,1848 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e0c120cb-533e-435d-a5d7-a6a6c58698cb", + "prevId": "528b4ca6-4e50-47ac-ba30-41705f00139b", + "tables": { + "alertes": { + "name": "alertes", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('taux_remplissage','seuil_inscriptions')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valeurSeuil": { + "name": "valeurSeuil", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailsDestinataires": { + "name": "emailsDestinataires", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "derniereVerification": { + "name": "derniereVerification", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dernierEnvoi": { + "name": "dernierEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alertes_id": { + "name": "alertes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailConfig": { + "name": "emailConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fromEmail": { + "name": "fromEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromName": { + "name": "fromName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "mode": { + "name": "mode", + "type": "enum('simulation','production')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'simulation'" + }, + "domainVerified": { + "name": "domainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "smtpHost": { + "name": "smtpHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpPort": { + "name": "smtpPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpSecure": { + "name": "smtpSecure", + "type": "enum('none','tls','ssl')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "smtpUser": { + "name": "smtpUser", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpPassword": { + "name": "smtpPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailConfig_id": { + "name": "emailConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailTemplates": { + "name": "emailTemplates", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primaryColor": { + "name": "primaryColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerBgColor": { + "name": "headerBgColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerTextColor": { + "name": "headerTextColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "headerTitle": { + "name": "headerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "footerText": { + "name": "footerText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bodyContent": { + "name": "bodyContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailTemplates_id": { + "name": "emailTemplates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "emailTemplates_type_unique": { + "name": "emailTemplates_type_unique", + "columns": [ + "type" + ] + } + }, + "checkConstraint": {} + }, + "envoisQuestionnaires": { + "name": "envoisQuestionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEnvoi": { + "name": "dateEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateReponse": { + "name": "dateReponse", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "envoisQuestionnaires_id": { + "name": "envoisQuestionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "envoisQuestionnaires_token_unique": { + "name": "envoisQuestionnaires_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "formateurs": { + "name": "formateurs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formateurs_id": { + "name": "formateurs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "logsNotifications": { + "name": "logsNotifications", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "enum('remerciement','notification_formateur_inscription','notification_formateur_annulation','alerte_capacite','notification_liste_attente')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "formateurId": { + "name": "formateurId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailDestinataire": { + "name": "emailDestinataire", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sujet": { + "name": "sujet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEnvoi": { + "name": "dateEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "statut": { + "name": "statut", + "type": "enum('success','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messageErreur": { + "name": "messageErreur", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "logsNotifications_id": { + "name": "logsNotifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "logsRappels": { + "name": "logsRappels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "rappelId": { + "name": "rappelId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailDestinataire": { + "name": "emailDestinataire", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeRappel": { + "name": "typeRappel", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEnvoi": { + "name": "dateEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "statut": { + "name": "statut", + "type": "enum('success','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messageErreur": { + "name": "messageErreur", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateSequence": { + "name": "dateSequence", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nbTentatives": { + "name": "nbTentatives", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "prochainEssai": { + "name": "prochainEssai", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "logsRappels_id": { + "name": "logsRappels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "passwordResetTokens": { + "name": "passwordResetTokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "passwordResetTokens_id": { + "name": "passwordResetTokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "passwordResetTokens_token_unique": { + "name": "passwordResetTokens_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "questionnaires": { + "name": "questionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('satisfaction','evaluation_pre','evaluation_post')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "envoiAutomatique": { + "name": "envoiAutomatique", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "delaiEnvoiJours": { + "name": "delaiEnvoiJours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "questionnaires_id": { + "name": "questionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "texte": { + "name": "texte", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeQuestion": { + "name": "typeQuestion", + "type": "enum('choix_multiple','echelle','texte_libre','oui_non')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleMin": { + "name": "echelleMin", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleMax": { + "name": "echelleMax", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleLabelMin": { + "name": "echelleLabelMin", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleLabelMax": { + "name": "echelleLabelMax", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "obligatoire": { + "name": "obligatoire", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rappels": { + "name": "rappels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "templateType": { + "name": "templateType", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joursAvant": { + "name": "joursAvant", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heureEnvoi": { + "name": "heureEnvoi", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'09:00'" + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "derniereExecution": { + "name": "derniereExecution", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rappels_id": { + "name": "rappels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reponsesQuestionnaires": { + "name": "reponsesQuestionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateReponse": { + "name": "dateReponse", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reponsesQuestionnaires_id": { + "name": "reponsesQuestionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reponsesQuestions": { + "name": "reponsesQuestions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "reponseQuestionnaireId": { + "name": "reponseQuestionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "questionId": { + "name": "questionId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reponseNumerique": { + "name": "reponseNumerique", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponseTexte": { + "name": "reponseTexte", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reponsesQuestions_id": { + "name": "reponsesQuestions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publicCible": { + "name": "publicCible", + "type": "enum('directeur','chef_service','tous','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formateurId": { + "name": "formateurId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "supportsFormation": { + "name": "supportsFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formateurId": { + "name": "formateurId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nomFichier": { + "name": "nomFichier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeFichier": { + "name": "typeFichier", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tailleFichier": { + "name": "tailleFichier", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "urlFichier": { + "name": "urlFichier", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "supportsFormation_id": { + "name": "supportsFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 21576c5..15855a7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1765828479906, "tag": "0022_closed_lucky_pierre", "breakpoints": true + }, + { + "idx": 23, + "version": "5", + "when": 1765830109012, + "tag": "0023_jittery_gorgon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 30efa5b..844c290 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -482,3 +482,41 @@ export const logsRappels = mysqlTable("logsRappels", { export type LogRappel = typeof logsRappels.$inferSelect; export type InsertLogRappel = typeof logsRappels.$inferInsert; + +/** + * Table des logs des notifications envoyées + * Stocke l'historique de toutes les notifications (remerciements, alertes formateurs, etc.) + */ +export const logsNotifications = mysqlTable("logsNotifications", { + id: int("id").autoincrement().primaryKey(), + /** Type de notification (remerciement, notification_formateur_inscription, notification_formateur_annulation, alerte_capacite, notification_liste_attente) */ + type: mysqlEnum("type", [ + "remerciement", + "notification_formateur_inscription", + "notification_formateur_annulation", + "alerte_capacite", + "notification_liste_attente" + ]).notNull(), + /** ID de la séquence concernée (si applicable) */ + sequenceId: int("sequenceId"), + /** ID de l'apprenant concerné (si applicable) */ + apprenantId: int("apprenantId"), + /** ID du formateur concerné (si applicable) */ + formateurId: int("formateurId"), + /** Email du destinataire */ + emailDestinataire: varchar("emailDestinataire", { length: 320 }).notNull(), + /** Sujet de l'email */ + sujet: varchar("sujet", { length: 500 }).notNull(), + /** Date d'envoi */ + dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(), + /** Statut de l'envoi (success, failed) */ + statut: mysqlEnum("statut", ["success", "failed"]).notNull(), + /** Message d'erreur en cas d'échec */ + messageErreur: text("messageErreur"), + /** Informations supplémentaires (JSON) */ + metadata: text("metadata"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type LogNotification = typeof logsNotifications.$inferSelect; +export type InsertLogNotification = typeof logsNotifications.$inferInsert; diff --git a/server/notificationLogsDb.ts b/server/notificationLogsDb.ts new file mode 100644 index 0000000..e1f19a9 --- /dev/null +++ b/server/notificationLogsDb.ts @@ -0,0 +1,210 @@ +import { eq, desc, and, gte, lte, sql, like } from "drizzle-orm"; +import { getDb } from "./db"; +import { logsNotifications, sequences, apprenants, formateurs, formations } from "../drizzle/schema"; + +export type NotificationType = + | "remerciement" + | "notification_formateur_inscription" + | "notification_formateur_annulation" + | "alerte_capacite" + | "notification_liste_attente"; + +export interface LogNotificationInput { + type: NotificationType; + sequenceId?: number; + apprenantId?: number; + formateurId?: number; + emailDestinataire: string; + sujet: string; + statut: "success" | "failed"; + messageErreur?: string; + metadata?: Record; +} + +/** + * Enregistre un log de notification + */ +export async function logNotification(input: LogNotificationInput) { + const db = await getDb(); + if (!db) { + console.warn("[NotificationLogs] Database not available"); + return; + } + + try { + await db.insert(logsNotifications).values({ + type: input.type, + sequenceId: input.sequenceId || null, + apprenantId: input.apprenantId || null, + formateurId: input.formateurId || null, + emailDestinataire: input.emailDestinataire, + sujet: input.sujet, + statut: input.statut, + messageErreur: input.messageErreur || null, + metadata: input.metadata ? JSON.stringify(input.metadata) : null, + }); + console.log(`[NotificationLogs] Log créé: ${input.type} -> ${input.emailDestinataire} (${input.statut})`); + } catch (error) { + console.error("[NotificationLogs] Erreur lors de la création du log:", error); + } +} + +/** + * Récupère l'historique des notifications avec filtres + */ +export async function getNotificationLogs(filters?: { + type?: NotificationType; + dateDebut?: Date; + dateFin?: Date; + statut?: "success" | "failed"; + email?: string; + sequenceId?: number; + limit?: number; + offset?: number; +}) { + const db = await getDb(); + if (!db) return { logs: [], total: 0 }; + + const conditions = []; + + if (filters?.type) { + conditions.push(eq(logsNotifications.type, filters.type)); + } + if (filters?.dateDebut) { + conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut)); + } + if (filters?.dateFin) { + conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin)); + } + if (filters?.statut) { + conditions.push(eq(logsNotifications.statut, filters.statut)); + } + if (filters?.email) { + conditions.push(like(logsNotifications.emailDestinataire, `%${filters.email}%`)); + } + if (filters?.sequenceId) { + conditions.push(eq(logsNotifications.sequenceId, filters.sequenceId)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Récupérer les logs avec les informations associées + const logs = await db + .select({ + id: logsNotifications.id, + type: logsNotifications.type, + sequenceId: logsNotifications.sequenceId, + apprenantId: logsNotifications.apprenantId, + formateurId: logsNotifications.formateurId, + emailDestinataire: logsNotifications.emailDestinataire, + sujet: logsNotifications.sujet, + dateEnvoi: logsNotifications.dateEnvoi, + statut: logsNotifications.statut, + messageErreur: logsNotifications.messageErreur, + metadata: logsNotifications.metadata, + sequenceNom: sequences.nom, + formationNom: formations.nom, + apprenantNom: apprenants.nom, + apprenantPrenom: apprenants.prenom, + formateurNom: formateurs.nom, + }) + .from(logsNotifications) + .leftJoin(sequences, eq(logsNotifications.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(apprenants, eq(logsNotifications.apprenantId, apprenants.id)) + .leftJoin(formateurs, eq(logsNotifications.formateurId, formateurs.id)) + .where(whereClause) + .orderBy(desc(logsNotifications.dateEnvoi)) + .limit(filters?.limit || 50) + .offset(filters?.offset || 0); + + // Compter le total + const [countResult] = await db + .select({ count: sql`COUNT(*)` }) + .from(logsNotifications) + .where(whereClause); + + return { + logs, + total: countResult?.count || 0, + }; +} + +/** + * Récupère les statistiques des notifications + */ +export async function getNotificationStats(filters?: { + dateDebut?: Date; + dateFin?: Date; +}) { + const db = await getDb(); + if (!db) return null; + + const conditions = []; + if (filters?.dateDebut) { + conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut)); + } + if (filters?.dateFin) { + conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Statistiques globales + const [globalStats] = await db + .select({ + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause); + + // Statistiques par type + const statsByType = await db + .select({ + type: logsNotifications.type, + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause) + .groupBy(logsNotifications.type); + + // Évolution par jour (7 derniers jours) + const evolutionParJour = await db + .select({ + date: sql`DATE(dateEnvoi)`, + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause) + .groupBy(sql`DATE(dateEnvoi)`) + .orderBy(sql`DATE(dateEnvoi)`) + .limit(30); + + return { + global: { + total: globalStats?.total || 0, + success: globalStats?.success || 0, + failed: globalStats?.failed || 0, + tauxSucces: globalStats?.total ? Math.round((globalStats.success / globalStats.total) * 100) : 0, + }, + parType: statsByType, + evolutionParJour, + }; +} + +/** + * Labels pour les types de notifications + */ +export const notificationTypeLabels: Record = { + remerciement: "Remerciement post-formation", + notification_formateur_inscription: "Notification formateur (inscription)", + notification_formateur_annulation: "Notification formateur (annulation)", + alerte_capacite: "Alerte capacité atteinte", + notification_liste_attente: "Notification liste d'attente", +}; diff --git a/server/remerciementScheduler.ts b/server/remerciementScheduler.ts index ad39cb2..4b17b31 100644 --- a/server/remerciementScheduler.ts +++ b/server/remerciementScheduler.ts @@ -1,7 +1,82 @@ import { getDb } from "./db"; -import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema"; +import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs, questionnaires, envoisQuestionnaires } from "../drizzle/schema"; import { eq, and, lte, sql } from "drizzle-orm"; import { sendRemerciementPostFormation } from "./emailService"; +import { logNotification } from "./notificationLogsDb"; +import crypto from "crypto"; + +/** + * Génère un token unique pour accéder au questionnaire + */ +function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * Récupère ou crée un lien questionnaire pour un apprenant + */ +async function getOrCreateQuestionnaireLink( + apprenantId: number, + sequenceId: number +): Promise { + const db = await getDb(); + if (!db) return null; + + try { + // Chercher un questionnaire de satisfaction actif + const [questionnaire] = await db + .select() + .from(questionnaires) + .where( + and( + eq(questionnaires.type, "satisfaction"), + eq(questionnaires.actif, true) + ) + ) + .limit(1); + + if (!questionnaire) { + console.log("[Remerciements] Aucun questionnaire de satisfaction actif trouvé"); + return null; + } + + // Vérifier si un envoi existe déjà + const [envoiExistant] = await db + .select() + .from(envoisQuestionnaires) + .where( + and( + eq(envoisQuestionnaires.questionnaireId, questionnaire.id), + eq(envoisQuestionnaires.apprenantId, apprenantId), + eq(envoisQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + + if (envoiExistant) { + // Retourner le lien existant + const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000"; + return `${baseUrl}/questionnaire/${envoiExistant.token}`; + } + + // Créer un nouvel envoi + const token = generateToken(); + await db.insert(envoisQuestionnaires).values({ + questionnaireId: questionnaire.id, + apprenantId, + sequenceId, + token, + dateEnvoi: new Date(), + dateReponse: null, + }); + + const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000"; + return `${baseUrl}/questionnaire/${token}`; + } catch (error) { + console.error("[Remerciements] Erreur lors de la création du lien questionnaire:", error); + return null; + } +} /** * Table pour suivre les remerciements déjà envoyés @@ -111,6 +186,9 @@ async function processRemerciementSequence( } try { + // Générer le lien questionnaire + const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequence.id); + await sendRemerciementPostFormation({ apprenantEmail: apprenant.email, apprenantNom: apprenant.nom, @@ -119,12 +197,35 @@ async function processRemerciementSequence( formationNom: formation.nom, sequenceNom: sequence.nom, formateurNom: formateur?.nom, + lienQuestionnaire: lienQuestionnaire || undefined, + }); + + // Logger la notification + await logNotification({ + type: "remerciement", + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "success", + metadata: { lienQuestionnaire }, }); marquerRemerciementEnvoye(inscription.id, sequence.id); envoyesCount++; console.log(`[Remerciements] Email envoyé à ${apprenant.email}`); - } catch (error) { + } catch (error: any) { + // Logger l'échec + await logNotification({ + type: "remerciement", + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "failed", + messageErreur: error.message, + }); + errorsCount++; console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); } @@ -189,6 +290,9 @@ export async function envoyerRemerciementsSequence(sequenceId: number): Promise< for (const { inscription, apprenant } of inscriptionsConfirmees) { try { + // Générer le lien questionnaire + const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequenceId); + await sendRemerciementPostFormation({ apprenantEmail: apprenant.email, apprenantNom: apprenant.nom, @@ -197,9 +301,33 @@ export async function envoyerRemerciementsSequence(sequenceId: number): Promise< formationNom: formation.nom, sequenceNom: sequence.nom, formateurNom: formateur?.nom, + lienQuestionnaire: lienQuestionnaire || undefined, }); + + // Logger la notification + await logNotification({ + type: "remerciement", + sequenceId, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "success", + metadata: { lienQuestionnaire }, + }); + sent++; - } catch (error) { + } catch (error: any) { + // Logger l'échec + await logNotification({ + type: "remerciement", + sequenceId, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "failed", + messageErreur: error.message, + }); + console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); failed++; } diff --git a/server/routers.ts b/server/routers.ts index f71309e..d6dffb0 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -9,6 +9,7 @@ import * as analyticsDb from "./analyticsDb"; import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService"; import { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService"; +import { logNotification } from "./notificationLogsDb"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; @@ -226,6 +227,7 @@ export const appRouter = router({ create: adminProcedure.input(z.object({ nom: z.string().min(1), + email: z.string().email().optional(), })).mutation(async ({ input }) => { await db.createFormateur(input); return { success: true }; @@ -234,6 +236,7 @@ export const appRouter = router({ update: adminProcedure.input(z.object({ id: z.number(), nom: z.string().min(1).optional(), + email: z.string().email().nullable().optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; await db.updateFormateur(id, data); @@ -487,8 +490,29 @@ export const appRouter = router({ ordre: d.ordre, })), }); + // Logger la notification + await logNotification({ + type: "notification_formateur_inscription", + sequenceId: input.sequenceId, + apprenantId: inscriptionApprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`, + statut: "success", + }); console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`); - } catch (e) { + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_formateur_inscription", + sequenceId: input.sequenceId, + apprenantId: inscriptionApprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`, + statut: "failed", + messageErreur: e.message, + }); console.error(`[Notification] Erreur envoi email formateur:`, e); } } @@ -582,8 +606,29 @@ export const appRouter = router({ nbInscrits: nbInscritsApres, capaciteMax: sequence.capaciteMax, }); + // Logger la notification + await logNotification({ + type: "notification_formateur_annulation", + sequenceId: input.sequenceId, + apprenantId: apprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Annulation d'inscription - ${formation.nom}`, + statut: "success", + }); console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`); - } catch (e) { + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_formateur_annulation", + sequenceId: input.sequenceId, + apprenantId: apprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Annulation d'inscription - ${formation.nom}`, + statut: "failed", + messageErreur: e.message, + }); console.error(`[Notification] Erreur envoi email formateur:`, e); } } @@ -607,8 +652,27 @@ export const appRouter = router({ positionListeAttente: 1, delaiReponse: 48, }); + // Logger la notification + await logNotification({ + type: "notification_liste_attente", + sequenceId: input.sequenceId, + apprenantId: premierEnAttente.apprenant.id, + emailDestinataire: premierEnAttente.apprenant.email, + sujet: `Place disponible - ${formation.nom}`, + statut: "success", + }); console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`); - } catch (e) { + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_liste_attente", + sequenceId: input.sequenceId, + apprenantId: premierEnAttente.apprenant.id, + emailDestinataire: premierEnAttente.apprenant.email, + sujet: `Place disponible - ${formation.nom}`, + statut: "failed", + messageErreur: e.message, + }); console.error(`[Notification] Erreur envoi email liste d'attente:`, e); } } @@ -1631,6 +1695,46 @@ export const appRouter = router({ await processRemerciementsAutomatiques(); return { success: true }; }), + + // Historique des notifications + historique: adminProcedure + .input(z.object({ + type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(), + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + statut: z.enum(["success", "failed"]).optional(), + email: z.string().optional(), + sequenceId: z.number().optional(), + limit: z.number().default(50), + offset: z.number().default(0), + })) + .query(async ({ input }) => { + const { getNotificationLogs } = await import("./notificationLogsDb"); + return getNotificationLogs({ + type: input.type, + dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, + dateFin: input.dateFin ? new Date(input.dateFin) : undefined, + statut: input.statut, + email: input.email, + sequenceId: input.sequenceId, + limit: input.limit, + offset: input.offset, + }); + }), + + // Statistiques des notifications + statistiques: adminProcedure + .input(z.object({ + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + })) + .query(async ({ input }) => { + const { getNotificationStats } = await import("./notificationLogsDb"); + return getNotificationStats({ + dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, + dateFin: input.dateFin ? new Date(input.dateFin) : undefined, + }); + }), }), }); diff --git a/todo.md b/todo.md index 5f39971..ffbb0f5 100644 --- a/todo.md +++ b/todo.md @@ -437,3 +437,8 @@ - [x] Ajout du champ email pour les formateurs - [x] Scheduler automatique pour les remerciements post-formation - [x] Procédure tRPC pour envoi manuel des remerciements + +## Améliorations notifications (15/12/2025) +- [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