diff --git a/.manus/db/db-query-1768206494365.json b/.manus/db/db-query-1768206494365.json new file mode 100644 index 0000000..29e0471 --- /dev/null +++ b/.manus/db/db-query-1768206494365.json @@ -0,0 +1,34 @@ +{ + "query": "DESCRIBE parametres;", + "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 DESCRIBE parametres;", + "rows": [ + { + "Field": "id", + "Type": "int(11)", + "Null": "NO", + "Key": "PRI", + "Default": "NULL", + "Extra": "auto_increment" + }, + { + "Field": "urlPublique", + "Type": "varchar(500)", + "Null": "NO", + "Key": "", + "Default": "https://formations.itinova.org", + "Extra": "" + }, + { + "Field": "updatedAt", + "Type": "timestamp", + "Null": "NO", + "Key": "", + "Default": "CURRENT_TIMESTAMP", + "Extra": "DEFAULT_GENERATED on update CURRENT_TIMESTAMP" + } + ], + "messages": [], + "stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint(11)\tNO\tPRI\tNULL\tauto_increment\nurlPublique\tvarchar(500)\tNO\t\thttps://formations.itinova.org\t\nupdatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\tDEFAULT_GENERATED on update CURRENT_TIMESTAMP\n", + "stderr": "", + "execution_time_ms": 75 +} \ No newline at end of file diff --git a/.manus/db/db-query-1768206500251.json b/.manus/db/db-query-1768206500251.json new file mode 100644 index 0000000..f1e5f4c --- /dev/null +++ b/.manus/db/db-query-1768206500251.json @@ -0,0 +1,9 @@ +{ + "query": "ALTER TABLE parametres ADD COLUMN delaiExpirationQR INT DEFAULT 30 NOT NULL;", + "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 ALTER TABLE parametres ADD COLUMN delaiExpirationQR INT DEFAULT 30 NOT NULL;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 413 +} \ No newline at end of file diff --git a/.manus/db/db-query-1768206505082.json b/.manus/db/db-query-1768206505082.json new file mode 100644 index 0000000..1adad6d --- /dev/null +++ b/.manus/db/db-query-1768206505082.json @@ -0,0 +1,9 @@ +{ + "query": "ALTER TABLE parametres ADD COLUMN dureeValiditeToken INT DEFAULT 24 NOT NULL;", + "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 ALTER TABLE parametres ADD COLUMN dureeValiditeToken INT DEFAULT 24 NOT NULL;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 427 +} \ No newline at end of file diff --git a/.manus/db/db-query-1768206510154.json b/.manus/db/db-query-1768206510154.json new file mode 100644 index 0000000..fc3a06a --- /dev/null +++ b/.manus/db/db-query-1768206510154.json @@ -0,0 +1,9 @@ +{ + "query": "ALTER TABLE parametres ADD COLUMN notificationsActives BOOLEAN DEFAULT TRUE NOT NULL;", + "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 ALTER TABLE parametres ADD COLUMN notificationsActives BOOLEAN DEFAULT TRUE NOT NULL;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 440 +} \ No newline at end of file diff --git a/.manus/db/db-query-1768206515397.json b/.manus/db/db-query-1768206515397.json new file mode 100644 index 0000000..8fdc071 --- /dev/null +++ b/.manus/db/db-query-1768206515397.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE IF NOT EXISTS historiqueParametres (\n id INT AUTO_INCREMENT PRIMARY KEY,\n userId INT NOT NULL,\n userName VARCHAR(255) NOT NULL,\n champModifie VARCHAR(100) NOT NULL,\n ancienneValeur TEXT,\n nouvelleValeur TEXT,\n dateModification TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL\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 historiqueParametres (\n id INT AUTO_INCREMENT PRIMARY KEY,\n userId INT NOT NULL,\n userName VARCHAR(255) NOT NULL,\n champModifie VARCHAR(100) NOT NULL,\n ancienneValeur TEXT,\n nouvelleValeur TEXT,\n dateModification TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL\n);", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 185 +} \ No newline at end of file diff --git a/.manus/db/db-query-1768206747739.json b/.manus/db/db-query-1768206747739.json new file mode 100644 index 0000000..936afe3 --- /dev/null +++ b/.manus/db/db-query-1768206747739.json @@ -0,0 +1,19 @@ +{ + "query": "SELECT * FROM historiqueParametres ORDER BY dateModification DESC LIMIT 5;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT * FROM historiqueParametres ORDER BY dateModification DESC LIMIT 5;", + "rows": [ + { + "id": "1", + "userId": "2850011", + "userName": "olivier pareige", + "champModifie": "delaiExpirationQR", + "ancienneValeur": "30", + "nouvelleValeur": "45", + "dateModification": "2026-01-12 08:31:59" + } + ], + "messages": [], + "stdout": "id\tuserId\tuserName\tchampModifie\tancienneValeur\tnouvelleValeur\tdateModification\n1\t2850011\tolivier pareige\tdelaiExpirationQR\t30\t45\t2026-01-12 08:31:59\n", + "stderr": "", + "execution_time_ms": 65 +} \ No newline at end of file diff --git a/.manus/db/db-query-error-1768206489090.json b/.manus/db/db-query-error-1768206489090.json new file mode 100644 index 0000000..9858aa1 --- /dev/null +++ b/.manus/db/db-query-error-1768206489090.json @@ -0,0 +1,8 @@ +{ + "query": "ALTER TABLE parametres \nADD COLUMN delaiExpirationQR INT DEFAULT 30 NOT NULL AFTER urlPublique,\nADD COLUMN dureeValiditeToken INT DEFAULT 24 NOT NULL AFTER delaiExpirationQR,\nADD COLUMN notificationsActives BOOLEAN DEFAULT TRUE NOT NULL AFTER dureeValiditeToken;", + "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 ALTER TABLE parametres \nADD COLUMN delaiExpirationQR INT DEFAULT 30 NOT NULL AFTER urlPublique,\nADD COLUMN dureeValiditeToken INT DEFAULT 24 NOT NULL AFTER delaiExpirationQR,\nADD COLUMN notificationsActives BOOLEAN DEFAULT TRUE NOT NULL AFTER dureeValiditeToken;", + "returncode": 1, + "logs": [ + "ERROR 1054 (42S22) at line 1: Unknown column 'delaiexpirationqr' in 'parametres'" + ] +} \ No newline at end of file diff --git a/client/src/pages/AdminParametres.tsx b/client/src/pages/AdminParametres.tsx index c7a1ee0..c3fc98b 100644 --- a/client/src/pages/AdminParametres.tsx +++ b/client/src/pages/AdminParametres.tsx @@ -4,22 +4,34 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; -import { Loader2, Save, Settings } from "lucide-react"; +import { Loader2, Save, Settings, CheckCircle2, XCircle, History } from "lucide-react"; import DashboardLayout from "@/components/DashboardLayout"; export default function AdminParametres() { const { data: parametres, isLoading, refetch } = trpc.parametres.get.useQuery(); - const [urlPublique, setUrlPublique] = useState(""); + const { data: historique, isLoading: isLoadingHistorique } = trpc.parametres.getHistorique.useQuery({ limit: 20 }); - // Initialiser l'URL quand les données sont chargées + const [urlPublique, setUrlPublique] = useState(""); + const [delaiExpirationQR, setDelaiExpirationQR] = useState(30); + const [dureeValiditeToken, setDureeValiditeToken] = useState(24); + const [notificationsActives, setNotificationsActives] = useState(true); + const [testUrlStatus, setTestUrlStatus] = useState<{ loading: boolean; result?: { accessible: boolean; message: string } }>({ loading: false }); + + // Initialiser les valeurs quand les données sont chargées useEffect(() => { if (parametres) { setUrlPublique(parametres.urlPublique); + setDelaiExpirationQR(parametres.delaiExpirationQR); + setDureeValiditeToken(parametres.dureeValiditeToken); + setNotificationsActives(parametres.notificationsActives); } }, [parametres]); - const updateMutation = trpc.parametres.updateUrlPublique.useMutation({ + const updateMutation = trpc.parametres.update.useMutation({ onSuccess: () => { toast.success("Paramètres mis à jour avec succès"); refetch(); @@ -29,6 +41,21 @@ export default function AdminParametres() { }, }); + const testUrlMutation = trpc.parametres.testerUrl.useMutation({ + onSuccess: (result) => { + setTestUrlStatus({ loading: false, result }); + if (result.accessible) { + toast.success(result.message); + } else { + toast.error(result.message); + } + }, + onError: (error) => { + setTestUrlStatus({ loading: false }); + toast.error(`Erreur lors du test : ${error.message}`); + }, + }); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -40,86 +67,248 @@ export default function AdminParametres() { return; } - updateMutation.mutate({ urlPublique }); + updateMutation.mutate({ + urlPublique, + delaiExpirationQR, + dureeValiditeToken, + notificationsActives, + }); + }; + + const handleTestUrl = () => { + try { + new URL(urlPublique); + setTestUrlStatus({ loading: true }); + testUrlMutation.mutate({ url: urlPublique }); + } catch { + toast.error("L'URL saisie n'est pas valide"); + } }; if (isLoading) { return ( -
- -
+ +
+ +
+
); } return ( -
-
- -
-

Paramètres de l'application

-

- Configuration globale de l'application -

-
-
- - - - URL publique de l'application - - Cette URL est utilisée pour générer les QR codes d'émargement. - Elle doit correspondre à l'URL publique de votre serveur. - - - -
-
- - setUrlPublique(e.target.value)} - required - /> -

- Exemple : https://formations.itinova.org (sans slash à la fin) -

-
- -
- -
-
- -
-

💡 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 + + + +
+ {/* URL publique */} +
+ +
+ { + setUrlPublique(e.target.value); + setTestUrlStatus({ loading: false }); + }} + required + className="flex-1" + /> + +
+

+ Cette URL est utilisée pour générer les QR codes d'émargement. + Exemple : https://formations.itinova.org (sans slash à la fin) +

+
+ + {/* Délai d'expiration QR codes */} +
+ + setDelaiExpirationQR(parseInt(e.target.value))} + required + /> +

+ Durée de validité des QR codes d'émargement après leur génération (entre 5 et 120 minutes) +

+
+ + {/* Durée de validité des tokens */} +
+ + setDureeValiditeToken(parseInt(e.target.value))} + required + /> +

+ Durée pendant laquelle les tokens d'émargement restent valides (entre 1 et 72 heures) +

+
+ + {/* Notifications actives */} +
+
+ +

+ Activer ou désactiver l'envoi automatique de notifications +

+
+ +
+ +
+ +
+
+
+
+ + {/* 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