diff --git a/deploy-update-manual.sh b/deploy-update-manual.sh index 362504b..b337627 100755 --- a/deploy-update-manual.sh +++ b/deploy-update-manual.sh @@ -6,6 +6,11 @@ set -e # Arrêter en cas d'erreur +# Charger les variables d'environnement +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + echo "=========================================" echo "Mise à jour Formation Manager Itinova" echo "Mode manuel (sans Git)" diff --git a/deploy-update-targz.sh b/deploy-update-targz.sh index b4e8d5c..c6d49a9 100755 --- a/deploy-update-targz.sh +++ b/deploy-update-targz.sh @@ -1,6 +1,11 @@ #!/bin/bash # Script mise à jour avec archive .tar.gz set -e + +# Charger les variables d'environnement +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' @@ -23,7 +28,7 @@ DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') -mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_FILE" +mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" --no-tablespaces "$DB_NAME" > "$BACKUP_FILE" echo -e "${GREEN}✓ Backup OK${NC}" echo -e "${YELLOW}2. Arrêt serveur...${NC}" pm2 stop formation-manager || true diff --git a/deploy-update.sh b/deploy-update.sh index 59ed616..77e6da8 100755 --- a/deploy-update.sh +++ b/deploy-update.sh @@ -6,6 +6,11 @@ set -e # Arrêter en cas d'erreur +# Charger les variables d'environnement +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + echo "=========================================" echo "Mise à jour Formation Manager Itinova" echo "=========================================" diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..ef999fe --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1530 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "96064bda-b127-4b05-8e67-9b37f2693366", + "prevId": "942306ba-d59b-44a7-b11b-f5f3883913fb", + "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 + }, + "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": {} + }, + "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": {} + }, + "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 1aaf50e..981be34 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1764540514199, "tag": "0017_unique_skrulls", "breakpoints": true + }, + { + "idx": 18, + "version": "5", + "when": 1764934995109, + "tag": "0018_smooth_purifiers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 98a43b4..43eff51 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -288,3 +288,132 @@ export const rappels = mysqlTable("rappels", { export type Rappel = typeof rappels.$inferSelect; export type InsertRappel = typeof rappels.$inferInsert; + +/** + * Table des questionnaires + * Stocke les questionnaires de satisfaction et d'évaluation des acquis + */ +export const questionnaires = mysqlTable("questionnaires", { + id: int("id").autoincrement().primaryKey(), + /** Nom du questionnaire */ + nom: varchar("nom", { length: 255 }).notNull(), + /** 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(), +}); + +export type Questionnaire = typeof questionnaires.$inferSelect; +export type InsertQuestionnaire = typeof questionnaires.$inferInsert; + +/** + * Table des questions + * Stocke les questions associées à chaque 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: 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 */ + obligatoire: boolean("obligatoire").default(false).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Question = typeof questions.$inferSelect; +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 */ + 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 + */ +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) */ + reponseTexte: text("reponseTexte"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type ReponseQuestion = typeof reponsesQuestions.$inferSelect; +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(), + /** Token unique pour accéder au questionnaire */ + token: varchar("token", { length: 100 }).notNull().unique(), + /** Date de réponse (null si pas encore répondu) */ + dateReponse: timestamp("dateReponse"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type EnvoiQuestionnaire = typeof envoisQuestionnaires.$inferSelect; +export type InsertEnvoiQuestionnaire = typeof envoisQuestionnaires.$inferInsert; diff --git a/rappels-cron.js b/rappels-cron.js new file mode 100644 index 0000000..8e179c8 --- /dev/null +++ b/rappels-cron.js @@ -0,0 +1,17 @@ +// Charger les variables d'environnement +import 'dotenv/config'; + +// Importer et exécuter le service de rappels +import { envoyerRappelsAutomatiques } from './dist/server/rappelService.js'; + +console.log('[Rappels] Démarrage à', new Date().toISOString()); + +envoyerRappelsAutomatiques() + .then(() => { + console.log('[Rappels] Terminé avec succès'); + process.exit(0); + }) + .catch((error) => { + console.error('[Rappels] Erreur:', error); + process.exit(1); + }); diff --git a/server/exportService.ts b/server/exportService.ts index afde1f6..c85f5e1 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -206,7 +206,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe // Date de la journée doc.setFontSize(12); - doc.setFont(undefined, 'bold'); + doc.setFont('helvetica', 'bold'); doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', @@ -215,7 +215,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe hour: '2-digit', minute: '2-digit' })}`, 14, yPos); - doc.setFont(undefined, 'normal'); + doc.setFont('helvetica', 'normal'); yPos += 10; // Tableau de présence avec signatures matin et après-midi @@ -256,9 +256,9 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe const signatureY = finalY + 20; doc.setFontSize(10); - doc.setFont(undefined, 'bold'); + doc.setFont('helvetica', 'bold'); doc.text('Signature du formateur :', 14, signatureY); - doc.setFont(undefined, 'normal'); + doc.setFont('helvetica', 'normal'); // Dessiner une ligne pour la signature doc.line(60, signatureY, 120, signatureY); diff --git a/server/questionnaireDb.ts b/server/questionnaireDb.ts new file mode 100644 index 0000000..068a3d6 --- /dev/null +++ b/server/questionnaireDb.ts @@ -0,0 +1,250 @@ +import { eq, and, desc, sql } from "drizzle-orm"; +import { getDb } from "./db"; +import { + questionnaires, + questions, + reponsesQuestionnaires, + reponsesQuestions, + envoisQuestionnaires, + type Questionnaire, + type InsertQuestionnaire, + type Question, + type InsertQuestion, + type ReponseQuestionnaire, + type InsertReponseQuestionnaire, + type ReponseQuestion, + type InsertReponseQuestion, + type EnvoiQuestionnaire, + type InsertEnvoiQuestionnaire, +} from "../drizzle/schema"; + +// ========== QUESTIONNAIRES ========== + +export async function getAllQuestionnaires() { + const db = await getDb(); + if (!db) return []; + return await db.select().from(questionnaires).orderBy(desc(questionnaires.createdAt)); +} + +export async function getQuestionnaireById(id: number) { + const db = await getDb(); + if (!db) return null; + const result = await db.select().from(questionnaires).where(eq(questionnaires.id, id)).limit(1); + return result[0] || null; +} + +export async function createQuestionnaire(data: InsertQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(questionnaires).values(data); + return result[0].insertId; +} + +export async function updateQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(questionnaires).set(data).where(eq(questionnaires.id, id)); +} + +export async function deleteQuestionnaire(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + // Supprimer d'abord les questions associées + await db.delete(questions).where(eq(questions.questionnaireId, id)); + // Puis le questionnaire + await db.delete(questionnaires).where(eq(questionnaires.id, id)); +} + +// ========== QUESTIONS ========== + +export async function getQuestionsByQuestionnaireId(questionnaireId: number) { + const db = await getDb(); + if (!db) return []; + return await db + .select() + .from(questions) + .where(eq(questions.questionnaireId, questionnaireId)) + .orderBy(questions.ordre); +} + +export async function createQuestion(data: InsertQuestion) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(questions).values(data); + return result[0].insertId; +} + +export async function updateQuestion(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(questions).set(data).where(eq(questions.id, id)); +} + +export async function deleteQuestion(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.delete(questions).where(eq(questions.id, id)); +} + +// ========== RÉPONSES QUESTIONNAIRES ========== + +export async function createReponseQuestionnaire(data: InsertReponseQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(reponsesQuestionnaires).values(data); + return result[0].insertId; +} + +export async function getReponseQuestionnaire(questionnaireId: number, apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return null; + const result = await db + .select() + .from(reponsesQuestionnaires) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.apprenantId, apprenantId), + eq(reponsesQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + return result[0] || null; +} + +export async function updateReponseQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(reponsesQuestionnaires).set(data).where(eq(reponsesQuestionnaires.id, id)); +} + +// ========== RÉPONSES QUESTIONS ========== + +export async function createReponseQuestion(data: InsertReponseQuestion) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(reponsesQuestions).values(data); + return result[0].insertId; +} + +export async function getReponsesByReponseQuestionnaireId(reponseQuestionnaireId: number) { + const db = await getDb(); + if (!db) return []; + return await db + .select() + .from(reponsesQuestions) + .where(eq(reponsesQuestions.reponseQuestionnaireId, reponseQuestionnaireId)); +} + +// ========== ENVOIS QUESTIONNAIRES ========== + +export async function createEnvoiQuestionnaire(data: InsertEnvoiQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(envoisQuestionnaires).values(data); + return result[0].insertId; +} + +export async function getEnvoiByToken(token: string) { + const db = await getDb(); + if (!db) return null; + const result = await db + .select() + .from(envoisQuestionnaires) + .where(eq(envoisQuestionnaires.token, token)) + .limit(1); + return result[0] || null; +} + +export async function updateEnvoiQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(envoisQuestionnaires).set(data).where(eq(envoisQuestionnaires.id, id)); +} + +export async function checkEnvoiExists(questionnaireId: number, apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return false; + const result = await db + .select() + .from(envoisQuestionnaires) + .where( + and( + eq(envoisQuestionnaires.questionnaireId, questionnaireId), + eq(envoisQuestionnaires.apprenantId, apprenantId), + eq(envoisQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + return result.length > 0; +} + +// ========== STATISTIQUES ========== + +/** + * Récupère les statistiques d'un questionnaire + */ +export async function getQuestionnaireStats(questionnaireId: number) { + const db = await getDb(); + if (!db) return null; + + // Nombre total d'envois + const envoisResult = await db + .select({ count: sql`count(*)` }) + .from(envoisQuestionnaires) + .where(eq(envoisQuestionnaires.questionnaireId, questionnaireId)); + const totalEnvois = envoisResult[0]?.count || 0; + + // Nombre de réponses + const reponsesResult = await db + .select({ count: sql`count(*)` }) + .from(reponsesQuestionnaires) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.complete, true) + ) + ); + const totalReponses = reponsesResult[0]?.count || 0; + + // Taux de réponse + const tauxReponse = totalEnvois > 0 ? (totalReponses / totalEnvois) * 100 : 0; + + return { + totalEnvois, + totalReponses, + tauxReponse: Math.round(tauxReponse * 10) / 10, // 1 décimale + }; +} + +/** + * Récupère les réponses détaillées d'un questionnaire pour analyse + */ +export async function getReponsesDetailleesQuestionnaire(questionnaireId: number) { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + reponseQuestionnaireId: reponsesQuestionnaires.id, + apprenantId: reponsesQuestionnaires.apprenantId, + sequenceId: reponsesQuestionnaires.sequenceId, + dateReponse: reponsesQuestionnaires.dateReponse, + questionId: reponsesQuestions.questionId, + reponseNumerique: reponsesQuestions.reponseNumerique, + reponseTexte: reponsesQuestions.reponseTexte, + }) + .from(reponsesQuestionnaires) + .leftJoin( + reponsesQuestions, + eq(reponsesQuestions.reponseQuestionnaireId, reponsesQuestionnaires.id) + ) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.complete, true) + ) + ); + + return result; +} diff --git a/server/routers.ts b/server/routers.ts index 9c55ff7..50273e2 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -884,6 +884,13 @@ export const appRouter = router({ await db.updateRappel(input.id, { actif: input.actif }); return { success: true }; }), + + envoyerAutomatiques: publicProcedure + .mutation(async () => { + const { processRappelsAutomatiques } = await import('./rappelScheduler'); + const result = await processRappelsAutomatiques(); + return result; + }), }), emailConfig: router({ @@ -997,6 +1004,197 @@ export const appRouter = router({ }), }), + // ===== QUESTIONNAIRES ===== + questionnaires: router({ + list: adminProcedure.query(async () => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getAllQuestionnaires(); + }), + + getById: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionnaireById(input.id); + }), + + create: adminProcedure + .input(z.object({ + nom: z.string(), + type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]), + description: z.string().optional(), + formationId: z.number().nullable().optional(), + actif: z.boolean().default(true), + envoiAutomatique: z.boolean().default(false), + delaiEnvoiJours: z.number().default(1), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const id = await questionnaireDb.createQuestionnaire(input); + return { id, success: true }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + nom: z.string().optional(), + type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(), + description: z.string().optional(), + formationId: z.number().nullable().optional(), + actif: z.boolean().optional(), + envoiAutomatique: z.boolean().optional(), + delaiEnvoiJours: z.number().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.updateQuestionnaire(id, data); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.deleteQuestionnaire(input.id); + return { success: true }; + }), + + getStats: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionnaireStats(input.id); + }), + + getReponsesDetaillees: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id); + }), + }), + + // ===== QUESTIONS ===== + questions: router({ + list: adminProcedure + .input(z.object({ questionnaireId: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId); + }), + + create: adminProcedure + .input(z.object({ + questionnaireId: z.number(), + ordre: z.number(), + texte: z.string(), + typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]), + options: z.string().optional(), + echelleMin: z.number().optional(), + echelleMax: z.number().optional(), + echelleLabelMin: z.string().optional(), + echelleLabelMax: z.string().optional(), + obligatoire: z.boolean().default(false), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const id = await questionnaireDb.createQuestion(input); + return { id, success: true }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + ordre: z.number().optional(), + texte: z.string().optional(), + typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(), + options: z.string().optional(), + echelleMin: z.number().optional(), + echelleMax: z.number().optional(), + echelleLabelMin: z.string().optional(), + echelleLabelMax: z.string().optional(), + obligatoire: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.updateQuestion(id, data); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.deleteQuestion(input.id); + return { success: true }; + }), + }), + + // ===== RÉPONSES QUESTIONNAIRES (PUBLIC) ===== + reponses: router({ + getByToken: publicProcedure + .input(z.object({ token: z.string() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const envoi = await questionnaireDb.getEnvoiByToken(input.token); + if (!envoi) return null; + + const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId); + const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId); + + return { + envoi, + questionnaire, + questions, + }; + }), + + submit: publicProcedure + .input(z.object({ + token: z.string(), + reponses: z.array(z.object({ + questionId: z.number(), + reponseNumerique: z.number().optional(), + reponseTexte: z.string().optional(), + })), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + + // Récupérer l'envoi + const envoi = await questionnaireDb.getEnvoiByToken(input.token); + if (!envoi) throw new Error("Token invalide"); + if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu"); + + // Créer la réponse au questionnaire + const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({ + questionnaireId: envoi.questionnaireId, + apprenantId: envoi.apprenantId, + sequenceId: envoi.sequenceId, + complete: true, + }); + + // Enregistrer chaque réponse + for (const reponse of input.reponses) { + await questionnaireDb.createReponseQuestion({ + reponseQuestionnaireId, + questionId: reponse.questionId, + reponseNumerique: reponse.reponseNumerique, + reponseTexte: reponse.reponseTexte, + }); + } + + // Mettre à jour l'envoi + await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, { + dateReponse: new Date(), + }); + + return { success: true }; + }), + }), + // ===== ÉTABLISSEMENTS ===== etablissements: router({ list: adminProcedure.query(async () => { diff --git a/todo.md b/todo.md index 11eb86a..ac00050 100644 --- a/todo.md +++ b/todo.md @@ -1,976 +1,15 @@ # Gestion des Formations Manager Itinova - TODO -## Phase 1 : Planification et initialisation -- [x] Initialiser le projet avec architecture full-stack -- [x] Créer le plan de développement -- [x] Créer le fichier todo.md - -## Phase 2 : Modèle de données -- [x] Créer la table des formations (nom, description, lien unique) -- [x] Créer la table des sessions (dates, lieu, capacité max 12, statut blocage J-15) -- [x] Créer la table des inscriptions (apprenant, session, statut) -- [x] Créer la table des apprenants (nom, prénom, email, code établissement) -- [x] Implémenter les relations entre tables -- [x] Ajouter les contraintes d'unicité pour éviter les doubles inscriptions - -## Phase 3 : Interface d'administration -- [x] Créer le dashboard administrateur avec navigation -- [x] Créer l'interface de gestion des formations (CRUD) -- [x] Créer l'interface de gestion des sessions (CRUD) -- [x] Créer l'interface de gestion des apprenants (CRUD) -- [x] Afficher les listes d'inscrits par session -- [x] Implémenter la gestion des rôles (admin DRH, responsable, stagiaire) - -## Phase 4 : Système d'inscription -- [x] Créer la page d'inscription publique avec lien unique par formation -- [x] Implémenter la validation de capacité maximale (12 participants) -- [x] Implémenter la validation anti-double inscription -- [x] Implémenter le blocage des inscriptions/désinscriptions à J-15 -- [x] Créer le système de liste d'attente si session complète -- [x] Afficher les sessions disponibles avec places restantes - -## Phase 5 : Invitations et emails automatiques -- [x] Implémenter la génération d'invitations Outlook (.ics) avec statut " occupé " -- [x] Inclure le lieu de formation dans les invitations -- [x] Créer le système d'envoi d'emails automatiques -- [x] Créer le template email de confirmation d'inscription -- [x] Créer le template email teaser pour chaque groupe -- [x] Créer le template email de rappel à J-7 -- [x] Implémenter l'envoi automatique des emails selon les événements - -## Phase 6 : Exports -- [x] Implémenter l'export Excel des listes d'inscrits par session -- [x] Implémenter l'export PDF des listes d'inscrits par session -- [x] Créer la génération de feuille de présence PDF (nom, prénom, code établissement) -- [x] Ajouter les boutons d'export dans l'interface admin - -## Phase 7 : Tests et documentation -- [x] Tester le flux complet d'inscription -- [x] Tester les validations et contraintes -- [x] Tester la génération des invitations et emails -- [x] Tester les exports PDF/Excel -- [x] Créer la documentation utilisateur -- [x] Créer la documentation d'administration - -## P## Phase 8 : Livraison -- [x] Vérifier toutes les fonctionnalités -- [x] Créer un checkpoint final -- [x] Présenter l'application à l'utilisateur - -## Corrections à apporter - -- [x] Corriger la mise à jour de la liste des apprenants après inscription -- [x] Implémenter l'envoi effectif des emails de confirmation - -## Nouvelles fonctionnalités - -- [x] Ajouter des filtres de tri sur la page des sessions (date, lieu, places restantes) -- [x] Ajouter des filtres de recherche sur la page des formations -- [x] Ajouter des filtres de recherche sur la page des apprenants - -- [x] Ajouter des filtres complets sur la page de gestion des inscrits (recherche, statut, établissement, tri par date) - -- [x] Ajouter le champ "Fonction" (Directeur, Chef de service, Autre) dans la fiche apprenant -- [x] Mettre à jour le schéma de base de données -- [x] Mettre à jour les formulaires d'inscription et de gestion -- [x] Ajouter la fonction dans les exports PDF/Excel - -## Améliorations supplémentaires - -- [x] Ajouter un filtre par fonction sur la page des apprenants -- [x] Ajouter un filtre par fonction sur la page des inscrits -- [x] Créer des statistiques de répartition par fonction sur le tableau de bord -- [x] Ajouter des graphiques de visualisation des données -- [x] Personnaliser les templates d'emails avec la fonction de l'apprenant - -## Refonte : Sessions → Séquences avec dates multiples - -- [x] Renommer "Session" en "Séquence" dans le schéma de base de données -- [x] Créer une table pour gérer jusqu'à 4 dates par séquence -- [x] Mettre à jour toutes les procédures tRPC pour utiliser "séquence" -- [x] Renommer toutes les interfaces et pages (AdminSessions → AdminSequences) -- [x] Adapter le formulaire de création/édition pour gérer 4 dates -- [x] Mettre à jour la page d'inscription pour afficher toutes les dates d'une séquence -- [x] Modifier la logique d'inscription pour inscrire à toutes les dates -- [x] Mettre à jour les exports PDF/Excel avec toutes les dates -- [x] Adapter les emails pour inclure toutes les dates de la séquence -- [x] Mettre à jour les invitations Outlook pour créer 4 événements si nécessaire -- [x] Renommer partout dans l'interface utilisateur (labels, titres, descriptions) - -## Corrections urgentes - -- [x] Corriger l'erreur React "Cannot update a component while rendering" dans Home.tsx - -- [x] Corriger le bug de modification des horaires des séquences - -- [x] Corriger le décalage d'une heure lors de l'enregistrement des dates (15h00 saisi devient 16h00 en base) -- [x] Corriger la mise à jour du nombre d'inscrits sur la page d'inscription publique - -- [x] Corriger le décalage d'une heure entre la saisie et l'affichage des dates de séquences - -- [x] Corriger le décalage d'une heure à l'affichage des dates (problème de formatage lors de la récupération) - -- [x] Corriger les fonctions formatDate pour gérer les objets Date JavaScript en plus des chaînes MySQL - -- [x] Forcer le fuseau horaire Europe/Paris dans la connexion MySQL pour éliminer le décalage d'une heure - -- [x] Corriger le décalage horaire persistant dans parseLocalDateTime (09:48 envoyé devient 10:48 en base) - -- [x] Corriger l'erreur "toISOString is not a function" lors de la modification de séquences - -- [x] Ajouter le champ "Public cible" dans la fiche formation avec liste déroulante (Directeur, Chef de service, Autre) -- [x] Mettre à jour le schéma de base de données pour les formations -- [x] Mettre à jour les formulaires de gestion des formations -- [x] Afficher le public cible dans les listes et exports - -- [x] Déplacer le champ "Public cible" des formations vers les séquences -- [x] Retirer publicCible du schéma formations -- [x] Ajouter publicCible au schéma sequences -- [x] Mettre à jour les procédures tRPC pour sequences -- [x] Mettre à jour le formulaire de création/édition de séquences -- [x] Afficher le public cible dans la liste des séquences -- [x] Afficher le public cible par séquence sur la page d'inscription publique - -- [x] Corriger le formulaire de modification des séquences pour afficher le champ Public cible - -## Bugs critiques à corriger - -- [x] La validation d'une inscription ne fonctionne pas (déjà corrigé dans la version restaurée) -- [x] Lorsqu'un apprenant s'inscrit, il n'apparaît pas dans la liste des apprenants (déjà corrigé dans la version restaurée) - -## Nouvelle fonctionnalité - Alerte public cible - -- [x] Analyser le code d'inscription pour identifier où ajouter l'alerte -- [x] Implémenter la logique de vérification du public cible dans le formulaire d'inscription -- [x] Afficher une alerte d'information (non bloquante) si le public cible ne correspond pas -- [x] Tester l'alerte avec différents scénarios (correspondance, non-correspondance) - -## Inclusion du public cible dans les exports - -- [x] Analyser le code des exports Excel et PDF -- [x] Ajouter le public cible dans l'export Excel des listes d'inscrits -- [x] Ajouter le public cible dans l'export PDF des listes d'inscrits -- [x] Ajouter le public cible dans l'export PDF des feuilles de présence -- [x] Tester tous les exports pour vérifier l'affichage correct - -## Ajout du filtre par public cible - -- [x] Analyser la page AdminSequences pour comprendre le système de filtres existant -- [x] Ajouter un état pour le filtre publicCible -- [x] Ajouter le composant Select pour le filtre publicCible dans l'interface -- [x] Implémenter la logique de filtrage par public cible -- [x] Tester le filtre avec différentes valeurs - -## Badges de couleur pour le public cible - -- [x] Créer un composant Badge pour afficher le public cible avec des couleurs distinctes -- [x] Intégrer le Badge dans le tableau des séquences (AdminSequences) -- [x] Intégrer le Badge dans la page des inscrits (AdminSequenceInscrits) -- [x] Tester l'affichage des badges avec différents publics cibles - -## Rapport d'analyse public cible vs fonction appre- [x] Créer une nouvelle page AdminRapportPublicCible -- [x] Ajouter la route dans App.tsx -- [x] Ajouter un lien dans le menu de navigation (DashboardLayout- [x] Créer une procédure tRPC pour récupérer les données d'analyse -- [x] Implémenter la logique de comparaison public cible vs fonction apprenants -- [x] Afficher les statistiques et écarts dans l'interface -- [x] Tester le rapport avec différentes données - -## Gestion des utilisateurs de l'application - -- [x] Analyser le schéma actuel de la table users -- [x] Ajouter les champs nécessaires (statut actif/inactif, date de création compte) -- [x] Créer les procédures tRPC pour la gestion des utilisateurs (liste, création, modification, désactivation) -- [x] Créer la page AdminUsers avec tableau des utilisateurs -- [x] Implémenter le formulaire de création d'utilisateur -- [x] Implémenter le formulaire de modification d'utilisateur -- [x] Ajouter la fonctionnalité d'activation/désactivation des comptes -- [x] Ajouter des filtres de recherche (nom, email, rôle, statut) -- [x] Ajouter la gestion des rôles (admin, user) -- [x] Intégrer la page dans le menu de navigation -- [x] Tester toutes les fonctionnalités de gestion des utilisateurs - -## Ajout de la fonctionnalité de création d'utilisateurs - -- [x] Ajouter une procédure tRPC pour créer un nouvel utilisateur -- [x] Ajouter un bouton "Ajouter un utilisateur" dans la page AdminUsers -- [x] Créer un dialogue de création avec formulaire (nom, email, rôle) -- [x] Tester la création d'un nouvel utilisateur - -## Réinitialisation de mot de passe - -- [x] Créer une table password_reset_tokens dans le schéma -- [x] Ajouter les fonctions de gestion des tokens dans db.ts -- [x] Créer une procédure tRPC pour générer un token et envoyer l'email -- [x] Ajouter un bouton "Réinitialiser le mot de passe" dans AdminUsers -- [x] Créer un dialogue de confirmation pour la réinitialisation -- [x] Tester la génération de token et l'envoi d'email - -## Correction affichage menu latéral - -- [x] Intégrer la page AdminUsers dans le DashboardLayout -- [x] Vérifier l'affichage du menu latéral sur la page utilisateurs - -## Correction de l'envoi d'emails pour réinitialisation de mot de passe - -- [x] Analyser le service d'envoi d'emails actuel -- [x] Rechercher l'API d'envoi d'emails disponible dans Manus -- [x] Intégrer un vrai service d'envoi d'emails (Resend) -- [x] Créer la documentation de configuration EMAIL_SETUP.md - -## Interface de personnalisation des templates d'emails - -- [x] Créer la table email_templates dans le schéma -- [x] Créer les procédures backend pour CRUD des templates -- [x] Créer la page AdminEmailTemplates -- [x] Implémenter l'éditeur de template (logo, couleurs, textes) -- [x] Ajouter la prévisualisation en temps réel -- [x] Modifier le service d'envoi pour utiliser les templates personnalisés -- [x] Ajouter le lien dans le menu de navigation -- [x] Tester la personnalisation et l'envoi avec templates personnalisés - -## Configuration SMTP via interface - -- [x] Créer la table email_config dans le schéma -- [x] Créer les procédures backend pour CRUD de la configuration -- [x] Ajouter une procédure de test d'envoi d'email -- [x] Modifier le service d'envoi pour utiliser la config en base -- [x] Créer la page AdminEmailConfig -- [x] Implémenter le formulaire de configuration (API key, email expéditeur, mode) -- [x] Ajouter le test d'envoi immédiat avec feedback -- [x] Ajouter la validation de domaine -- [x] Ajouter le basculement simulation/production -- [x] Ajouter le lien dans le menu de navigation -- [x] Tester la configuration et l'envoi d'emails - -## Amélioration disposition formulaire modification séquences - -- [x] Réorganiser les champs du formulaire pour éviter tout chevauchement -- [x] Ajouter les champs Public cible et Formateur dans le formulaire de modification -- [x] Séparer Public cible et Formateur sur une ligne dédiée -- [x] Tester la disposition sur différentes tailles d'écran - -## Correction bug FormateurCombobox - -- [x] Recréer le fichier FormateurCombobox.tsx manquant -- [x] Ajouter l'import de FormateurCombobox dans AdminSequences.tsx -- [x] Redémarrer le serveur pour détecter le nouveau fichier - -## Recréation des fichiers manquants - -- [x] Recréer le composant CapacityProgressBar avec barre de progression colorée -- [x] Recréer la page AdminCalendrier avec vue calendrier mensuel -- [x] Recréer la page AdminRappels avec gestion des rappels automatiques -- [x] Intégrer CapacityProgressBar dans AdminSequences -- [x] Redémarrer le serveur pour détecter tous les nouveaux fichiers -- [ ] Vérifier que tous les imports et routes sont corrects -- [ ] Créer un checkpoint complet avec toutes les fonctionnalités - -## Correction affichage page calendrier - -- [x] Ajouter la route /admin/calendrier dans App.tsx -- [x] Ajouter la route /admin/rappels dans App.tsx -- [x] Ajouter le lien "Calendrier" dans DashboardLayout -- [x] Ajouter le lien "Rappels" dans DashboardLayout -- [x] Ajouter les imports CalendarDays et Bell -- [x] Redémarrer le serveur -- [x] Tester l'accès aux pages calendrier et rappels - -## Vérification des trois fonctionnalités principales - -- [x] Vérifier que CapacityProgressBar est bien utilisé dans AdminSequences -- [x] Vérifier que la page AdminCalendrier existe et est accessible -- [x] Vérifier que la page AdminRappels existe et est accessible -- [x] Ajouter les procédures tRPC manquantes pour les rappels -- [x] Créer un rapport de vérification complet - -## Corrections de bugs - Calendrier et taux de remplissage - -- [x] Corriger l'affichage des séquences dans le calendrier de novembre (seq.dates au lieu de seq.datesFormation) -- [x] Vérifier la logique de filtrage des séquences par mois dans AdminCalendrier -- [x] Corriger le calcul du taux de remplissage (ajout de nbInscrits dans sequences.list) -- [x] Vérifier que sequences.list retourne bien le nombre d'inscrits - -## Correction bug calendrier - parseISO - -- [x] Corriger l'erreur "e.split is not a function" dans AdminCalendrier -- [x] Ajouter une vérification du type de dateDebut avant parseISO -- [x] Gérer le cas où dateDebut est déjà un objet Date ou une chaîne - -## Trois corrections demandées - -- [x] Ajouter "Tous" dans la liste des publics cible du formulaire "Nouvelle séquence" -- [x] Ajouter "Tous" dans les fonctions de badge et label -- [x] Corriger le composant Progress pour supporter indicatorClassName -- [x] Corriger les couleurs de la barre de progression (vert/orange/rouge) selon le taux de remplissage -- [x] Compléter les informations des séquences affichées dans le calendrier (lieu, formateur, nb inscrits) - -## Trois nouvelles corrections - -- [x] Ajouter le champ formateur dans le formulaire "Nouvelle séquence" -- [x] Modifier le placeholder du formateur : "Sélectionner un formateur" (déjà correct par défaut) -- [x] Afficher le nom de la séquence ET le nom de la formation dans le calendrier - -## Vérification des trois corrections - -- [ ] Vérifier que le champ formateur apparaît bien dans le formulaire "Nouvelle séquence" -- [ ] Vérifier le texte du placeholder du formateur -- [ ] Vérifier que le nom de la séquence ET le nom de la formation apparaissent dans le calendrier -- [ ] Corriger les problèmes trouvés - -## Corrections affichage nom de formation dans calendrier - -- [x] Modifier getSequences() pour récupérer les informations de formation via jointure -- [x] Créer getSequencesWithFormation() pour éviter les conflits avec le spread operator -- [x] Mettre à jour la procédure sequences.list pour utiliser getSequencesWithFormation() -- [x] Corriger AdminCalendrier.tsx pour utiliser formation.nom au lieu de formation.titre -- [x] Vérifier que le placeholder du FormateurCombobox est correct (sans "(optionnel)") -- [x] Tester l'affichage du nom de formation dans le calendrier -- [x] Tester l'affichage du nom de formation dans le dialog de détails - -## Corrections affichage formateur - -- [x] Corriger le placeholder "Sélectionner un formateur (optionnel)" en "Sélectionner un formateur" dans le formulaire de modification de séquence -- [x] Ajouter l'affichage du formateur dans la liste des séquences (AdminSequences) -- [x] Tester les modifications - -## Nettoyage de la table utilisateurs - -- [x] Vérifier les utilisateurs existants dans la base de données -- [x] Supprimer tous les utilisateurs sauf les 4 spécifiés (IDs: 1110110, 2010001, 2040001, 2070001) -- [x] Vérifier que la suppression a réussi - -## Correction erreur formateurs.create - -- [x] Identifier le code qui appelle formateurs.create -- [x] Ajouter la procédure formateurs.create manquante dans le routeur -- [x] Tester la correction - -## Correction affichage du formateur dans les séquences -- [x] Vérifier comment getSequencesWithFormation récupère les informations du formateur -- [x] Corriger la jointure pour inclure les données du formateur -- [x] Vérifier l'affichage dans AdminSequences.tsx -- [x] Ajouter formateurId dans le schéma de validation de sequences.create -- [x] Ajouter formateurId dans le schéma de validation de sequences.update -- [x] Corriger le schéma Zod pour accepter null (formateurId: z.number().nullable().optional()) -- [x] Modifier FormateurCombobox pour stocker l'ID au lieu du nom -- [x] Tester les modifications dans la liste et dans le dialog de modification - -## Améliorations des feuilles de présence - -- [x] Copier le logo Itinova dans le dossier public du projet -- [x] Ajouter le logo Itinova en haut à droite de chaque page de feuille de présence -- [x] Ajouter le nom du formateur dans l'en-tête de la feuille de présence -- [x] Modifier le tableau pour avoir deux signatures par apprenant et par journée (matin et après-midi) -- [x] Mettre à jour l'interface FeuillePresenceInfo pour inclure le formateur -- [x] Tester la génération des feuilles de présence avec toutes les modifications - -## Signature du formateur sur les feuilles de présence - -- [x] Ajouter un champ de signature pour le formateur en bas de chaque page de feuille de présence - -## Détail par apprenant - -- [x] Créer une procédure tRPC pour récupérer toutes les inscriptions d'un apprenant avec les informations de formation et séquence -- [x] Créer la page AdminApprenantDetail affichant la liste des inscriptions par apprenant -- [x] Ajouter un bouton "Détail par apprenant" dans la page AdminApprenants -- [x] Ajouter la route /admin/apprenants/:id dans App.tsx -- [x] Tester l'affichage des détails d'un apprenant - -## Problème de création excessive d'utilisateurs - -- [x] Analyser la table users pour identifier les utilisateurs en double -- [x] Examiner le code d'authentification OAuth -- [x] Identifier la cause de la création excessive d'utilisateurs -- [x] Corriger le problème -- [x] Tester la correction - -## Identifiant de connexion et modification du mot de passe - -- [ ] Ajouter le champ username (identifiant de connexion) dans le schéma de la base de données -- [ ] Générer et appliquer la migration de la base de données -- [x] Mettre à jour les procédures tRPC pour gérer username et password -- [ ] Afficher l'identifiant de connexion dans la liste des utilisateurs -- [ ] Permettre la modification de l'identifiant et du mot de passe dans la fenêtre d'édition -- [x] Tester les modifications - -## Utilisateur administrateur spécial - -- [x] Créer l'utilisateur "adminServFormation" avec mot de passe "Itinova69!" dans la base de données -- [x] Modifier l'interface pour désactiver le bouton de suppression pour cet utilisateur -- [x] Modifier le formulaire d'édition pour rendre les champs nom, email et identifiant en lecture seule pour cet utilisateur -- [x] Tester les modifications - -## Email modifiable pour administrateur - -- [x] Modifier l'interface pour rendre le champ email modifiable pour l'utilisateur adminServFormation -- [x] Tester la modification - -## Authentification locale avec username/password - -- [x] Créer la route d'authentification locale dans le backend (POST /api/auth/local/login) -- [x] Créer la page de connexion locale avec formulaire username/password -- [x] Ajouter la route /login dans App.tsx -- [x] Créer un lien vers la page de login depuis la page d'accueil -- [x] Écrire des tests pour l'authentification locale -- [x] Tester la connexion avec adminServFormation/Itinova69! - -## Ajouter un lien visible vers la connexion locale - -- [x] Modifier la page d'accueil pour afficher un bouton "Connexion locale" -- [x] Tester l'accès à la page de connexion locale depuis la page d'accueil - -## Corriger la redirection de /login vers OAuth - -- [x] Identifier pourquoi /login redirige vers OAuth au lieu d'afficher le formulaire local -- [x] Corriger la page Login.tsx pour empêcher la redirection automatique -- [x] Tester que /login affiche bien le formulaire username/password - -## Désactiver la redirection OAuth pour /login - -- [ ] Identifier le middleware qui force la redirection vers OAuth -- [ ] Exclure la route /login de la redirection automatique -- [ ] Tester que /login affiche bien le formulaire local même après publication - -## Créer une page de choix de connexion - -- [ ] Créer une page LoginChoice avec boutons "Connexion Manus" et "Connexion locale" -- [ ] Modifier les routes pour afficher cette page en premier -- [ ] Tester le choix et les redirections - -## Créer une page de choix de connexion - -- [x] Créer une page LoginChoice avec boutons "Connexion Manus" et "Connexion locale" -- [x] Modifier les routes pour afficher cette page en premier -- [x] Tester le choix et les redirections - -## Correction redirection après déconnexion - -- [x] Modifier la procédure de déconnexion pour rediriger vers la page de choix (/) -- [x] Tester la déconnexion et vérifier la redirection - -## Correction page de choix au lancement - -- [x] Analyser pourquoi la page de choix ne s'affiche pas au lancement -- [x] Corriger la redirection automatique -- [x] Tester l'affichage de la page de choix - -## Sauvegarde complète avec corrections authentification - -- [x] Appliquer toutes les corrections d'authentification locale -- [x] Créer un checkpoint final -- [x] Créer un package de déploiement complet - -## Configuration HTTPS avec Let's Encrypt - -- [x] Créer le guide de configuration HTTPS complet -- [x] Créer le script d'installation automatique de nginx et certbot -- [x] Créer la configuration nginx optimisée -- [x] Créer le script de renouvellement automatique des certificats -- [x] Tester la configuration HTTPS - -## Ajout du logo Itinova sur la page de choix de connexion - -- [ ] Modifier la page LoginChoice pour afficher le logo en haut -- [ ] Tester l'affichage du logo - -## Script de correction VPS - -- [ ] Créer un script de correction complet pour appliquer toutes les modifications sur le VPS -- [ ] Tester le script - -## Correction interface templates d'emails - -- [x] Permettre la modification du corps du message dans les templates d'emails - -## Correction erreur 403 sur application publiée - -- [x] Identifier la cause de l'erreur 403 après publication de la version avec bodyContent -- [x] Corriger le problème (ajout bodyContent dans routers.ts et db.ts) -- [x] Tester l'accès public à l'application - -## Amélioration prévisualisation templates d'emails - -- [x] Modifier la prévisualisation pour afficher le contenu saisi dans "Corps du message" en temps réel - -## Amélioration éditeur de templates d'emails - -- [x] Installer React Quill (éditeur WYSIWYG) -- [x] Intégrer l'éditeur dans AdminEmailTemplates -- [x] Ajouter des boutons pour insérer des variables dynamiques ({{nomApprenant}}, {{nomFormation}}, {{dateDebut}}, etc.) -- [x] Tester l'éditeur et la prévisualisation - -## Correction erreur React Quill incompatible avec React 19 - -- [x] Remplacer React Quill par une solution compatible (TipTap) -- [x] Tester le nouvel éditeur - -## Remplacement automatique des variables dans les emails - -- [x] Créer une fonction utilitaire de remplacement des variables -- [x] Modifier les fonctions d'envoi d'emails pour utiliser le remplacement -- [x] Ajouter un bouton "Aperçu email" dans l'interface de gestion des templates -- [x] Créer un modal d'aperçu montrant l'email final avec variables remplacées -- [x] Tester l'envoi d'emails avec variables - -## Tableau de bord analytique avancé - -- [x] Créer les requêtes backend pour récupérer les données analytiques -- [x] Créer une page dédiée au tableau de bord analytique -- [x] Ajouter un graphique d'évolution temporelle des inscriptions -- [x] Ajouter un graphique de taux de remplissage par mois -- [x] Ajouter des statistiques de participation par établissement -- [x] Intégrer la page dans le menu de navigation - -## Améliorations tableau de bord analytique - -- [x] Ajouter des filtres de période (dernier mois, trimestre, année, personnalisé) -- [x] Implémenter l'export PDF des rapports analytiques (placeholder) -- [x] Implémenter l'export Excel des rapports analytiques -- [x] Créer le schéma de base de données pour les alertes -- [ ] Créer l'interface de configuration des alertes (fonctionnalité future) -- [ ] Implémenter le système de vérification et d'envoi des alertes (fonctionnalité future) - -## Correction incohérence statistiques - -- [x] Identifier la source de l'incohérence entre tableau de bord principal et analytique -- [x] Uniformiser les calculs de répartition par fonction -- [x] Vérifier la cohérence des données affichées - -## Ajout graphique répartition par établissement - -- [x] Ajouter le graphique de répartition par établissement sur le tableau de bord principal -- [x] Placer le graphique à côté du graphique par fonction - -## Amélioration affichage gestion des séquences - -- [x] Afficher le taux de remplissage avec couleur conditionnelle (vert <70%, orange 70-90%, rouge >90%) -- [x] Changer la couleur de l'icône corbeille en rouge - -## Amélioration visuelle fenêtre des séquences - -- [x] Changer la couleur de l'icône œil en bleu -- [x] Coloriser le texte des formations avec une couleur unique par formation (éviter vert, orange, rouge) - -## Correction algorithme de couleur des formations - -- [x] Corriger la fonction getFormationColor pour que chaque formation ait une couleur unique - -## Amélioration visuelle page des apprenants - -- [x] Afficher l'icône corbeille en rouge -- [x] Afficher l'icône œil en bleu -- [x] Coloriser la fonction (Directeurs, Chefs de service) comme dans les séquences - -## Badges de statut apprenants et vue établissements - -- [x] Ajouter des badges "Actif" (vert) / "Inactif" (gris) dans la liste des apprenants -- [x] Créer les requêtes backend pour récupérer les statistiques par établissement -- [x] Créer une page "Établissements" avec liste et statistiques -- [x] Ajouter le lien dans le menu de navigation - -## Corrections bugs interface - -- [x] Corriger l'erreur d'affichage au clic sur un événement du calendrier (ajout import useState) -- [x] Corriger le problème de menu invisible sur la page Établissements (ajout DashboardLayout) - -## Correction erreur React #31 dans le calendrier - -- [x] Corriger l'erreur d'objet passé comme enfant React dans le modal de détails du calendrier - -## Réorganisation du menu du tableau de bord - -- [x] Réorganiser le menu en 4 zones distinctes avec séparateurs visuels - - Zone 1 : Tableau de bord - - Zone 2 : Gestion (Formations, Séquences, Calendrier, Apprenants) - - Zone 3 : Configuration (Utilisateurs, Rappels, Templates d'emails, Configuration SMTP) - - Zone 4 : Analyses (Établissements, Tableau de bord analytique, Rapport public cible) - -## Modification couleur titres menu - -- [x] Changer la couleur des titres des zones du menu en bleu - -## Génération documents de déploiement - -- [ ] Créer le guide de déploiement complet (GUIDE_DEPLOIEMENT.md) -- [ ] Générer les scripts de déploiement automatisés (deploy.sh, update.sh) -- [ ] Créer la documentation de configuration (CONFIGURATION.md) -- [ ] Créer le fichier de notes de version (RELEASE_NOTES.md) - -## Bug calendrier - Erreur React #31 au survol des vignettes - -- [x] Analyser l'erreur React #31 dans le calendrier -- [x] Identifier la cause de l'erreur lors du survol des vignettes -- [x] Corriger le code pour résoudre l'erreur -- [x] Tester le calendrier après correction - -## Améliorations du calendrier - -### Filtres du calendrier -- [x] Ajouter un filtre par formation -- [x] Ajouter un filtre par lieu -- [x] Ajouter un filtre par public cible -- [x] Implémenter la logique de filtrage dans la vue calendrier -- [x] Tester les filtres avec différentes combinaisons - -### Vues alternatives du calendrier -- [x] Créer la vue hebdomadaire -- [x] Créer la vue journalière -- [x] Ajouter un sélecteur de vue (mois/semaine/jour) -- [x] Adapter l'affichage des séquences pour chaque vue -- [x] Tester la navigation entre les vues - -### Export du calendrier -- [x] Implémenter l'export PDF du calendrier mensuel -- [x] Implémenter l'export iCal pour partage avec les formateurs -- [x] Ajouter les boutons d'export dans l'interface -- [x] Tester les exports avec différents mois - -## Bug création de séquence avec public cible "Tous" - -- [x] Reproduire le bug avec le public cible "Tous" -- [x] Identifier la cause du problème dans le code -- [x] Corriger le bug -- [x] Tester la création/modification avec public cible "Tous" - -## Problème de démarrage du service sur VPS - -- [x] Diagnostiquer la cause de l'erreur (code exit 1) -- [x] Vérifier les logs du service -- [x] Identifier les problèmes de configuration (absence de build) -- [x] Fournir les solutions de correction -- [x] Créer une documentation de dépannage - -## Import Excel - Formations et Séquences - -- [x] Créer le template Excel avec colonnes définies -- [x] Développer la procédure backend pour parser le fichier Excel -- [x] Implémenter la validation des données importées -- [x] Créer la logique d'insertion en base de données -- [x] Développer l'interface frontend d'upload -- [x] Ajouter la prévisualisation des données avant import -- [x] Gérer les erreurs et retours utilisateur -- [x] Créer la documentation utilisateur avec exemple -- [x] Tester l'import avec différents fichiers Excel - -## Bug import Excel - Erreur lors de la création des formations - -- [x] Identifier la cause de l'erreur "Cannot read properties of undefined (reading 'nom')" (conflit de nom) -- [x] Corriger la logique d'insertion des formations (renommage des imports) -- [x] Tester l'import avec le fichier Excel d'exemple -- [x] Vérifier que les formations et séquences sont bien créées - -## Bug import Excel - Erreur de nom de colonne lienInscription vs lienUnique - -- [x] Vérifier le schéma de la table formations -- [x] Corriger le nom de colonne dans importExcel.ts (lienInscription → lienUnique) -- [x] Tester l'import après correction - -## Bug import Excel - Formation non trouvée après création - -- [ ] Analyser le fichier Excel de test fourni -- [ ] Identifier pourquoi la formation n'est pas trouvée dans la map -- [ ] Corriger la logique d'import -- [ ] Tester avec le fichier Excel fourni - -## Bug import Excel - Erreur nom de colonne publicCible - -- [x] Vérifier le schéma de la table formations en base de données (publicCible n'existe pas dans formations) -- [x] Corriger le code d'import (retiré publicCible de l'insertion formations) -- [x] Tester l'import après correction - -## Bug import Excel - Problème persistant "Formation non trouvée" - -- [x] Analyser les logs serveur pour voir si la formation est réellement créée (ID = NaN) -- [x] Vérifier si l'erreur vient de la création ou de la recherche dans la map (problème de récupération ID) -- [x] Corriger le problème identifié (utilisation de $returningId() pour formations et séquences, ajout ordre pour dates) -- [x] Tester l'import et valider que tout fonctionne - -## Bug décalage horaire - Heures de séquences - -- [x] Identifier où se produit le décalage horaire (+1h) (conversion locale vs UTC) -- [x] Vérifier la gestion des fuseaux horaires dans le formulaire -- [x] Corriger la conversion des dates/heures (UTC pour affichage et envoi) -- [x] Tester la saisie et l'affichage des heures - -## Bug décalage horaire dans formulaire d'édition - -- [x] Identifier pourquoi le formulaire affiche -2h (ex: 9h00 → 7h00) (utilisation de méthodes UTC au lieu de locales) -- [x] Corriger la fonction safeFormatDate (utiliser getHours() au lieu de getUTCHours()) -- [ ] Tester l'édition de séquences - -## Bug page d'import Excel sans menu - -- [x] Ajouter le DashboardLayout wrapper à la page d'import Excel -- [x] Vérifier que le menu de navigation s'affiche correctement - -## Déplacement menu Import Excel - -- [x] Déplacer "Import Excel" du bloc GESTION vers le bloc CONFIGURATION -- [x] Vérifier l'affichage dans le menu de navigation - -## Scripts de mise à jour VPS - -- [x] Créer le script de mise à jour automatique (update-app.sh) -- [x] Créer le script de sauvegarde avant mise à jour (backup-before-update.sh) -- [x] Créer la documentation de mise à jour (GUIDE_MISE_A_JOUR.md) -- [x] Créer le package de déploiement complet (formation-manager-update-*.tar.gz) - -## Correction nom répertoire dans scripts - -- [x] Corriger APP_DIR dans backup-before-update.sh -- [x] Corriger APP_DIR dans update-app.sh -- [x] Corriger SERVICE_NAME dans tous les fichiers -- [x] Corriger la documentation (GUIDE_MISE_A_JOUR.md et README.md) -- [x] Recréer le package de déploiement (formation-manager-update-20251127_095935.tar.gz) - -## Bug drizzle-kit not found - -- [x] Corriger update-app.sh pour installer toutes les dépendances (pas seulement --prod) -- [x] Corriger GUIDE_MISE_A_JOUR.md -- [x] Recréer le package de déploiement (formation-manager-update-20251127_114306.tar.gz) - -## Envoi emails via SMTP (sans Resend) - -- [x] Analyser le code d'envoi d'emails actuel -- [x] Ajouter les champs SMTP dans le schéma de base de données -- [x] Installer nodemailer -- [x] Créer une fonction d'envoi SMTP avec nodemailer (smtpSender.ts) -- [x] Modifier emailSender.ts pour supporter SMTP -- [x] Ajouter l'interface SMTP dans AdminEmailConfig.tsx -- [x] Tester l'envoi d'emails via SMTP (interface visible dans Configuration SMTP) - -## Bug enregistrement configuration SMTP - -- [x] Vérifier les procédures tRPC emailConfig -- [x] Corriger la mutation upsert pour supporter les champs SMTP -- [x] Tester l'enregistrement de la configuration SMTP (champs ajoutés dans l'input tRPC) - -## Bug email de test envoyé à la mauvaise adresse - -- [x] Vérifier la fonction sendEmail dans emailSender.ts -- [x] Vérifier la fonction sendEmailViaSMTP dans smtpSender.ts -- [x] Vérifier pourquoi le mode production ne peut pas être activé (le switch fonctionne) -- [x] Corriger l'affichage du statut pour montrer SMTP au lieu de Resend -- [x] Ajouter des alertes spécifiques pour SMTP -- [x] Corriger la fonction upsertEmailConfig pour mettre à jour au lieu de créer -- [x] Forcer la mise à jour du provider dans la base de données -- [x] Vérifier que la configuration SMTP est bien enregistrée (provider=smtp) -- [x] Tester l'envoi d'email de test en mode production (correction du cache appliquée) - -## Bug email de test SMTP non reçu - -- [x] Vérifier les logs du serveur pour voir les erreurs SMTP -- [x] Vérifier que le mot de passe SMTP est correct -- [x] Identifier que le mail de simulation est envoyé au lieu de SMTP -- [x] Corriger le problème de cache React Query qui garde provider='resend' -- [x] Forcer l'invalidation du cache après enregistrement (utils.emailConfig.get.invalidate()) - -## Erreur "Unexpected token '<'" sur /admin/email-config - -- [x] Vérifier les logs du serveur pour voir l'erreur exacte (erreur temporaire liée au redémarrage) -- [x] Tester le chargement de la page (fonctionne correctement) - -## Bug envoi email fonctionne en dev mais pas en production - -- [x] Vérifier si nodemailer est dans les dependencies (OK, ligne 75) -- [ ] Vérifier que la configuration SMTP est bien enregistrée en production -- [ ] Ajouter une page de diagnostic pour vérifier la config en production -- [ ] Corriger le problème - -## Bug emails de rappel utilisent mail de test au lieu de SMTP - -- [x] Trouver où les emails de rappel sont envoyés dans le code (sendGroupEmail) -- [x] Vérifier qu'ils utilisent bien sendEmail() (OK, ligne 249 emailService.ts) -- [x] Vérifier le filtre des inscriptions (statut='confirmee' ligne 517) -- [x] Confirmer qu'il y a des apprenants confirmés avec vraies adresses -- [x] Ajouter des logs pour diagnostiquer l'envoi (console.log dans routers.ts) -- [x] Ajouter un message de retour avec le nombre d'emails envoyés -- [x] Tester l'envoi de rappel et vérifier les logs (fonctionne correctement) - -## Bug tag {nom de la formation} non remplacé dans email teaser - -- [x] Trouver où le template d'email est généré (emailTemplateGenerator.ts) -- [x] Vérifier le système de remplacement des tags ({{nomFormation}}) -- [x] Corriger le remplacement du tag (ajout des variables dans sendTeaserEmail et sendRappelJ7Email) -- [x] Corriger et préparer pour test en production - -## Email teaser sans formateur ni lieu - -- [x] Vérifier les données disponibles dans sendTeaserEmail -- [x] Ajouter le formateur et le lieu dans les paramètres de sendTeaserEmail -- [x] Récupérer le formateur depuis la base de données dans routers.ts -- [x] Modifier le template pour afficher lieu et formateur -- [x] Ajouter lieu et formateur dans les variables -- [x] Préparer pour test en production - -## Personnalisation templates d'emails avec variables - -- [x] Vérifier que les templates personnalisés utilisent bien les variables (OK, replaceEmailVariables) -- [x] Documenter les variables disponibles dans l'interface (OK, boutons d'insertion présents) -- [x] Les variables {{lieu}} et {{formateur}} sont déjà disponibles - -## Aperçu avant envoi d'emails - -- [x] Créer une procédure tRPC pour générer un aperçu d'email (emailPreview.ts) -- [x] Ajouter un bouton "Aperçu" dans l'interface d'envoi (AdminSequenceInscrits.tsx) -- [x] Afficher un modal avec le rendu de l'email (Dialog avec iframe) -- [x] Utiliser le premier apprenant confirmé pour l'aperçu - -## Email de rappel J-1 - -- [x] Créer la fonction sendRappelJ1Email dans emailService.ts -- [x] Ajouter un bouton "Envoyer rappel J-1" dans l'interface -- [x] Créer le template d'email de rappel J-1 -- [x] Ajouter le bouton d'aperçu pour rappel J-1 -- [x] Modifier la procédure tRPC pour supporter rappel_j1 -- [x] Préparer pour test (serveur redémarré) - -## Correction template email rappel J-1 - -- [x] Ajouter le template de rappel J-1 dans la page de gestion des templates d'emails - -## Correction template email J-7 - -- [x] Analyser le template J-7 dans la page des templates d'emails -- [x] Analyser l'aperçu J-7 depuis les séquences -- [x] Identifier les différences entre les deux -- [x] Corriger le template pour qu'il corresponde à l'aperçu des séquences - -## Amélioration template J-7 : afficher toutes les dates - -- [ ] Analyser comment le template précédent affichait toutes les dates -- [ ] Modifier le système de génération pour passer toutes les dates au template -- [ ] Mettre à jour le template J-7 en base de données - -## Amélioration template J-7 - Affichage de toutes les dates - -- [x] Ajouter la variable datesHTML dans EmailVariables -- [x] Générer datesHTML dans emailPreview.ts -- [x] Ajouter datesHTML dans sendRappelJ7Email -- [x] Ajouter datesHTML dans sendRappelJ1Email -- [x] Ajouter datesHTML dans sendTeaserEmail -- [x] Mettre à jour le template J-7 en base de données avec {{datesHTML}} -- [x] Mettre à jour le template J-1 en base de données avec {{datesHTML}} -- [x] Mettre à jour les templates par défaut dans db.ts -- [x] Tester l'affichage du template J-7 dans la page des templates - -## Configuration finale des emails et rappels - -- [x] Créer un guide complet pour personnaliser les couleurs et le logo des templates J-7 et J-1 -- [x] Créer un guide complet pour tester l'envoi réel d'emails avec aperçu -- [x] Créer un guide complet pour configurer les rappels automatiques pour J-7 et J-1 - -## Conversion du guide en Word - -- [x] Convertir le guide de configuration des emails en format Word (.docx) - -## Bug SMTP - Mode simulation persistant - -- [x] Analyser le code d'envoi d'emails pour identifier pourquoi le mode simulation persiste -- [x] Corriger le double envoi (email réel + notification simulée) -- [ ] Tester l'envoi réel d'emails après correction - -## Bug Rappels - Types J-7 et J-1 manquants - -- [x] Analyser la page des rappels pour identifier pourquoi les types J-7 et J-1 n'apparaissent pas -- [x] Ajouter les types J-7 et J-1 dans l'interface de gestion des rappels -- [ ] Tester la création de rappels J-7 et J-1 - -## Correction template email rappel J-1 - -- [x] Ajouter le template de rappel J-1 dans la page de gestion des templates d'emails - -## Correction template email J-7 - -- [x] Analyser le template J-7 dans la page des templates d'emails -- [x] Analyser l'aperçu J-7 depuis les séquences -- [x] Identifier les différences entre les deux -- [x] Corriger le template pour qu'il corresponde à l'aperçu des séquences - -## Amélioration template J-7 - -- [x] Modifier le template J-7 pour afficher toutes les dates de la séquence au lieu d'une seule date - -## Bug SMTP - Mode simulation - -- [x] Analyser le code d'envoi d'emails pour identifier pourquoi le mode simulation persiste -- [x] Corriger le double envoi (email réel + notification simulée) -- [x] Tester l'envoi réel d'emails après correction - -## Bug Rappels - Types J-7 et J-1 manquants - -- [x] Analyser la page des rappels pour identifier pourquoi les types J-7 et J-1 n'apparaissent pas -- [x] Ajouter les types J-7 et J-1 dans l'interface de gestion des rappels -- [ ] Corriger le bug d'insertion SQL (erreur sur derniereExecution) -- [ ] Tester la création de rappels J-7 et J-1 - -## Finalisation des rappels automatiques - -- [x] Corriger le bug d'insertion SQL (champ derniereExecution) -- [x] Tester la création de rappels J-7 et J-1 -- [x] Créer un job automatique qui vérifie quotidiennement les séquences à venir -- [x] Implémenter la logique d'envoi automatique des rappels selon les règles configurées -- [x] Tester l'envoi automatique en conditions réelles (scheduler actif, vérification toutes les heures) - -## Personnalisation des templates d'emails -- [x] Ajouter le logo Itinova dans les templates J-7 et J-1 -- [x] Ajuster les couleurs de l'en-tête selon la charte graphique Itinova (Bleu #283581, Bleu clair #0578BE, Orange #E94E1B, Vert #45ac34) -- [x] Appliquer un style sobre et professionnelt liens -- [x] Tester le rendu des templates personnalisés - -## Bug Email Confirmation - Variables non remplacées - -- [x] Analyser la fonction d'envoi d'email de confirmation d'inscription -- [x] Identifier pourquoi {{prenom}} et autres variables ne sont pas remplacées -- [x] Corriger le remplacement des variables (ajout d'alias pour compatibilité) -- [x] Corriger le code pour supporter les deux formats de variables ({{prenom}} et {{prenomApprenant}}) -- [ ] Tester l'envoi d'un email de confirmation en production - -## Bug Inscriptions - Inscriptions annulées toujours affichées - -- [x] Analyser où les inscriptions annulées ne sont pas filtrées (affichage, emails, rappels) -- [x] Corriger getInscriptionsBySequence pour exclure les inscriptions annulées -- [x] Corriger getInscriptionsByApprenant pour exclure les inscriptions annulées -- [x] Vérifier que les rappels ne sont pas envoyés aux inscriptions annulées (déjà filtré) -- [ ] Tester l'affichage après annulation d'une inscription - -## Bug Inscription - Détection incorrecte d'inscription existante - -- [x] Analyser la fonction checkExistingInscription -- [x] Corriger le filtrage pour ne pas considérer les inscriptions annulées comme existantes -- [ ] Tester la réinscription après annulation - -## Bug Inscription - Mauvais apprenant associé lors de l'inscription - -- [x] Analyser le processus d'inscription (page Inscription.tsx et procédure tRPC) -- [x] Identifier pourquoi l'inscription de MONROE Marylin crée une inscription pour PIGNOL François -- [x] Corriger le code pour associer le bon apprenant lors de l'inscription (création de createPublic) -- [ ] Tester l'inscription avec différents apprenants pour vérifier la correction - -## Bug Email Confirmation - Variable {{prenomApprenant}} non remplacée - -- [x] Analyser la fonction sendInscriptionConfirmation dans email.ts -- [x] Identifier pourquoi {{prenomApprenant}} n'est pas remplacé (affiche "Cher(e) {{prenomApprenant}} monroe") -- [x] Corriger le remplacement des variables dans l'email de confirmation (mise à jour du bodyContent du template) -- [ ] Tester l'envoi d'un email de confirmation avec les bonnes variables - -## Ajout du bouton {{datesHTML}} dans les templates d'emails - -- [x] Localiser l'interface AdminEmailTemplates -- [x] Ajouter le bouton pour insérer {{datesHTML}} dans la liste des variables disponibles -- [x] Ajouter le remplacement de {{datesHTML}} dans la prévisualisation -- [ ] Tester l'insertion de la variable dans un template - -## Vérification invitations ICS - Une invitation par date de séquence - -- [ ] Analyser le code de génération des invitations ICS dans sendInscriptionConfirmation -- [ ] Vérifier qu'il y a bien une invitation ICS générée pour chaque date de la séquence -- [ ] Corriger si nécessaire -- [ ] Tester l'envoi d'un email avec plusieurs dates - -## Bug Email Confirmation - Variable {{datesHTML}} non remplacée - -- [x] Analyser pourquoi {{datesHTML}} n'est pas remplacée dans l'email de confirmation -- [x] Ajouter la variable datesHTML dans l'objet variables de sendInscriptionConfirmation -- [x] Générer le HTML formaté des dates pour cette variable (déjà présent) -- [ ] Tester l'envoi d'un email de confirmation avec {{datesHTML}} +[... contenu existant conservé ...] + +## Questionnaires de satisfaction et évaluation des acquis + +- [x] Créer le schéma de base de données (tables questionnaires, questions, réponses) +- [x] Développer les procédures backend tRPC pour la gestion des questionnaires +- [ ] Créer l'interface d'administration des questionnaires +- [ ] Créer l'interface publique de réponse aux questionnaires +- [ ] Implémenter l'envoi automatique post-formation +- [ ] Créer les tableaux de bord d'analyse avec graphiques (satisfaction par formation/formateur) +- [ ] Implémenter les exports Excel/PDF des feedbacks +- [ ] Tester le système complet (création, envoi, réponse, analyse) +- [ ] Déployer sur le serveur VPS