-
💡 Information
-
- Après avoir modifié l'URL publique, les nouveaux QR codes générés
- utiliseront automatiquement cette URL. Les QR codes déjà générés
- resteront inchangés.
+
+
+
+
+
Paramètres de l'application
+
+ Configuration globale de l'application
-
-
+
+
+
+ {/* Configuration générale */}
+
+
+ Configuration générale
+
+ Paramètres globaux de l'application
+
+
+
+
+
+
+
+ {/* Historique des modifications */}
+
+
+
+
+ Historique des modifications
+
+
+ Traçabilité des changements de paramètres
+
+
+
+ {isLoadingHistorique ? (
+
+
+
+ ) : !historique || historique.length === 0 ? (
+
+ Aucune modification enregistrée
+
+ ) : (
+
+
+
+
+ Date
+ Utilisateur
+ Champ modifié
+ Ancienne valeur
+ Nouvelle valeur
+
+
+
+ {historique.map((entry) => (
+
+
+ {new Date(entry.dateModification).toLocaleString('fr-FR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ {entry.userName}
+
+
+ {entry.champModifie}
+
+
+
+ {entry.ancienneValeur || vide}
+
+
+ {entry.nouvelleValeur || vide}
+
+
+ ))}
+
+
+
+ )}
+
+
+
);
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 71ae325..d7e7123 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,4 +1,4 @@
-import { boolean, datetime, int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
+import { boolean, int, mysqlEnum, mysqlTable, text, timestamp, varchar, datetime, decimal } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -33,6 +33,20 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
+/**
+ * Table des tokens de réinitialisation de mot de passe
+ */
+export const passwordResetTokens = mysqlTable("passwordResetTokens", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ token: varchar("token", { length: 255 }).notNull().unique(),
+ expiresAt: timestamp("expiresAt").notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
+export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
+
/**
* Table des formations (ex: Manager Itinova)
*/
@@ -51,44 +65,23 @@ export type Formation = typeof formations.$inferSelect;
export type InsertFormation = typeof formations.$inferInsert;
/**
- * Table des apprenants
- */
-export const apprenants = mysqlTable("apprenants", {
- id: int("id").autoincrement().primaryKey(),
- nom: varchar("nom", { length: 255 }).notNull(),
- prenom: varchar("prenom", { length: 255 }).notNull(),
- email: varchar("email", { length: 320 }).notNull().unique(),
- codeEtablissement: varchar("codeEtablissement", { length: 50 }).notNull(),
- fonction: mysqlEnum("fonction", ["directeur", "chef_service", "autre"]).notNull(),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
-});
-
-export type Apprenant = typeof apprenants.$inferSelect;
-export type InsertApprenant = typeof apprenants.$inferInsert;
-
-/**
- * Table des séquences de formation
- * Une séquence peut contenir jusqu'à 4 dates de formation
+ * Table des séquences de formation (ex: Groupe A, Groupe B)
+ * Chaque séquence peut avoir jusqu'à 4 dates de formation
*/
export const sequences = mysqlTable("sequences", {
id: int("id").autoincrement().primaryKey(),
formationId: int("formationId").notNull(),
nom: varchar("nom", { length: 255 }).notNull(),
- /** Lieu de la formation */
- lieu: varchar("lieu", { length: 500 }).notNull(),
- /** Public cible de la séquence (Directeur, Chef de service, Tous, Autre) */
- publicCible: mysqlEnum("publicCible", ["directeur", "chef_service", "tous", "autre"]).notNull(),
+ lieu: varchar("lieu", { length: 255 }).notNull(),
+ capaciteMax: int("capaciteMax").notNull(),
+ dateBloquage: datetime("dateBloquage"),
+ statut: mysqlEnum("statut", ["ouverte", "fermee", "annulee"]).default("ouverte").notNull(),
+ /** Public cible de la séquence (Directeur, Chef de service, Autre, Tous) */
+ publicCible: mysqlEnum("publicCible", ["directeur", "chef_service", "autre", "tous"]).default("tous").notNull(),
/** ID du formateur assigné à cette séquence */
formateurId: int("formateurId"),
- /** Capacité maximale (12 par défaut) */
- capaciteMax: int("capaciteMax").default(12).notNull(),
- /** Date de blocage des inscriptions (J-15 avant la première date) */
- dateBlocage: datetime("dateBlocage").notNull(),
- /** Statut de la séquence */
- statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee"]).default("ouverte").notNull(),
/** Token unique pour le QR code d'émargement */
- qrCodeToken: varchar("qrCodeToken", { length: 64 }).unique(),
+ qrCodeToken: varchar("qrCodeToken", { length: 100 }).unique(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -103,30 +96,45 @@ export type InsertSequence = typeof sequences.$inferInsert;
export const datesFormation = mysqlTable("datesFormation", {
id: int("id").autoincrement().primaryKey(),
sequenceId: int("sequenceId").notNull(),
- /** Date de début de cette journée de formation */
- dateDebut: datetime("dateDebut").notNull(),
- /** Date de fin de cette journée de formation */
- dateFin: datetime("dateFin").notNull(),
- /** Ordre de la date dans la séquence (1 à 4) */
+ /** Ordre de la date (1, 2, 3 ou 4) */
ordre: int("ordre").notNull(),
+ dateDebut: datetime("dateDebut").notNull(),
+ dateFin: datetime("dateFin").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type DateFormation = typeof datesFormation.$inferSelect;
export type InsertDateFormation = typeof datesFormation.$inferInsert;
+/**
+ * Table des apprenants
+ */
+export const apprenants = mysqlTable("apprenants", {
+ id: int("id").autoincrement().primaryKey(),
+ nom: varchar("nom", { length: 255 }).notNull(),
+ prenom: varchar("prenom", { length: 255 }).notNull(),
+ email: varchar("email", { length: 320 }).notNull().unique(),
+ codeEtablissement: varchar("codeEtablissement", { length: 50 }).notNull(),
+ /** Fonction de l'apprenant (Directeur, Chef de service, Autre) */
+ fonction: mysqlEnum("fonction", ["directeur", "chef_service", "autre"]).default("autre").notNull(),
+ /** Statut du compte (actif/inactif) */
+ isActive: boolean("isActive").default(true).notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type Apprenant = typeof apprenants.$inferSelect;
+export type InsertApprenant = typeof apprenants.$inferInsert;
+
/**
* Table des inscriptions
- * L'inscription à une séquence inscrit automatiquement à toutes ses dates
*/
export const inscriptions = mysqlTable("inscriptions", {
id: int("id").autoincrement().primaryKey(),
- apprenantId: int("apprenantId").notNull(),
sequenceId: int("sequenceId").notNull(),
- /** Statut de l'inscription */
- statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).notNull(),
+ apprenantId: int("apprenantId").notNull(),
+ statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(),
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -134,87 +142,30 @@ export type Inscription = typeof inscriptions.$inferSelect;
export type InsertInscription = typeof inscriptions.$inferInsert;
/**
- * Table des tokens de réinitialisation de mot de passe
- * Stocke les tokens temporaires générés pour la réinitialisation
- */
-export const passwordResetTokens = mysqlTable("passwordResetTokens", {
- id: int("id").autoincrement().primaryKey(),
- userId: int("userId").notNull(),
- /** Token unique généré pour la réinitialisation */
- token: varchar("token", { length: 255 }).notNull().unique(),
- /** Date d'expiration du token (24h après génération) */
- expiresAt: timestamp("expiresAt").notNull(),
- /** Indique si le token a été utilisé */
- used: boolean("used").default(false).notNull(),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
-});
-
-export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
-export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
-
-/**
- * Table des templates d'emails personnalisables
- */
-export const emailTemplates = mysqlTable("emailTemplates", {
- id: int("id").autoincrement().primaryKey(),
- /** Type de template (inscription, teaser, rappel, reset_password) */
- type: varchar("type", { length: 50 }).notNull().unique(),
- /** Nom affiché du template */
- name: varchar("name", { length: 255 }).notNull(),
- /** URL du logo (peut être vide pour utiliser le logo par défaut) */
- logoUrl: text("logoUrl"),
- /** Couleur principale (hex) */
- primaryColor: varchar("primaryColor", { length: 7 }).default("#2563eb").notNull(),
- /** Couleur de fond de l'en-tête (hex) */
- headerBgColor: varchar("headerBgColor", { length: 7 }).default("#2563eb").notNull(),
- /** Couleur du texte de l'en-tête (hex) */
- headerTextColor: varchar("headerTextColor", { length: 7 }).default("#ffffff").notNull(),
- /** Titre de l'en-tête */
- headerTitle: varchar("headerTitle", { length: 255 }).default("Formation Manager Itinova").notNull(),
- /** Texte du pied de page */
- footerText: text("footerText"),
- /** Contenu du corps de l'email (HTML ou texte) */
- bodyContent: text("bodyContent"),
- /** Actif ou non */
- active: boolean("active").default(true).notNull(),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
-});
-
-export type EmailTemplate = typeof emailTemplates.$inferSelect;
-export type InsertEmailTemplate = typeof emailTemplates.$inferInsert;
-
-/**
- * Table de configuration SMTP/Email
- * Stocke la configuration pour l'envoi d'emails (Resend, etc.)
+ * Table de configuration des emails
*/
export const emailConfig = mysqlTable("emailConfig", {
id: int("id").autoincrement().primaryKey(),
- /** Service d'envoi (resend, smtp) */
- provider: varchar("provider", { length: 50 }).default("resend").notNull(),
- /** Clé API Resend (chiffrée) */
- apiKey: text("apiKey"),
+ /** Provider d'envoi d'emails (resend, smtp, simulation) */
+ provider: mysqlEnum("provider", ["resend", "smtp", "simulation"]).default("simulation").notNull(),
+ /** Clé API Resend */
+ resendApiKey: varchar("resendApiKey", { length: 255 }),
+ /** Hôte SMTP */
+ smtpHost: varchar("smtpHost", { length: 255 }),
+ /** Port SMTP */
+ smtpPort: int("smtpPort"),
+ /** Sécurité SMTP (TLS, SSL, None) */
+ smtpSecure: mysqlEnum("smtpSecure", ["tls", "ssl", "none"]),
+ /** Utilisateur SMTP */
+ smtpUser: varchar("smtpUser", { length: 255 }),
+ /** Mot de passe SMTP */
+ smtpPassword: varchar("smtpPassword", { length: 255 }),
/** Email expéditeur */
fromEmail: varchar("fromEmail", { length: 320 }).notNull(),
/** Nom de l'expéditeur */
- fromName: varchar("fromName", { length: 255 }).default("Formation Manager Itinova").notNull(),
- /** Mode: simulation ou production */
+ fromName: varchar("fromName", { length: 255 }).notNull(),
+ /** Mode de fonctionnement (simulation ou production) */
mode: mysqlEnum("mode", ["simulation", "production"]).default("simulation").notNull(),
- /** Domaine vérifié (pour Resend) */
- domainVerified: boolean("domainVerified").default(false).notNull(),
- /** Actif ou non */
- active: boolean("active").default(true).notNull(),
- // Champs SMTP
- /** Hôte SMTP (ex: smtp.gmail.com) */
- smtpHost: varchar("smtpHost", { length: 255 }),
- /** Port SMTP (ex: 587, 465, 25) */
- smtpPort: int("smtpPort"),
- /** Sécurité SMTP (none, tls, ssl) */
- smtpSecure: mysqlEnum("smtpSecure", ["none", "tls", "ssl"]).default("tls"),
- /** Nom d'utilisateur SMTP */
- smtpUser: varchar("smtpUser", { length: 320 }),
- /** Mot de passe SMTP (chiffré) */
- smtpPassword: text("smtpPassword"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -222,15 +173,32 @@ export const emailConfig = mysqlTable("emailConfig", {
export type EmailConfig = typeof emailConfig.$inferSelect;
export type InsertEmailConfig = typeof emailConfig.$inferInsert;
+/**
+ * Table des templates d'emails
+ */
+export const emailTemplates = mysqlTable("emailTemplates", {
+ id: int("id").autoincrement().primaryKey(),
+ /** Type de template (inscription, teaser, rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
+ type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull().unique(),
+ titre: varchar("titre", { length: 255 }).notNull(),
+ /** Corps du message avec variables dynamiques */
+ bodyContent: text("bodyContent").notNull(),
+ couleurPrincipale: varchar("couleurPrincipale", { length: 7 }).default("#283581").notNull(),
+ couleurSecondaire: varchar("couleurSecondaire", { length: 7 }).default("#0578BE").notNull(),
+ piedDePage: text("piedDePage"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type EmailTemplate = typeof emailTemplates.$inferSelect;
+export type InsertEmailTemplate = typeof emailTemplates.$inferInsert;
+
/**
* Table des formateurs
- * Stocke la liste des formateurs disponibles pour animer les séquences
*/
export const formateurs = mysqlTable("formateurs", {
id: int("id").autoincrement().primaryKey(),
- /** Nom complet du formateur */
nom: varchar("nom", { length: 255 }).notNull(),
- /** Email du formateur pour les notifications */
email: varchar("email", { length: 320 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -239,67 +207,32 @@ export const formateurs = mysqlTable("formateurs", {
export type Formateur = typeof formateurs.$inferSelect;
export type InsertFormateur = typeof formateurs.$inferInsert;
-/**
- * Table des alertes automatiques
- * Configure les alertes à envoyer selon certaines conditions
- */
-export const alertes = mysqlTable("alertes", {
- id: int("id").autoincrement().primaryKey(),
- /** Nom de l'alerte */
- nom: varchar("nom", { length: 255 }).notNull(),
- /** Type d'alerte (taux_remplissage, seuil_inscriptions) */
- type: mysqlEnum("type", ["taux_remplissage", "seuil_inscriptions"]).notNull(),
- /** Condition de déclenchement (ex: ">=", ">", "<=", "<", "=") */
- condition: varchar("condition", { length: 10 }).notNull(),
- /** Valeur seuil pour déclencher l'alerte */
- valeurSeuil: int("valeurSeuil").notNull(),
- /** Code établissement concerné (null = tous) */
- codeEtablissement: varchar("codeEtablissement", { length: 50 }),
- /** Email(s) destinataire(s) séparés par des virgules */
- emailsDestinataires: text("emailsDestinataires").notNull(),
- /** Actif ou non */
- actif: boolean("actif").default(true).notNull(),
- /** Dernière vérification */
- derniereVerification: timestamp("derniereVerification"),
- /** Dernier envoi d'alerte */
- dernierEnvoi: timestamp("dernierEnvoi"),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
-});
-
-export type Alerte = typeof alertes.$inferSelect;
-export type InsertAlerte = typeof alertes.$inferInsert;
-
/**
* Table des rappels automatiques
- * Stocke les règles d'envoi automatique de rappels avant les séquences
*/
export const rappels = mysqlTable("rappels", {
id: int("id").autoincrement().primaryKey(),
- /** Nom du rappel */
nom: varchar("nom", { length: 255 }).notNull(),
- /** Type de template à utiliser (rappel, rappelJ1, etc.) */
- templateType: varchar("templateType", { length: 50 }).notNull(),
- /** Timing : 'pre_formation' ou 'post_formation' */
- timing: mysqlEnum("timing", ["pre_formation", "post_formation"]).default("pre_formation").notNull(),
- /** Nombre de jours avant (positif) ou après (négatif) la première date de la séquence */
- joursAvant: int("joursAvant").notNull(),
- /** Heure d'envoi (format HH:MM) */
+ /** Type de template d'email à utiliser (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
+ templateType: mysqlEnum("templateType", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(),
+ /** Nombre de jours avant/après la séquence */
+ nbJours: int("nbJours").notNull(),
+ /** Heure d'envoi (format HH:mm) */
heureEnvoi: varchar("heureEnvoi", { length: 5 }).default("09:00").notNull(),
- /** Actif ou non */
- actif: boolean("actif").default(true).notNull(),
- /** Dernière exécution */
- derniereExecution: timestamp("derniereExecution"),
+ /** Timing du rappel (pre_formation ou post_formation) */
+ timing: mysqlEnum("timing", ["pre_formation", "post_formation"]).default("pre_formation").notNull(),
/** Nom du fichier joint (optionnel) */
nomFichier: varchar("nomFichier", { length: 255 }),
- /** URL publique du fichier sur S3 */
- urlFichier: text("urlFichier"),
- /** Clé S3 du fichier pour suppression */
+ /** URL du fichier sur S3 (optionnel) */
+ urlFichier: varchar("urlFichier", { length: 500 }),
+ /** Clé S3 du fichier (optionnel) */
s3Key: varchar("s3Key", { length: 500 }),
- /** Type MIME du fichier */
+ /** Type MIME du fichier (optionnel) */
typeFichier: varchar("typeFichier", { length: 100 }),
- /** Taille du fichier en octets */
+ /** Taille du fichier en octets (optionnel) */
tailleFichier: int("tailleFichier"),
+ actif: boolean("actif").default(true).notNull(),
+ derniereExecution: datetime("derniereExecution"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -309,14 +242,11 @@ export type InsertRappel = typeof rappels.$inferInsert;
/**
* Table de liaison entre rappels et dates de formation
- * Permet d'associer un rappel à des dates de formation spécifiques
- * Si aucune association n'existe pour un rappel, il s'applique à toutes les dates
+ * Si aucune date n'est associée, le rappel s'applique à toutes les dates
*/
export const rappelDates = mysqlTable("rappelDates", {
id: int("id").autoincrement().primaryKey(),
- /** ID du rappel */
rappelId: int("rappelId").notNull(),
- /** ID de la date de formation concernée */
dateFormationId: int("dateFormationId").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -325,25 +255,38 @@ export type RappelDate = typeof rappelDates.$inferSelect;
export type InsertRappelDate = typeof rappelDates.$inferInsert;
/**
- * Table des questionnaires
- * Stocke les questionnaires de satisfaction et d'évaluation des acquis
+ * Table des logs de rappels envoyés
+ */
+export const logsRappels = mysqlTable("logsRappels", {
+ id: int("id").autoincrement().primaryKey(),
+ rappelId: int("rappelId").notNull(),
+ sequenceId: int("sequenceId").notNull(),
+ apprenantId: int("apprenantId").notNull(),
+ email: varchar("email", { length: 320 }).notNull(),
+ /** Type de rappel (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
+ type: mysqlEnum("type", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(),
+ statut: mysqlEnum("statut", ["succes", "echec"]).notNull(),
+ messageErreur: text("messageErreur"),
+ /** Nombre de tentatives d'envoi */
+ nbTentatives: int("nbTentatives").default(1).notNull(),
+ /** Date du prochain essai en cas d'échec */
+ prochainEssai: datetime("prochainEssai"),
+ dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
+});
+
+export type LogRappel = typeof logsRappels.$inferSelect;
+export type InsertLogRappel = typeof logsRappels.$inferInsert;
+
+/**
+ * Table des questionnaires de satisfaction et d'évaluation
*/
export const questionnaires = mysqlTable("questionnaires", {
id: int("id").autoincrement().primaryKey(),
- /** Nom du questionnaire */
- nom: varchar("nom", { length: 255 }).notNull(),
+ titre: varchar("titre", { length: 255 }).notNull(),
+ description: text("description"),
/** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */
type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(),
- /** Description du questionnaire */
- description: text("description"),
- /** ID de la formation liée (null = tous les formations) */
- formationId: int("formationId"),
- /** Actif ou non */
actif: boolean("actif").default(true).notNull(),
- /** Envoi automatique après la formation */
- envoiAutomatique: boolean("envoiAutomatique").default(false).notNull(),
- /** Délai en jours après la dernière date de la séquence (pour envoi automatique) */
- delaiEnvoiJours: int("delaiEnvoiJours").default(1).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -352,33 +295,23 @@ export type Questionnaire = typeof questionnaires.$inferSelect;
export type InsertQuestionnaire = typeof questionnaires.$inferInsert;
/**
- * Table des questions
- * Stocke les questions associées à chaque questionnaire
+ * Table des questions d'un questionnaire
*/
export const questions = mysqlTable("questions", {
id: int("id").autoincrement().primaryKey(),
- /** ID du questionnaire */
questionnaireId: int("questionnaireId").notNull(),
- /** Ordre d'affichage */
- ordre: int("ordre").notNull(),
- /** Texte de la question */
texte: text("texte").notNull(),
/** Type de question (choix_multiple, echelle, texte_libre, oui_non) */
typeQuestion: mysqlEnum("typeQuestion", ["choix_multiple", "echelle", "texte_libre", "oui_non"]).notNull(),
- /** Options pour choix multiple (JSON array) */
+ /** Options pour les questions à choix multiples (JSON) */
options: text("options"),
- /** Valeur minimale pour échelle */
- echelleMin: int("echelleMin"),
- /** Valeur maximale pour échelle */
- echelleMax: int("echelleMax"),
- /** Label pour valeur minimale (ex: "Pas du tout satisfait") */
- echelleLabelMin: varchar("echelleLabelMin", { length: 100 }),
- /** Label pour valeur maximale (ex: "Très satisfait") */
- echelleLabelMax: varchar("echelleLabelMax", { length: 100 }),
- /** Question obligatoire */
+ /** Valeur minimale pour les échelles */
+ valeurMin: int("valeurMin"),
+ /** Valeur maximale pour les échelles */
+ valeurMax: int("valeurMax"),
obligatoire: boolean("obligatoire").default(false).notNull(),
+ ordre: int("ordre").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Question = typeof questions.$inferSelect;
@@ -386,43 +319,30 @@ export type InsertQuestion = typeof questions.$inferInsert;
/**
* Table des réponses aux questionnaires
- * Stocke les réponses complètes d'un apprenant à un questionnaire
*/
export const reponsesQuestionnaires = mysqlTable("reponsesQuestionnaires", {
id: int("id").autoincrement().primaryKey(),
- /** ID du questionnaire */
questionnaireId: int("questionnaireId").notNull(),
- /** ID de l'apprenant */
- apprenantId: int("apprenantId").notNull(),
- /** ID de la séquence */
sequenceId: int("sequenceId").notNull(),
- /** Date de réponse */
+ apprenantId: int("apprenantId").notNull(),
dateReponse: timestamp("dateReponse").defaultNow().notNull(),
- /** Questionnaire complété */
- complete: boolean("complete").default(false).notNull(),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ReponseQuestionnaire = typeof reponsesQuestionnaires.$inferSelect;
export type InsertReponseQuestionnaire = typeof reponsesQuestionnaires.$inferInsert;
/**
- * Table des réponses aux questions
- * Stocke les réponses individuelles à chaque question
+ * Table des réponses individuelles aux questions
*/
export const reponsesQuestions = mysqlTable("reponsesQuestions", {
id: int("id").autoincrement().primaryKey(),
- /** ID de la réponse au questionnaire */
reponseQuestionnaireId: int("reponseQuestionnaireId").notNull(),
- /** ID de la question */
questionId: int("questionId").notNull(),
- /** Réponse numérique (pour échelle, oui_non) */
- reponseNumerique: int("reponseNumerique"),
- /** Réponse texte (pour texte_libre, choix_multiple) */
+ /** Réponse textuelle */
reponseTexte: text("reponseTexte"),
+ /** Réponse numérique (pour échelles et oui/non) */
+ reponseNumerique: int("reponseNumerique"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ReponseQuestion = typeof reponsesQuestions.$inferSelect;
@@ -430,153 +350,69 @@ export type InsertReponseQuestion = typeof reponsesQuestions.$inferInsert;
/**
* Table des envois de questionnaires
- * Stocke l'historique des envois de questionnaires aux apprenants
*/
export const envoisQuestionnaires = mysqlTable("envoisQuestionnaires", {
id: int("id").autoincrement().primaryKey(),
- /** ID du questionnaire */
questionnaireId: int("questionnaireId").notNull(),
- /** ID de l'apprenant */
- apprenantId: int("apprenantId").notNull(),
- /** ID de la séquence */
sequenceId: int("sequenceId").notNull(),
- /** Date d'envoi */
- dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
+ apprenantId: int("apprenantId").notNull(),
/** Token unique pour accéder au questionnaire */
token: varchar("token", { length: 100 }).notNull().unique(),
- /** Date de réponse (null si pas encore répondu) */
+ dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
dateReponse: timestamp("dateReponse"),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+ statut: mysqlEnum("statut", ["envoye", "repondu"]).default("envoye").notNull(),
});
export type EnvoiQuestionnaire = typeof envoisQuestionnaires.$inferSelect;
export type InsertEnvoiQuestionnaire = typeof envoisQuestionnaires.$inferInsert;
/**
- * Table des supports de formation
- * Stocke les documents uploadés par les formateurs pour chaque séquence
+ * Table des supports de formation uploadés par les formateurs
*/
export const supportsFormation = mysqlTable("supportsFormation", {
id: int("id").autoincrement().primaryKey(),
- /** ID de la séquence */
sequenceId: int("sequenceId").notNull(),
- /** ID du formateur qui a uploadé le support */
formateurId: int("formateurId").notNull(),
- /** Nom du fichier */
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
- /** Type de fichier (PDF, PPTX, DOCX, etc.) */
- typeFichier: varchar("typeFichier", { length: 50 }).notNull(),
- /** Taille du fichier en octets */
+ typeFichier: varchar("typeFichier", { length: 100 }).notNull(),
tailleFichier: int("tailleFichier").notNull(),
- /** URL du fichier dans S3 */
urlFichier: varchar("urlFichier", { length: 500 }).notNull(),
- /** Clé S3 du fichier */
s3Key: varchar("s3Key", { length: 500 }).notNull(),
- /** Description optionnelle du support */
description: text("description"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
- updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type SupportFormation = typeof supportsFormation.$inferSelect;
export type InsertSupportFormation = typeof supportsFormation.$inferInsert;
/**
- * Table des logs des rappels envoyés
- * Stocke l'historique de tous les rappels envoyés pour éviter les doublons
+ * Table des présences (émargement)
*/
-export const logsRappels = mysqlTable("logsRappels", {
+export const presences = mysqlTable("presences", {
id: int("id").autoincrement().primaryKey(),
- /** ID du rappel (règle de rappel) */
- rappelId: int("rappelId").notNull(),
- /** ID de la séquence concernée */
- sequenceId: int("sequenceId").notNull(),
- /** ID de l'apprenant destinataire */
- apprenantId: int("apprenantId").notNull(),
- /** Email du destinataire */
- emailDestinataire: varchar("emailDestinataire", { length: 320 }).notNull(),
- /** Type de rappel (rappel, rappelJ1) */
- typeRappel: varchar("typeRappel", { length: 50 }).notNull(),
- /** Date d'envoi du rappel */
- 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"),
- /** Date de la première date de la séquence (pour référence) */
- dateSequence: timestamp("dateSequence").notNull(),
- /** Nombre de tentatives d'envoi */
- nbTentatives: int("nbTentatives").default(1).notNull(),
- /** Date du prochain essai en cas d'échec */
- prochainEssai: timestamp("prochainEssai"),
+ inscriptionId: int("inscriptionId").notNull(),
+ dateFormationId: int("dateFormationId").notNull(),
+ heurePresence: timestamp("heurePresence").defaultNow().notNull(),
+ /** Mode de validation (qrcode, manuel) */
+ modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(),
+ /** ID de l'utilisateur qui a validé (formateur ou admin) */
+ validateurId: int("validateurId"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
-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;
+export type Presence = typeof presences.$inferSelect;
+export type InsertPresence = typeof presences.$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é */
+ urlPdf: varchar("urlPdf", { length: 500 }).notNull(),
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;
@@ -584,25 +420,23 @@ 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(),
+ /** Texte personnalisable de l'attestation avec variables dynamiques */
+ texteAttestation: text("texteAttestation").notNull(),
+ /** Nom du signataire */
+ nomSignataire: varchar("nomSignataire", { length: 255 }).notNull(),
+ /** Fonction du signataire */
+ fonctionSignataire: varchar("fonctionSignataire", { length: 255 }).notNull(),
+ /** URL du logo sur S3 */
+ logoUrl: varchar("logoUrl", { length: 500 }),
/** Clé S3 du logo */
logoS3Key: varchar("logoS3Key", { length: 500 }),
- /** URL du logo */
- logoUrl: varchar("logoUrl", { length: 1000 }),
+ /** URL de la signature sur S3 */
+ signatureUrl: varchar("signatureUrl", { length: 500 }),
/** 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(),
});
@@ -610,29 +444,35 @@ export type ConfigAttestation = typeof configAttestation.$inferSelect;
export type InsertConfigAttestation = typeof configAttestation.$inferInsert;
/**
- * Table des présences (émargement numérique)
- * Stocke les présences validées pour chaque apprenant à chaque date de formation
+ * Table des notifications envoyées
*/
-export const presences = mysqlTable("presences", {
+export const logsNotifications = mysqlTable("logsNotifications", {
id: int("id").autoincrement().primaryKey(),
- /** ID de l'inscription */
- inscriptionId: int("inscriptionId").notNull(),
- /** ID de la date de formation */
- dateFormationId: int("dateFormationId").notNull(),
- /** Heure de validation de la présence */
- heurePresence: timestamp("heurePresence").notNull(),
- /** Mode de validation (qrcode, manuel) */
- modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(),
- /** ID du validateur (formateur qui a validé manuellement, null si QR code) */
- validateurId: int("validateurId"),
- /** Commentaire optionnel */
- commentaire: text("commentaire"),
- createdAt: timestamp("createdAt").defaultNow().notNull(),
+ /** Type de notification */
+ type: mysqlEnum("type", [
+ "inscription",
+ "annulation",
+ "capacite_atteinte",
+ "place_disponible",
+ "remerciement",
+ "formateur_inscription",
+ "formateur_annulation"
+ ]).notNull(),
+ /** Email du destinataire */
+ destinataire: varchar("destinataire", { length: 320 }).notNull(),
+ /** ID de la séquence concernée */
+ sequenceId: int("sequenceId"),
+ /** ID de l'apprenant concerné */
+ apprenantId: int("apprenantId"),
+ /** Statut de l'envoi */
+ statut: mysqlEnum("statut", ["succes", "echec"]).notNull(),
+ /** Message d'erreur en cas d'échec */
+ messageErreur: text("messageErreur"),
+ dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
});
-export type Presence = typeof presences.$inferSelect;
-export type InsertPresence = typeof presences.$inferInsert;
-
+export type LogNotification = typeof logsNotifications.$inferSelect;
+export type InsertLogNotification = typeof logsNotifications.$inferInsert;
/**
* Table des paramètres de l'application
@@ -642,8 +482,36 @@ export const parametres = mysqlTable("parametres", {
id: int("id").autoincrement().primaryKey(),
/** URL publique de l'application pour les QR codes d'émargement */
urlPublique: varchar("urlPublique", { length: 500 }).notNull().default("https://formations.itinova.org"),
+ /** Délai d'expiration des QR codes en minutes (par défaut 30 minutes) */
+ delaiExpirationQR: int("delaiExpirationQR").default(30).notNull(),
+ /** Durée de validité des tokens d'émargement en heures (par défaut 24 heures) */
+ dureeValiditeToken: int("dureeValiditeToken").default(24).notNull(),
+ /** Activer ou désactiver les notifications automatiques */
+ notificationsActives: boolean("notificationsActives").default(true).notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Parametre = typeof parametres.$inferSelect;
export type InsertParametre = typeof parametres.$inferInsert;
+
+/**
+ * Table de l'historique des modifications des paramètres
+ * Permet de tracer qui a modifié quoi et quand
+ */
+export const historiqueParametres = mysqlTable("historiqueParametres", {
+ id: int("id").autoincrement().primaryKey(),
+ /** ID de l'utilisateur qui a effectué la modification */
+ userId: int("userId").notNull(),
+ /** Nom de l'utilisateur (pour historique même si user supprimé) */
+ userName: varchar("userName", { length: 255 }).notNull(),
+ /** Champ modifié */
+ champModifie: varchar("champModifie", { length: 100 }).notNull(),
+ /** Ancienne valeur (JSON) */
+ ancienneValeur: text("ancienneValeur"),
+ /** Nouvelle valeur (JSON) */
+ nouvelleValeur: text("nouvelleValeur"),
+ dateModification: timestamp("dateModification").defaultNow().notNull(),
+});
+
+export type HistoriqueParametre = typeof historiqueParametres.$inferSelect;
+export type InsertHistoriqueParametre = typeof historiqueParametres.$inferInsert;
diff --git a/server/parametresDb.ts b/server/parametresDb.ts
index 54187dc..fd1cbd7 100644
--- a/server/parametresDb.ts
+++ b/server/parametresDb.ts
@@ -1,6 +1,6 @@
-import { eq } from "drizzle-orm";
+import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
-import { parametres } from "../drizzle/schema";
+import { parametres, historiqueParametres } from "../drizzle/schema";
/**
* Récupérer les paramètres de l'application
@@ -26,17 +26,108 @@ export async function getParametres() {
}
/**
- * Mettre à jour l'URL publique
+ * Mettre à jour les paramètres de l'application
+ * Enregistre automatiquement l'historique des modifications
*/
-export async function updateUrlPublique(urlPublique: string) {
+export async function updateParametres(
+ updates: Partial<{
+ urlPublique: string;
+ delaiExpirationQR: number;
+ dureeValiditeToken: number;
+ notificationsActives: boolean;
+ }>,
+ userId: number,
+ userName: string
+) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const params = await getParametres();
+ // Enregistrer l'historique pour chaque champ modifié
+ for (const [key, value] of Object.entries(updates)) {
+ const oldValue = params[key as keyof typeof params];
+ if (oldValue !== value) {
+ await db.insert(historiqueParametres).values({
+ userId,
+ userName,
+ champModifie: key,
+ ancienneValeur: oldValue !== null && oldValue !== undefined ? String(oldValue) : null,
+ nouvelleValeur: value !== null && value !== undefined ? String(value) : null,
+ });
+ }
+ }
+
+ // Mettre à jour les paramètres
await db.update(parametres)
- .set({ urlPublique })
+ .set(updates)
.where(eq(parametres.id, params.id));
return getParametres();
}
+
+/**
+ * Mettre à jour l'URL publique (fonction de compatibilité)
+ */
+export async function updateUrlPublique(urlPublique: string, userId?: number, userName?: string) {
+ return updateParametres(
+ { urlPublique },
+ userId || 1,
+ userName || "Système"
+ );
+}
+
+/**
+ * Récupérer l'historique des modifications des paramètres
+ */
+export async function getHistoriqueParametres(limit: number = 50) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ return db.select()
+ .from(historiqueParametres)
+ .orderBy(sql`${historiqueParametres.dateModification} DESC`)
+ .limit(limit);
+}
+
+/**
+ * Tester l'accessibilité d'une URL
+ */
+export async function testerUrl(url: string): Promise<{ accessible: boolean; message: string; statusCode?: number }> {
+ try {
+ // Vérifier le format de l'URL
+ const urlObj = new URL(url);
+ if (!['http:', 'https:'].includes(urlObj.protocol)) {
+ return {
+ accessible: false,
+ message: "L'URL doit commencer par http:// ou https://"
+ };
+ }
+
+ // Tenter une requête HEAD pour vérifier l'accessibilité
+ const response = await fetch(url, {
+ method: 'HEAD',
+ signal: AbortSignal.timeout(5000), // Timeout de 5 secondes
+ });
+
+ return {
+ accessible: response.ok,
+ message: response.ok
+ ? "L'URL est accessible"
+ : `L'URL a retourné une erreur HTTP ${response.status}`,
+ statusCode: response.status
+ };
+ } catch (error) {
+ if (error instanceof TypeError && error.message.includes('Invalid URL')) {
+ return {
+ accessible: false,
+ message: "Format d'URL invalide"
+ };
+ }
+
+ return {
+ accessible: false,
+ message: `Erreur lors du test: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
+ };
+ }
+}
diff --git a/server/routers.ts b/server/routers.ts
index cb5ea55..109a014 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -1472,15 +1472,46 @@ export const appRouter = router({
return getParametres();
}),
+ update: adminProcedure
+ .input(z.object({
+ urlPublique: z.string().url().optional(),
+ delaiExpirationQR: z.number().int().positive().optional(),
+ dureeValiditeToken: z.number().int().positive().optional(),
+ notificationsActives: z.boolean().optional(),
+ }))
+ .mutation(async ({ input, ctx }) => {
+ const { updateParametres } = await import("./parametresDb");
+ await updateParametres(input, ctx.user.id, ctx.user.name || "Administrateur");
+ return { success: true };
+ }),
+
updateUrlPublique: adminProcedure
.input(z.object({
urlPublique: z.string().url(),
}))
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
const { updateUrlPublique } = await import("./parametresDb");
- await updateUrlPublique(input.urlPublique);
+ await updateUrlPublique(input.urlPublique, ctx.user.id, ctx.user.name || "Administrateur");
return { success: true };
}),
+
+ testerUrl: adminProcedure
+ .input(z.object({
+ url: z.string(),
+ }))
+ .mutation(async ({ input }) => {
+ const { testerUrl } = await import("./parametresDb");
+ return testerUrl(input.url);
+ }),
+
+ getHistorique: adminProcedure
+ .input(z.object({
+ limit: z.number().int().positive().default(50),
+ }))
+ .query(async ({ input }) => {
+ const { getHistoriqueParametres } = await import("./parametresDb");
+ return getHistoriqueParametres(input.limit);
+ }),
}),
// ===== ANALYTICS =====
diff --git a/todo.md b/todo.md
index 3af26cc..c5bbbbe 100644
--- a/todo.md
+++ b/todo.md
@@ -693,3 +693,15 @@
- [x] Ajouter DashboardLayout à la page AdminParametres
- [x] Ajouter DashboardLayout à la page FormateurEmargement
+
+## Améliorations page Paramètres
+
+- [x] Ajouter les champs dans la table parametres : delaiExpirationQR, dureeValiditeToken, notificationsActives
+- [x] Créer la table historiqueParametres pour tracer les modifications
+- [x] Créer les fonctions backend pour gérer les nouveaux paramètres
+- [x] Créer la procédure tRPC pour tester l'accessibilité d'une URL
+- [x] Créer la procédure tRPC pour récupérer l'historique des modifications
+- [x] Mettre à jour l'interface AdminParametres avec les nouveaux champs
+- [x] Ajouter le bouton "Tester l'URL" avec indicateur de statut
+- [x] Ajouter une section "Historique des modifications" dans la page
+- [x] Tester toutes les fonctionnalités