From ea57cfc0cb3a5546b6504981df2a0938cbf48eb6 Mon Sep 17 00:00:00 2001 From: Manus Sandbox Date: Mon, 15 Dec 2025 14:59:59 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20du=20syst=C3=A8me=20de=20?= =?UTF-8?q?notifications=20enrichi=20comprenant=20:=20email=20de=20remerci?= =?UTF-8?q?ement=20post-formation=20automatique,=20notifications=20aux=20f?= =?UTF-8?q?ormateurs=20(nouvelle=20inscription=20et=20annulation),=20alert?= =?UTF-8?q?es=20de=20capacit=C3=A9=20atteinte=20pour=20les=20admins,=20not?= =?UTF-8?q?ifications=20aux=20apprenants=20en=20liste=20d'attente=20quand?= =?UTF-8?q?=20une=20place=20se=20lib=C3=A8re,=20ajout=20du=20champ=20email?= =?UTF-8?q?=20pour=20les=20formateurs,=20et=20scheduler=20automatique=20po?= =?UTF-8?q?ur=20les=20remerciements.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .manus/db/db-query-1765828497955.json | 9 + drizzle/meta/0022_snapshot.json | 1745 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 2 + server/db.ts | 13 + server/emailService.ts | 367 ++++++ server/rappelScheduler.ts | 5 +- server/remerciementScheduler.ts | 209 +++ server/routers.ts | 138 +- todo.md | 10 + 10 files changed, 2503 insertions(+), 2 deletions(-) create mode 100644 .manus/db/db-query-1765828497955.json create mode 100644 drizzle/meta/0022_snapshot.json create mode 100644 server/remerciementScheduler.ts diff --git a/.manus/db/db-query-1765828497955.json b/.manus/db/db-query-1765828497955.json new file mode 100644 index 0000000..c31f7ef --- /dev/null +++ b/.manus/db/db-query-1765828497955.json @@ -0,0 +1,9 @@ +{ + "query": "ALTER TABLE formateurs ADD COLUMN email varchar(320) DEFAULT 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 formateurs ADD COLUMN email varchar(320) DEFAULT NULL;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 593 +} \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..89867c9 --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,1745 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "528b4ca6-4e50-47ac-ba30-41705f00139b", + "prevId": "9bb0c846-ec7f-4969-82c8-44003c70b785", + "tables": { + "alertes": { + "name": "alertes", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('taux_remplissage','seuil_inscriptions')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valeurSeuil": { + "name": "valeurSeuil", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailsDestinataires": { + "name": "emailsDestinataires", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "derniereVerification": { + "name": "derniereVerification", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dernierEnvoi": { + "name": "dernierEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alertes_id": { + "name": "alertes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailConfig": { + "name": "emailConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fromEmail": { + "name": "fromEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromName": { + "name": "fromName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "mode": { + "name": "mode", + "type": "enum('simulation','production')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'simulation'" + }, + "domainVerified": { + "name": "domainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "smtpHost": { + "name": "smtpHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpPort": { + "name": "smtpPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpSecure": { + "name": "smtpSecure", + "type": "enum('none','tls','ssl')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "smtpUser": { + "name": "smtpUser", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smtpPassword": { + "name": "smtpPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailConfig_id": { + "name": "emailConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailTemplates": { + "name": "emailTemplates", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primaryColor": { + "name": "primaryColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerBgColor": { + "name": "headerBgColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerTextColor": { + "name": "headerTextColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "headerTitle": { + "name": "headerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "footerText": { + "name": "footerText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bodyContent": { + "name": "bodyContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailTemplates_id": { + "name": "emailTemplates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "emailTemplates_type_unique": { + "name": "emailTemplates_type_unique", + "columns": [ + "type" + ] + } + }, + "checkConstraint": {} + }, + "envoisQuestionnaires": { + "name": "envoisQuestionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEnvoi": { + "name": "dateEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateReponse": { + "name": "dateReponse", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "envoisQuestionnaires_id": { + "name": "envoisQuestionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "envoisQuestionnaires_token_unique": { + "name": "envoisQuestionnaires_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "formateurs": { + "name": "formateurs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formateurs_id": { + "name": "formateurs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "logsRappels": { + "name": "logsRappels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "rappelId": { + "name": "rappelId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailDestinataire": { + "name": "emailDestinataire", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeRappel": { + "name": "typeRappel", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateEnvoi": { + "name": "dateEnvoi", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "statut": { + "name": "statut", + "type": "enum('success','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messageErreur": { + "name": "messageErreur", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateSequence": { + "name": "dateSequence", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nbTentatives": { + "name": "nbTentatives", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "prochainEssai": { + "name": "prochainEssai", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "logsRappels_id": { + "name": "logsRappels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "passwordResetTokens": { + "name": "passwordResetTokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "passwordResetTokens_id": { + "name": "passwordResetTokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "passwordResetTokens_token_unique": { + "name": "passwordResetTokens_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "questionnaires": { + "name": "questionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('satisfaction','evaluation_pre','evaluation_post')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "envoiAutomatique": { + "name": "envoiAutomatique", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "delaiEnvoiJours": { + "name": "delaiEnvoiJours", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "questionnaires_id": { + "name": "questionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "texte": { + "name": "texte", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeQuestion": { + "name": "typeQuestion", + "type": "enum('choix_multiple','echelle','texte_libre','oui_non')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleMin": { + "name": "echelleMin", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleMax": { + "name": "echelleMax", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleLabelMin": { + "name": "echelleLabelMin", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "echelleLabelMax": { + "name": "echelleLabelMax", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "obligatoire": { + "name": "obligatoire", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "questions_id": { + "name": "questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rappels": { + "name": "rappels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "templateType": { + "name": "templateType", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joursAvant": { + "name": "joursAvant", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heureEnvoi": { + "name": "heureEnvoi", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'09:00'" + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "derniereExecution": { + "name": "derniereExecution", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rappels_id": { + "name": "rappels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reponsesQuestionnaires": { + "name": "reponsesQuestionnaires", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "questionnaireId": { + "name": "questionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateReponse": { + "name": "dateReponse", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reponsesQuestionnaires_id": { + "name": "reponsesQuestionnaires_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "reponsesQuestions": { + "name": "reponsesQuestions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "reponseQuestionnaireId": { + "name": "reponseQuestionnaireId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "questionId": { + "name": "questionId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reponseNumerique": { + "name": "reponseNumerique", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponseTexte": { + "name": "reponseTexte", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reponsesQuestions_id": { + "name": "reponsesQuestions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publicCible": { + "name": "publicCible", + "type": "enum('directeur','chef_service','tous','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formateurId": { + "name": "formateurId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "supportsFormation": { + "name": "supportsFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "formateurId": { + "name": "formateurId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nomFichier": { + "name": "nomFichier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "typeFichier": { + "name": "typeFichier", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tailleFichier": { + "name": "tailleFichier", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "urlFichier": { + "name": "urlFichier", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "supportsFormation_id": { + "name": "supportsFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 16f11cc..21576c5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1765183092631, "tag": "0021_equal_the_fallen", "breakpoints": true + }, + { + "idx": 22, + "version": "5", + "when": 1765828479906, + "tag": "0022_closed_lucky_pierre", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index c0ad2ee..30efa5b 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -226,6 +226,8 @@ 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(), }); diff --git a/server/db.ts b/server/db.ts index 26144cc..3622b78 100644 --- a/server/db.ts +++ b/server/db.ts @@ -893,3 +893,16 @@ export async function updateRappelExecution(id: number) { derniereExecution: new Date(), }).where(eq(rappels.id, id)); } + + +// ==================== UTILISATEURS ADMIN ==================== + +/** + * Récupère tous les utilisateurs avec le rôle admin + */ +export async function getAdminUsers() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(users).where(eq(users.role, 'admin')); +} diff --git a/server/emailService.ts b/server/emailService.ts index 67ef96f..9469805 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -474,3 +474,370 @@ export async function sendPasswordResetEmail(params: { html: await getEmailTemplate(content, 'reset_password'), }); } + + +/** + * ======================================== + * SYSTÈME DE NOTIFICATIONS ENRICHI + * ======================================== + */ + +/** + * Envoie un email de remerciement post-formation à un apprenant + */ +export async function sendRemerciementPostFormation(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + formateurNom?: string; + lienQuestionnaire?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const content = ` +

Merci pour votre participation !

+

Bonjour ${salutation},

+ +

Nous tenons à vous remercier pour votre participation à la formation ${params.formationNom} (${params.sequenceNom}).

+ + ${params.formateurNom ? `

Nous espérons que les sessions animées par ${params.formateurNom} ont répondu à vos attentes.

` : ''} + +
+

Votre avis compte !

+

Afin d'améliorer continuellement nos formations, nous vous invitons à partager votre retour d'expérience.

+ ${params.lienQuestionnaire ? ` +
+ Donner mon avis +
+ ` : '

Un questionnaire de satisfaction vous sera envoyé prochainement.

'} +
+ +

Nous vous souhaitons une excellente continuation dans vos fonctions et espérons vous revoir lors de prochaines formations.

+ +

Cordialement,
L'équipe Formation

+ `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + formateur: params.formateurNom || '', + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: `Merci pour votre participation - ${params.formationNom}`, + html: await getEmailTemplate(content, 'remerciement', variables), + }); +} + +/** + * Envoie une notification au formateur pour une nouvelle inscription + */ +export async function sendNotificationFormateurNouvelleInscription(params: { + formateurEmail: string; + formateurNom: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + apprenantEtablissement?: string; + formationNom: string; + sequenceNom: string; + nbInscrits: number; + capaciteMax: number; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + + const datesHTML = params.dates.map(date => ` +
  • Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    🎓 Nouvelle inscription à votre formation

    +

    Bonjour ${params.formateurNom},

    + +

    Un nouvel apprenant vient de s'inscrire à votre formation.

    + +
    +

    Détails de l'inscription

    +

    Apprenant : ${params.apprenantPrenom} ${params.apprenantNom}

    +

    Fonction : ${fonctionLabel}

    + ${params.apprenantEtablissement ? `

    Établissement : ${params.apprenantEtablissement}

    ` : ''} +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +
    + +
    +

    📊 État des inscriptions

    +

    ${params.nbInscrits} / ${params.capaciteMax} places occupées

    +
    +
    +
    +
    + +

    Dates de la formation :

    +
      ${datesHTML}
    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `Nouvelle inscription - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une notification au formateur pour une annulation d'inscription + */ +export async function sendNotificationFormateurAnnulation(params: { + formateurEmail: string; + formateurNom: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + nbInscrits: number; + capaciteMax: number; + raisonAnnulation?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + + const content = ` +

    ⚠️ Annulation d'inscription

    +

    Bonjour ${params.formateurNom},

    + +

    Un apprenant a annulé son inscription à votre formation.

    + +
    +

    Détails de l'annulation

    +

    Apprenant : ${params.apprenantPrenom} ${params.apprenantNom}

    +

    Fonction : ${fonctionLabel}

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.raisonAnnulation ? `

    Raison : ${params.raisonAnnulation}

    ` : ''} +
    + +
    +

    📊 État des inscriptions après annulation

    +

    ${params.nbInscrits} / ${params.capaciteMax} places occupées

    +

    ${params.capaciteMax - params.nbInscrits} place(s) disponible(s)

    +
    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `Annulation d'inscription - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une alerte aux admins quand la capacité maximale est atteinte + */ +export async function sendAlerteCapaciteAtteinte(params: { + adminEmails: string[]; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + nbListeAttente: number; + formateurNom?: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise<{ sent: number; failed: number }> { + const datesHTML = params.dates.map(date => ` +
  • ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    🔔 Alerte : Capacité maximale atteinte

    + +
    +

    ⚠️ La séquence est complète

    +

    La capacité maximale de la séquence ${params.sequenceNom} a été atteinte.

    +
    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.formateurNom ? `

    Formateur : ${params.formateurNom}

    ` : ''} +

    Capacité : ${params.nbInscrits} / ${params.capaciteMax} (100%)

    + ${params.nbListeAttente > 0 ? `

    Liste d'attente : ${params.nbListeAttente} personne(s)

    ` : ''} +
    + +

    Dates prévues :

    +
      ${datesHTML}
    + +
    +

    Actions possibles

    +
      +
    • Augmenter la capacité maximale de la séquence
    • +
    • Créer une nouvelle séquence pour cette formation
    • +
    • Contacter les personnes en liste d'attente pour les informer
    • +
    +
    + +

    Cordialement,
    Le système de gestion des formations

    + `; + + let sent = 0; + let failed = 0; + + for (const adminEmail of params.adminEmails) { + try { + await sendEmail({ + to: adminEmail, + subject: `🔔 Capacité atteinte - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'alerte_admin'), + }); + sent++; + } catch (error) { + console.error(`Erreur envoi alerte à ${adminEmail}:`, error); + failed++; + } + } + + return { sent, failed }; +} + +/** + * Envoie une notification aux apprenants en liste d'attente quand une place se libère + */ +export async function sendNotificationPlaceDisponible(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + positionListeAttente: number; + lienConfirmation?: string; + delaiReponse?: number; // en heures +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + const delai = params.delaiReponse || 48; + + const content = ` +

    🎉 Une place s'est libérée !

    +

    Bonjour ${salutation},

    + +

    Bonne nouvelle ! Une place vient de se libérer pour la formation à laquelle vous êtes inscrit(e) en liste d'attente.

    + +
    +

    ✅ Vous pouvez maintenant confirmer votre inscription

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Votre position : ${params.positionListeAttente}${params.positionListeAttente === 1 ? 'er' : 'ème'} sur la liste d'attente

    +
    + + ${params.lienConfirmation ? ` + + ` : '

    Veuillez contacter le service RH pour confirmer votre inscription.

    '} + +
    +

    ⏰ Important

    +

    Vous avez ${delai} heures pour confirmer votre inscription.

    +

    Passé ce délai, la place sera proposée à la personne suivante sur la liste d'attente.

    +
    + +

    Cordialement,
    L'équipe Formation

    + `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: `🎉 Place disponible - ${params.formationNom}`, + html: await getEmailTemplate(content, 'notification_liste_attente', variables), + }); +} + +/** + * Envoie une notification à un apprenant en liste d'attente pour l'informer d'une annulation de séquence + */ +export async function sendNotificationAnnulationListeAttente(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + raisonAnnulation?: string; + alternativesDisponibles?: Array<{ sequenceNom: string; nbPlaces: number }>; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const alternativesHTML = params.alternativesDisponibles && params.alternativesDisponibles.length > 0 + ? ` +
    +

    📋 Séquences alternatives disponibles

    +
      + ${params.alternativesDisponibles.map(alt => ` +
    • ${alt.sequenceNom} - ${alt.nbPlaces} place(s) disponible(s)
    • + `).join('')} +
    +

    Contactez le service RH pour vous inscrire à une autre séquence.

    +
    + ` : ''; + + const content = ` +

    Information : Annulation de séquence

    +

    Bonjour ${salutation},

    + +

    Nous vous informons que la séquence de formation pour laquelle vous étiez en liste d'attente a été annulée.

    + +
    +

    Séquence annulée

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.raisonAnnulation ? `

    Raison : ${params.raisonAnnulation}

    ` : ''} +
    + + ${alternativesHTML} + +

    Nous nous excusons pour ce désagrément et restons à votre disposition pour toute question.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.apprenantEmail, + subject: `Information : Annulation de séquence - ${params.formationNom}`, + html: await getEmailTemplate(content, 'notification_annulation'), + }); +} diff --git a/server/rappelScheduler.ts b/server/rappelScheduler.ts index 81b8514..9be9dac 100644 --- a/server/rappelScheduler.ts +++ b/server/rappelScheduler.ts @@ -4,6 +4,7 @@ import { eq, and, gte, lte, sql } from "drizzle-orm"; import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService"; import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb"; import { notifyOwner } from "./_core/notification"; +import { processRemerciementsAutomatiques } from "./remerciementScheduler"; /** * Job automatique qui vérifie quotidiennement les séquences à venir @@ -243,11 +244,13 @@ export function initRappelScheduler() { // Exécuter immédiatement au démarrage processRappelsAutomatiques(); + processRemerciementsAutomatiques(); // Puis exécuter toutes les heures setInterval(() => { processRappelsAutomatiques(); + processRemerciementsAutomatiques(); }, 60 * 60 * 1000); // 1 heure en millisecondes - console.log("[Rappels] Scheduler initialisé - vérification toutes les heures"); + console.log("[Rappels] Scheduler initialisé - vérification toutes les heures (rappels + remerciements)"); } diff --git a/server/remerciementScheduler.ts b/server/remerciementScheduler.ts new file mode 100644 index 0000000..ad39cb2 --- /dev/null +++ b/server/remerciementScheduler.ts @@ -0,0 +1,209 @@ +import { getDb } from "./db"; +import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema"; +import { eq, and, lte, sql } from "drizzle-orm"; +import { sendRemerciementPostFormation } from "./emailService"; + +/** + * Table pour suivre les remerciements déjà envoyés + */ +const remerciementsEnvoyes = new Map(); + +/** + * Vérifie si un remerciement a déjà été envoyé pour une inscription + */ +function remerciementDejaEnvoye(inscriptionId: number, sequenceId: number): boolean { + const key = `${inscriptionId}-${sequenceId}`; + return remerciementsEnvoyes.has(key); +} + +/** + * Marque un remerciement comme envoyé + */ +function marquerRemerciementEnvoye(inscriptionId: number, sequenceId: number): void { + const key = `${inscriptionId}-${sequenceId}`; + remerciementsEnvoyes.set(key, new Date()); +} + +/** + * Job automatique qui vérifie quotidiennement les séquences terminées + * et envoie les emails de remerciement aux apprenants + */ +export async function processRemerciementsAutomatiques() { + console.log("[Remerciements] Démarrage du traitement des remerciements post-formation..."); + + const db = await getDb(); + if (!db) { + console.error("[Remerciements] Base de données non disponible"); + return; + } + + try { + const now = new Date(); + const hier = new Date(); + hier.setDate(hier.getDate() - 1); + hier.setHours(23, 59, 59, 999); + + // Récupérer les séquences dont la dernière date est passée (terminées hier ou avant) + const sequencesTerminees = await db + .select({ + sequence: sequences, + formation: formations, + formateur: formateurs, + derniereDate: sql`MAX(${datesFormation.dateFin})`.as('derniereDate'), + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .innerJoin(datesFormation, eq(sequences.id, datesFormation.sequenceId)) + .where(eq(sequences.statut, 'ouverte')) // Séquences encore ouvertes (pas encore marquées terminées) + .groupBy(sequences.id, formations.id, formateurs.id) + .having(lte(sql`MAX(${datesFormation.dateFin})`, hier)); + + console.log(`[Remerciements] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s)`); + + for (const { sequence, formation, formateur, derniereDate } of sequencesTerminees) { + await processRemerciementSequence(sequence, formation, formateur, derniereDate); + } + + console.log("[Remerciements] Traitement terminé"); + } catch (error) { + console.error("[Remerciements] Erreur lors du traitement des remerciements:", error); + } +} + +async function processRemerciementSequence( + sequence: any, + formation: any, + formateur: any | null, + derniereDate: Date +) { + console.log(`[Remerciements] Traitement de la séquence: ${sequence.nom} (terminée le ${derniereDate})`); + + const db = await getDb(); + if (!db) return; + + try { + // Récupérer les inscriptions confirmées pour cette séquence + const inscriptionsConfirmees = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequence.id), + eq(inscriptions.statut, 'confirmee') + ) + ); + + console.log(`[Remerciements] ${inscriptionsConfirmees.length} inscription(s) confirmée(s) pour ${sequence.nom}`); + + let envoyesCount = 0; + let errorsCount = 0; + + for (const { inscription, apprenant } of inscriptionsConfirmees) { + // Vérifier si le remerciement a déjà été envoyé + if (remerciementDejaEnvoye(inscription.id, sequence.id)) { + console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}`); + continue; + } + + try { + await sendRemerciementPostFormation({ + apprenantEmail: apprenant.email, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + formateurNom: formateur?.nom, + }); + + marquerRemerciementEnvoye(inscription.id, sequence.id); + envoyesCount++; + console.log(`[Remerciements] Email envoyé à ${apprenant.email}`); + } catch (error) { + errorsCount++; + console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); + } + } + + console.log(`[Remerciements] Séquence ${sequence.nom}: ${envoyesCount} envoyé(s), ${errorsCount} erreur(s)`); + + // Optionnel: Marquer la séquence comme terminée + // await db.update(sequences).set({ statut: 'terminee' }).where(eq(sequences.id, sequence.id)); + + } catch (error) { + console.error(`[Remerciements] Erreur pour la séquence ${sequence.nom}:`, error); + } +} + +/** + * Envoie manuellement un email de remerciement pour une séquence spécifique + * Utilisé par l'interface admin pour envoyer les remerciements à la demande + */ +export async function envoyerRemerciementsSequence(sequenceId: number): Promise<{ sent: number; failed: number }> { + const db = await getDb(); + if (!db) { + throw new Error("Base de données non disponible"); + } + + // Récupérer la séquence avec formation et formateur + const sequenceData = await db + .select({ + sequence: sequences, + formation: formations, + formateur: formateurs, + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .where(eq(sequences.id, sequenceId)) + .limit(1); + + if (sequenceData.length === 0) { + throw new Error("Séquence introuvable"); + } + + const { sequence, formation, formateur } = sequenceData[0]; + + // Récupérer les inscriptions confirmées + const inscriptionsConfirmees = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequenceId), + eq(inscriptions.statut, 'confirmee') + ) + ); + + let sent = 0; + let failed = 0; + + for (const { inscription, apprenant } of inscriptionsConfirmees) { + try { + await sendRemerciementPostFormation({ + apprenantEmail: apprenant.email, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + formateurNom: formateur?.nom, + }); + sent++; + } catch (error) { + console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); + failed++; + } + } + + return { sent, failed }; +} diff --git a/server/routers.ts b/server/routers.ts index d9c1a80..f71309e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -8,7 +8,7 @@ import * as db from "./db"; import * as analyticsDb from "./analyticsDb"; import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService"; import { TRPCError } from "@trpc/server"; -import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService"; +import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; @@ -463,6 +463,70 @@ export const appRouter = router({ lieu: inscriptionSequence.lieu, statut, }); + + // Notification au formateur si présent + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + if (formateur && formateur.email) { + const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + try { + await sendNotificationFormateurNouvelleInscription({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: inscriptionApprenant.nom, + apprenantPrenom: inscriptionApprenant.prenom, + apprenantFonction: inscriptionApprenant.fonction, + apprenantEtablissement: inscriptionApprenant.codeEtablissement, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + nbInscrits: nbInscritsActuel, + capaciteMax: inscriptionSequence.capaciteMax, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`); + } catch (e) { + console.error(`[Notification] Erreur envoi email formateur:`, e); + } + } + } + + // Alerte capacité atteinte pour les admins + const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + if (nbInscritsApres >= inscriptionSequence.capaciteMax) { + try { + const admins = await db.getAdminUsers(); + const adminEmails = admins.filter(a => a.email).map(a => a.email!); + if (adminEmails.length > 0) { + const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente'); + let formateurNom: string | undefined; + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + formateurNom = formateur?.nom; + } + await sendAlerteCapaciteAtteinte({ + adminEmails, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + capaciteMax: inscriptionSequence.capaciteMax, + nbInscrits: nbInscritsApres, + nbListeAttente, + formateurNom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`); + } + } catch (e) { + console.error(`[Notification] Erreur envoi alerte capacité:`, e); + } + } } return { success: true, statut }; @@ -493,8 +557,63 @@ export const appRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' }); } + // Récupérer les infos avant la mise à jour pour les notifications + const apprenant = await db.getApprenantById(input.apprenantId); + const formation = await db.getFormationById(sequence.formationId); + await db.updateInscription(inscription.id, { statut: 'annulee' }); + // Notifications après désinscription + if (apprenant && formation) { + // Notification au formateur + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + if (formateur && formateur.email) { + const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + try { + await sendNotificationFormateurAnnulation({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + nbInscrits: nbInscritsApres, + capaciteMax: sequence.capaciteMax, + }); + console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`); + } catch (e) { + console.error(`[Notification] Erreur envoi email formateur:`, e); + } + } + } + + // Notifier le premier en liste d'attente qu'une place s'est libérée + const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId); + const premierEnAttente = inscriptionsListeAttente + .filter(i => i.inscription.statut === 'liste_attente') + .sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0]; + + if (premierEnAttente && premierEnAttente.apprenant) { + try { + await sendNotificationPlaceDisponible({ + apprenantEmail: premierEnAttente.apprenant.email, + apprenantNom: premierEnAttente.apprenant.nom, + apprenantPrenom: premierEnAttente.apprenant.prenom, + apprenantFonction: premierEnAttente.apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + positionListeAttente: 1, + delaiReponse: 48, + }); + console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`); + } catch (e) { + console.error(`[Notification] Erreur envoi email liste d'attente:`, e); + } + } + } + return { success: true }; }), @@ -1496,6 +1615,23 @@ export const appRouter = router({ return getEtablissementDetail(input.codeEtablissement); }), }), + + // ===== NOTIFICATIONS ===== + notifications: router({ + envoyerRemerciements: adminProcedure + .input(z.object({ sequenceId: z.number() })) + .mutation(async ({ input }) => { + const { envoyerRemerciementsSequence } = await import("./remerciementScheduler"); + return envoyerRemerciementsSequence(input.sequenceId); + }), + + envoyerRemerciementsAutomatiques: adminProcedure + .mutation(async () => { + const { processRemerciementsAutomatiques } = await import("./remerciementScheduler"); + await processRemerciementsAutomatiques(); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index a4bdb6d..5f39971 100644 --- a/todo.md +++ b/todo.md @@ -427,3 +427,13 @@ - [x] Bug persistant : J-1 fonctionne mais J-7 ne fonctionne toujours pas - [x] Analyser la différence entre le template J-7 et J-1 - [x] Corriger le template J-7 définitivement + +## 🔔 Système de notifications enrichi +- [x] Email de remerciement post-formation +- [x] Notification aux formateurs (nouvelle inscription) +- [x] Notification aux formateurs (annulation) +- [x] Alerte capacité atteinte (pour les admins) +- [x] Notification d'annulation aux apprenants en liste d'attente +- [x] Ajout du champ email pour les formateurs +- [x] Scheduler automatique pour les remerciements post-formation +- [x] Procédure tRPC pour envoi manuel des remerciements