Checkpoint: Corrections multiples :
- Renommage questionnaire.nom → questionnaire.titre dans QuestionnaireReponse.tsx, AdminQuestionnaireEdit.tsx, questionnaireScheduler.ts, questionnaireExport.ts - Correction des requêtes SQL dans questionnaireDb.ts (suppression de la condition complete inexistante) - Ajout des colonnes valeurMin et valeurMax dans la table questions - Renommage de la colonne nom en titre dans la table questionnaires
This commit is contained in:
90
.manus/db/db-query-1768376558906.json
Normal file
90
.manus/db/db-query-1768376558906.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"query": "DESCRIBE questionnaires;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute DESCRIBE questionnaires;",
|
||||
"rows": [
|
||||
{
|
||||
"Field": "id",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "PRI",
|
||||
"Default": "NULL",
|
||||
"Extra": "auto_increment"
|
||||
},
|
||||
{
|
||||
"Field": "nom",
|
||||
"Type": "varchar(255)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "type",
|
||||
"Type": "enum('satisfaction','evaluation_pre','evaluation_post')",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "description",
|
||||
"Type": "text",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "formationId",
|
||||
"Type": "int(11)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "actif",
|
||||
"Type": "tinyint(1)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "1",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "envoiAutomatique",
|
||||
"Type": "tinyint(1)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "0",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "delaiEnvoiJours",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "1",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "createdAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "updatedAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": "DEFAULT_GENERATED on update CURRENT_TIMESTAMP"
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint(11)\tNO\tPRI\tNULL\tauto_increment\nnom\tvarchar(255)\tNO\t\tNULL\t\ntype\tenum('satisfaction','evaluation_pre','evaluation_post')\tNO\t\tNULL\t\ndescription\ttext\tYES\t\tNULL\t\nformationId\tint(11)\tYES\t\tNULL\t\nactif\ttinyint(1)\tNO\t\t1\t\nenvoiAutomatique\ttinyint(1)\tNO\t\t0\t\ndelaiEnvoiJours\tint(11)\tNO\t\t1\t\ncreatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nupdatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\tDEFAULT_GENERATED on update CURRENT_TIMESTAMP\n",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 710
|
||||
}
|
||||
40
.manus/db/db-query-1768376568790.json
Normal file
40
.manus/db/db-query-1768376568790.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"query": "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'questionnaires';",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'questionnaires';",
|
||||
"rows": [
|
||||
{
|
||||
"COLUMN_NAME": "id"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "nom"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "type"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "description"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "formationId"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "actif"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "envoiAutomatique"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "delaiEnvoiJours"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "createdAt"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "updatedAt"
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"stdout": "COLUMN_NAME\nid\nnom\ntype\ndescription\nformationId\nactif\nenvoiAutomatique\ndelaiEnvoiJours\ncreatedAt\nupdatedAt\n",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 61
|
||||
}
|
||||
90
.manus/db/db-query-1768376575187.json
Normal file
90
.manus/db/db-query-1768376575187.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"query": "SHOW COLUMNS FROM questionnaires;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SHOW COLUMNS FROM questionnaires;",
|
||||
"rows": [
|
||||
{
|
||||
"Field": "id",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "PRI",
|
||||
"Default": "NULL",
|
||||
"Extra": "auto_increment"
|
||||
},
|
||||
{
|
||||
"Field": "nom",
|
||||
"Type": "varchar(255)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "type",
|
||||
"Type": "enum('satisfaction','evaluation_pre','evaluation_post')",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "description",
|
||||
"Type": "text",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "formationId",
|
||||
"Type": "int(11)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "actif",
|
||||
"Type": "tinyint(1)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "1",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "envoiAutomatique",
|
||||
"Type": "tinyint(1)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "0",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "delaiEnvoiJours",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "1",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "createdAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "updatedAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": "DEFAULT_GENERATED on update CURRENT_TIMESTAMP"
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint(11)\tNO\tPRI\tNULL\tauto_increment\nnom\tvarchar(255)\tNO\t\tNULL\t\ntype\tenum('satisfaction','evaluation_pre','evaluation_post')\tNO\t\tNULL\t\ndescription\ttext\tYES\t\tNULL\t\nformationId\tint(11)\tYES\t\tNULL\t\nactif\ttinyint(1)\tNO\t\t1\t\nenvoiAutomatique\ttinyint(1)\tNO\t\t0\t\ndelaiEnvoiJours\tint(11)\tNO\t\t1\t\ncreatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nupdatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\tDEFAULT_GENERATED on update CURRENT_TIMESTAMP\n",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 62
|
||||
}
|
||||
9
.manus/db/db-query-1768376586515.json
Normal file
9
.manus/db/db-query-1768376586515.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "ALTER TABLE questionnaires CHANGE COLUMN nom titre VARCHAR(255) NOT NULL;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE questionnaires CHANGE COLUMN nom titre VARCHAR(255) NOT NULL;",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 172
|
||||
}
|
||||
114
.manus/db/db-query-1768376894838.json
Normal file
114
.manus/db/db-query-1768376894838.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"query": "SHOW COLUMNS FROM questions;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SHOW COLUMNS FROM questions;",
|
||||
"rows": [
|
||||
{
|
||||
"Field": "id",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "PRI",
|
||||
"Default": "NULL",
|
||||
"Extra": "auto_increment"
|
||||
},
|
||||
{
|
||||
"Field": "questionnaireId",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "ordre",
|
||||
"Type": "int(11)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "texte",
|
||||
"Type": "text",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "typeQuestion",
|
||||
"Type": "enum('choix_multiple','echelle','texte_libre','oui_non')",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "options",
|
||||
"Type": "text",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "echelleMin",
|
||||
"Type": "int(11)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "echelleMax",
|
||||
"Type": "int(11)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "echelleLabelMin",
|
||||
"Type": "varchar(255)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "echelleLabelMax",
|
||||
"Type": "varchar(255)",
|
||||
"Null": "YES",
|
||||
"Key": "",
|
||||
"Default": "NULL",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "obligatoire",
|
||||
"Type": "tinyint(1)",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "0",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "createdAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": ""
|
||||
},
|
||||
{
|
||||
"Field": "updatedAt",
|
||||
"Type": "timestamp",
|
||||
"Null": "NO",
|
||||
"Key": "",
|
||||
"Default": "CURRENT_TIMESTAMP",
|
||||
"Extra": "DEFAULT_GENERATED on update CURRENT_TIMESTAMP"
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint(11)\tNO\tPRI\tNULL\tauto_increment\nquestionnaireId\tint(11)\tNO\t\tNULL\t\nordre\tint(11)\tNO\t\tNULL\t\ntexte\ttext\tNO\t\tNULL\t\ntypeQuestion\tenum('choix_multiple','echelle','texte_libre','oui_non')\tNO\t\tNULL\t\noptions\ttext\tYES\t\tNULL\t\nechelleMin\tint(11)\tYES\t\tNULL\t\nechelleMax\tint(11)\tYES\t\tNULL\t\nechelleLabelMin\tvarchar(255)\tYES\t\tNULL\t\nechelleLabelMax\tvarchar(255)\tYES\t\tNULL\t\nobligatoire\ttinyint(1)\tNO\t\t0\t\ncreatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nupdatedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\tDEFAULT_GENERATED on update CURRENT_TIMESTAMP\n",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 57
|
||||
}
|
||||
49
.manus/db/db-query-1768376902477.json
Normal file
49
.manus/db/db-query-1768376902477.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"query": "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'questions';",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'questions';",
|
||||
"rows": [
|
||||
{
|
||||
"COLUMN_NAME": "id"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "questionnaireId"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "ordre"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "texte"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "typeQuestion"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "options"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "echelleMin"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "echelleMax"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "echelleLabelMin"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "echelleLabelMax"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "obligatoire"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "createdAt"
|
||||
},
|
||||
{
|
||||
"COLUMN_NAME": "updatedAt"
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"stdout": "COLUMN_NAME\nid\nquestionnaireId\nordre\ntexte\ntypeQuestion\noptions\nechelleMin\nechelleMax\nechelleLabelMin\nechelleLabelMax\nobligatoire\ncreatedAt\nupdatedAt\n",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 62
|
||||
}
|
||||
9
.manus/db/db-query-1768376942069.json
Normal file
9
.manus/db/db-query-1768376942069.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "ALTER TABLE questions ADD COLUMN IF NOT EXISTS valeurMin INT DEFAULT NULL; ALTER TABLE questions ADD COLUMN IF NOT EXISTS valeurMax INT DEFAULT NULL;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE questions ADD COLUMN IF NOT EXISTS valeurMin INT DEFAULT NULL; ALTER TABLE questions ADD COLUMN IF NOT EXISTS valeurMax INT DEFAULT NULL;",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 950
|
||||
}
|
||||
@@ -436,7 +436,7 @@ export default function AdminQuestionnaireEdit() {
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{nom || "Questionnaire sans titre"}</DialogTitle>
|
||||
<DialogTitle>{titre || "Questionnaire sans titre"}</DialogTitle>
|
||||
{description && <p className="text-sm text-muted-foreground mt-2">{description}</p>}
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 mt-4">
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function QuestionnaireReponse() {
|
||||
{APP_LOGO && (
|
||||
<img src={APP_LOGO} alt={APP_TITLE} className="h-16 mx-auto mb-4" />
|
||||
)}
|
||||
<h1 className="text-3xl font-bold mb-2">{data.questionnaire?.nom || 'Questionnaire'}</h1>
|
||||
<h1 className="text-3xl font-bold mb-2">{data.questionnaire?.titre || 'Questionnaire'}</h1>
|
||||
{data.questionnaire?.description && (
|
||||
<p className="text-muted-foreground">{data.questionnaire.description}</p>
|
||||
)}
|
||||
|
||||
77
create-deployment-package.sh
Executable file
77
create-deployment-package.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de création du package de déploiement
|
||||
# Version: 1.0
|
||||
# Date: 13 janvier 2026
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Création du package de déploiement ==="
|
||||
echo ""
|
||||
|
||||
# Variables
|
||||
PROJECT_DIR="/home/ubuntu/formation-manager-itinova"
|
||||
PACKAGE_DIR="$PROJECT_DIR/deployment-package"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
ARCHIVE_NAME="formation-manager-itinova-${TIMESTAMP}.tar.gz"
|
||||
|
||||
# Nettoyer le répertoire de package
|
||||
echo "Nettoyage du répertoire de package..."
|
||||
rm -rf "$PACKAGE_DIR"
|
||||
mkdir -p "$PACKAGE_DIR"
|
||||
|
||||
# Créer la structure du package
|
||||
echo "Création de la structure du package..."
|
||||
mkdir -p "$PACKAGE_DIR/scripts"
|
||||
mkdir -p "$PACKAGE_DIR/docs"
|
||||
mkdir -p "$PACKAGE_DIR/source"
|
||||
|
||||
# Copier les scripts de déploiement
|
||||
echo "Copie des scripts de déploiement..."
|
||||
cp "$PROJECT_DIR/deploy.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/deploy-update.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/deploy-update-manual.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/deploy-update-targz.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/setup-env.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/setup-https.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/create-admin.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/reset-admin.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/diagnose.sh" "$PACKAGE_DIR/scripts/" 2>/dev/null || true
|
||||
|
||||
# Rendre les scripts exécutables
|
||||
chmod +x "$PACKAGE_DIR/scripts/"*.sh
|
||||
|
||||
# Copier la documentation
|
||||
echo "Copie de la documentation..."
|
||||
cp "$PROJECT_DIR/GUIDE_DEPLOIEMENT.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/DEPLOIEMENT-MANUEL.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/DEPLOIEMENT-TARGZ.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/INSTRUCTIONS-DEPLOIEMENT.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/GUIDE_CONFIGURATION_ATTESTATIONS.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/README.md" "$PACKAGE_DIR/docs/" 2>/dev/null || true
|
||||
|
||||
# Copier les sources (sans node_modules et fichiers de build)
|
||||
echo "Copie des sources de l'application..."
|
||||
rsync -av --progress \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
--exclude '.git' \
|
||||
--exclude 'deployment-package' \
|
||||
--exclude '*.log' \
|
||||
--exclude '.env' \
|
||||
--exclude '.DS_Store' \
|
||||
"$PROJECT_DIR/" "$PACKAGE_DIR/source/"
|
||||
|
||||
# Créer l'archive
|
||||
echo "Création de l'archive..."
|
||||
cd "$PROJECT_DIR"
|
||||
tar -czf "$ARCHIVE_NAME" -C deployment-package .
|
||||
|
||||
echo ""
|
||||
echo "=== Package créé avec succès ==="
|
||||
echo "Archive: $PROJECT_DIR/$ARCHIVE_NAME"
|
||||
echo "Taille: $(du -h "$ARCHIVE_NAME" | cut -f1)"
|
||||
echo ""
|
||||
echo "Pour transférer vers votre serveur VPS:"
|
||||
echo "scp $ARCHIVE_NAME user@your-server:/path/to/destination/"
|
||||
echo ""
|
||||
@@ -1,396 +0,0 @@
|
||||
# Documentation de Configuration - Formation Manager Itinova
|
||||
|
||||
**Version:** 7a737c05
|
||||
**Date:** 24 novembre 2025
|
||||
**Auteur:** Manus AI
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document détaille toutes les variables de configuration et les paramètres nécessaires pour personnaliser et sécuriser votre installation de Formation Manager Itinova.
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Toutes les variables d'environnement doivent être définies dans le fichier `.env` à la racine du projet. Ce fichier ne doit **jamais** être commité dans le dépôt Git pour des raisons de sécurité.
|
||||
|
||||
### Base de données
|
||||
|
||||
#### DATABASE_URL
|
||||
|
||||
**Type:** String (URL de connexion)
|
||||
**Requis:** Oui
|
||||
**Format:** `mysql://utilisateur:motdepasse@hote:port/nombase`
|
||||
|
||||
La chaîne de connexion complète à votre base de données MySQL ou TiDB. Cette URL contient toutes les informations nécessaires pour établir la connexion : utilisateur, mot de passe, hôte, port et nom de la base de données.
|
||||
|
||||
**Exemple:**
|
||||
```
|
||||
DATABASE_URL=mysql://formation_user:SecureP@ssw0rd@localhost:3306/formation_manager
|
||||
```
|
||||
|
||||
**Recommandations de sécurité:**
|
||||
- Utilisez un mot de passe fort d'au moins 16 caractères
|
||||
- Créez un utilisateur dédié avec uniquement les permissions nécessaires
|
||||
- Si possible, utilisez une connexion SSL/TLS (ajoutez `?ssl=true` à la fin de l'URL)
|
||||
- Pour TiDB Cloud, utilisez toujours SSL et les certificats fournis
|
||||
|
||||
---
|
||||
|
||||
### Authentification et sécurité
|
||||
|
||||
#### JWT_SECRET
|
||||
|
||||
**Type:** String (clé secrète)
|
||||
**Requis:** Oui
|
||||
**Longueur minimale:** 32 caractères (64 recommandés)
|
||||
|
||||
La clé secrète utilisée pour signer les tokens JWT (JSON Web Tokens) qui gèrent les sessions utilisateur. Cette clé est critique pour la sécurité de votre application.
|
||||
|
||||
**Génération d'une clé sécurisée:**
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**Exemple:**
|
||||
```
|
||||
JWT_SECRET=a3f8b2c9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
|
||||
```
|
||||
|
||||
**Important:** Ne réutilisez jamais la même clé entre différents environnements (développement, staging, production).
|
||||
|
||||
#### OAUTH_SERVER_URL
|
||||
|
||||
**Type:** URL
|
||||
**Requis:** Oui
|
||||
**Valeur par défaut:** `https://api.manus.im`
|
||||
|
||||
L'URL du serveur OAuth Manus utilisé pour l'authentification des utilisateurs. Cette valeur ne doit généralement pas être modifiée sauf si vous utilisez une instance Manus personnalisée.
|
||||
|
||||
#### VITE_OAUTH_PORTAL_URL
|
||||
|
||||
**Type:** URL
|
||||
**Requis:** Oui
|
||||
**Valeur par défaut:** `https://auth.manus.im`
|
||||
|
||||
L'URL du portail de connexion Manus où les utilisateurs sont redirigés pour s'authentifier. Cette URL est utilisée côté client (frontend).
|
||||
|
||||
---
|
||||
|
||||
### Identité du propriétaire
|
||||
|
||||
#### OWNER_OPEN_ID
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Oui
|
||||
|
||||
L'identifiant OpenID unique du propriétaire de l'application. Cet utilisateur aura automatiquement le rôle `admin` lors de sa première connexion et recevra les notifications système.
|
||||
|
||||
**Comment obtenir votre Open ID:**
|
||||
1. Connectez-vous à votre compte Manus
|
||||
2. Accédez à votre profil utilisateur
|
||||
3. Copiez votre Open ID depuis les paramètres du compte
|
||||
|
||||
#### OWNER_NAME
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Oui
|
||||
|
||||
Le nom complet du propriétaire de l'application, utilisé dans les notifications et les logs système.
|
||||
|
||||
**Exemple:**
|
||||
```
|
||||
OWNER_OPEN_ID=usr_abc123def456
|
||||
OWNER_NAME=Olivier Pareige
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuration de l'application
|
||||
|
||||
#### VITE_APP_ID
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Oui
|
||||
|
||||
L'identifiant unique de votre application Manus. Cette valeur est fournie lors de la création de votre application sur la plateforme Manus.
|
||||
|
||||
#### VITE_APP_TITLE
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Non
|
||||
**Valeur par défaut:** `Gestion des Formations Manager Itinova`
|
||||
|
||||
Le titre de l'application affiché dans l'interface utilisateur, les onglets du navigateur et les emails. Vous pouvez personnaliser ce titre selon vos besoins.
|
||||
|
||||
**Exemple:**
|
||||
```
|
||||
VITE_APP_TITLE=Formation Manager - Itinova
|
||||
```
|
||||
|
||||
#### VITE_APP_LOGO
|
||||
|
||||
**Type:** String (chemin relatif)
|
||||
**Requis:** Non
|
||||
**Valeur par défaut:** `/logo.png`
|
||||
|
||||
Le chemin vers le fichier logo de l'application. Le fichier doit être placé dans le répertoire `client/public/`. Le logo est affiché dans le header et les emails.
|
||||
|
||||
**Formats supportés:** PNG, SVG, JPG
|
||||
**Taille recommandée:** 200x50 pixels (ratio 4:1)
|
||||
|
||||
---
|
||||
|
||||
### APIs Manus intégrées
|
||||
|
||||
Ces variables sont généralement fournies automatiquement par la plateforme Manus lors du déploiement. Elles donnent accès aux services intégrés (LLM, stockage, notifications, etc.).
|
||||
|
||||
#### BUILT_IN_FORGE_API_URL
|
||||
|
||||
**Type:** URL
|
||||
**Requis:** Oui (pour les fonctionnalités avancées)
|
||||
|
||||
L'URL de base des APIs Manus Forge utilisées côté serveur.
|
||||
|
||||
#### BUILT_IN_FORGE_API_KEY
|
||||
|
||||
**Type:** String (clé API)
|
||||
**Requis:** Oui (pour les fonctionnalités avancées)
|
||||
|
||||
La clé d'authentification pour accéder aux APIs Manus côté serveur. Cette clé ne doit **jamais** être exposée côté client.
|
||||
|
||||
#### VITE_FRONTEND_FORGE_API_KEY
|
||||
|
||||
**Type:** String (clé API)
|
||||
**Requis:** Non
|
||||
|
||||
Une clé API distincte pour les appels côté client (frontend). Cette clé a des permissions limitées pour des raisons de sécurité.
|
||||
|
||||
#### VITE_FRONTEND_FORGE_API_URL
|
||||
|
||||
**Type:** URL
|
||||
**Requis:** Non
|
||||
|
||||
L'URL des APIs Manus accessibles depuis le frontend.
|
||||
|
||||
---
|
||||
|
||||
### Analytics (optionnel)
|
||||
|
||||
#### VITE_ANALYTICS_ENDPOINT
|
||||
|
||||
**Type:** URL
|
||||
**Requis:** Non
|
||||
|
||||
L'URL de votre service d'analytics (compatible Plausible/Umami). Si défini, les statistiques de visite seront envoyées à ce endpoint.
|
||||
|
||||
#### VITE_ANALYTICS_WEBSITE_ID
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Non (sauf si VITE_ANALYTICS_ENDPOINT est défini)
|
||||
|
||||
L'identifiant unique de votre site dans le système d'analytics.
|
||||
|
||||
**Exemple:**
|
||||
```
|
||||
VITE_ANALYTICS_ENDPOINT=https://analytics.example.com
|
||||
VITE_ANALYTICS_WEBSITE_ID=formation-manager-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuration de l'environnement
|
||||
|
||||
#### NODE_ENV
|
||||
|
||||
**Type:** String
|
||||
**Requis:** Oui
|
||||
**Valeurs possibles:** `development`, `production`, `test`
|
||||
|
||||
Définit l'environnement d'exécution de l'application. En production, cette valeur **doit** être `production` pour activer les optimisations et désactiver les outils de développement.
|
||||
|
||||
#### PORT
|
||||
|
||||
**Type:** Number
|
||||
**Requis:** Non
|
||||
**Valeur par défaut:** `3000`
|
||||
|
||||
Le port sur lequel le serveur Express écoute les connexions. Si vous utilisez Nginx comme reverse proxy, gardez la valeur par défaut.
|
||||
|
||||
---
|
||||
|
||||
## Configuration SMTP (emails)
|
||||
|
||||
L'application utilise un serveur SMTP pour envoyer les emails automatiques (confirmations, rappels, teasers). La configuration SMTP se fait via l'interface web dans la section "Configuration SMTP" du menu.
|
||||
|
||||
### Paramètres SMTP requis
|
||||
|
||||
| Paramètre | Description | Exemple |
|
||||
|-----------|-------------|---------|
|
||||
| Hôte SMTP | Adresse du serveur SMTP | `smtp.gmail.com` |
|
||||
| Port | Port du serveur (25, 465, 587) | `587` |
|
||||
| Sécurité | TLS ou SSL | `TLS` |
|
||||
| Utilisateur | Adresse email d'envoi | `noreply@itinova.org` |
|
||||
| Mot de passe | Mot de passe ou app password | `****************` |
|
||||
|
||||
### Fournisseurs SMTP recommandés
|
||||
|
||||
**Gmail** : Gratuit jusqu'à 500 emails/jour. Nécessite un "App Password" si 2FA activé.
|
||||
**SendGrid** : 100 emails/jour gratuits, excellent pour la délivrabilité.
|
||||
**Amazon SES** : Très économique, $0.10 pour 1000 emails.
|
||||
**Mailgun** : 5000 emails/mois gratuits les 3 premiers mois.
|
||||
|
||||
### Test de configuration
|
||||
|
||||
Après avoir configuré SMTP, utilisez la fonction "Envoyer un email de test" dans l'interface pour vérifier que les emails sont bien envoyés et reçus.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Nginx
|
||||
|
||||
### Certificat SSL
|
||||
|
||||
Pour activer HTTPS, vous devez obtenir un certificat SSL. La méthode recommandée est d'utiliser Let's Encrypt (gratuit) :
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d votre-domaine.com
|
||||
```
|
||||
|
||||
Certbot configurera automatiquement Nginx et renouvellera le certificat avant expiration.
|
||||
|
||||
### Optimisations recommandées
|
||||
|
||||
**Compression Gzip** : Déjà activée dans la configuration fournie, réduit la bande passante de 70%.
|
||||
|
||||
**Cache des assets statiques** : Les fichiers CSS, JS et images sont mis en cache 1 an côté client pour améliorer les performances.
|
||||
|
||||
**HTTP/2** : Activé par défaut avec `http2` dans la directive `listen 443 ssl http2`.
|
||||
|
||||
**Rate limiting** : Pour protéger contre les attaques DDoS, ajoutez dans votre configuration Nginx :
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://formation_backend;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sécurité avancée
|
||||
|
||||
### Rotation des secrets
|
||||
|
||||
Les clés sensibles (JWT_SECRET, mots de passe DB) doivent être changées régulièrement :
|
||||
|
||||
1. **Générer une nouvelle clé JWT**
|
||||
2. **Mettre à jour `.env`**
|
||||
3. **Redémarrer l'application**
|
||||
4. **Tous les utilisateurs devront se reconnecter**
|
||||
|
||||
### Sauvegarde des configurations
|
||||
|
||||
Sauvegardez régulièrement votre fichier `.env` dans un gestionnaire de secrets sécurisé (pas dans Git) :
|
||||
|
||||
- **Vault** (HashiCorp)
|
||||
- **AWS Secrets Manager**
|
||||
- **Azure Key Vault**
|
||||
- **1Password** (pour les petites équipes)
|
||||
|
||||
### Audit de sécurité
|
||||
|
||||
Vérifiez régulièrement :
|
||||
|
||||
- [ ] Les permissions de fichiers (`.env` doit être en 600)
|
||||
- [ ] Les logs d'accès pour détecter des comportements suspects
|
||||
- [ ] Les mises à jour de sécurité des dépendances (`pnpm audit`)
|
||||
- [ ] La force des mots de passe de base de données
|
||||
- [ ] L'expiration des certificats SSL
|
||||
|
||||
---
|
||||
|
||||
## Personnalisation
|
||||
|
||||
### Modification du logo
|
||||
|
||||
1. Placez votre nouveau logo dans `client/public/`
|
||||
2. Mettez à jour `VITE_APP_LOGO` dans `.env`
|
||||
3. Modifiez également `APP_LOGO` dans `client/src/const.ts`
|
||||
4. Rebuild l'application : `pnpm run build`
|
||||
5. Mettez à jour le favicon via l'interface de gestion
|
||||
|
||||
### Personnalisation des couleurs
|
||||
|
||||
Les couleurs de l'interface sont définies dans `client/src/index.css`. Modifiez les variables CSS dans la section `:root` pour personnaliser le thème :
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: 221.2 83.2% 53.3%; /* Bleu principal */
|
||||
--secondary: 210 40% 96.1%;
|
||||
--accent: 210 40% 96.1%;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### Ajout de fonctions personnalisées
|
||||
|
||||
Pour ajouter des fonctions spécifiques à votre organisation :
|
||||
|
||||
1. **Schéma** : Ajoutez les tables dans `drizzle/schema.ts`
|
||||
2. **Backend** : Créez les fonctions dans `server/db.ts`
|
||||
3. **API** : Ajoutez les procédures dans `server/routers.ts`
|
||||
4. **Frontend** : Créez les pages dans `client/src/pages/`
|
||||
5. **Navigation** : Ajoutez les liens dans `client/src/components/DashboardLayout.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### L'application ne démarre pas
|
||||
|
||||
**Vérifiez le fichier `.env`** : Assurez-vous que toutes les variables requises sont définies.
|
||||
|
||||
**Testez la connexion DB** :
|
||||
```bash
|
||||
mysql -h HOST -u USER -p DATABASE
|
||||
```
|
||||
|
||||
**Consultez les logs** :
|
||||
```bash
|
||||
sudo journalctl -u formation-manager -n 100
|
||||
```
|
||||
|
||||
### Les emails ne sont pas envoyés
|
||||
|
||||
**Vérifiez la configuration SMTP** : Utilisez la fonction de test dans l'interface.
|
||||
|
||||
**Consultez les logs** : Les erreurs SMTP sont loguées dans les journaux système.
|
||||
|
||||
**Vérifiez les ports** : Assurez-vous que les ports SMTP (587, 465) ne sont pas bloqués par le pare-feu.
|
||||
|
||||
### Erreurs de base de données
|
||||
|
||||
**Migrations non appliquées** : Exécutez `pnpm db:push` pour synchroniser le schéma.
|
||||
|
||||
**Connexion refusée** : Vérifiez que MySQL est démarré et accessible.
|
||||
|
||||
**Erreur d'authentification** : Vérifiez les identifiants dans `DATABASE_URL`.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Pour toute question sur la configuration :
|
||||
|
||||
- **Documentation technique** : README.md du projet
|
||||
- **Support Manus** : https://help.manus.im
|
||||
- **Contact** : o.pareige@itinova.org
|
||||
|
||||
---
|
||||
|
||||
**Fin de la documentation de configuration**
|
||||
Binary file not shown.
@@ -1,681 +0,0 @@
|
||||
# Guide de Déploiement - Formation Manager Itinova
|
||||
|
||||
**Version:** 7a737c05
|
||||
**Date:** 24 novembre 2025
|
||||
**Auteur:** Manus AI
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document fournit les instructions complètes pour déployer l'application **Formation Manager Itinova** sur un serveur de production. L'application est construite avec React 19, Express 4, tRPC 11 et utilise une base de données MySQL/TiDB.
|
||||
|
||||
---
|
||||
|
||||
## Prérequis système
|
||||
|
||||
Avant de commencer le déploiement, assurez-vous que votre serveur dispose des éléments suivants :
|
||||
|
||||
### Configuration matérielle minimale
|
||||
|
||||
| Composant | Spécification minimale | Recommandé |
|
||||
|-----------|------------------------|------------|
|
||||
| CPU | 2 cœurs | 4 cœurs |
|
||||
| RAM | 2 GB | 4 GB |
|
||||
| Stockage | 20 GB | 50 GB SSD |
|
||||
| Bande passante | 100 Mbps | 1 Gbps |
|
||||
|
||||
### Logiciels requis
|
||||
|
||||
L'application nécessite les logiciels suivants installés sur le serveur :
|
||||
|
||||
**Node.js version 22.13.0 ou supérieure** : Le runtime JavaScript est essentiel pour exécuter l'application côté serveur. La version 22.13.0 garantit la compatibilité avec toutes les dépendances du projet.
|
||||
|
||||
**pnpm** : Le gestionnaire de paquets pnpm est utilisé pour installer les dépendances de manière efficace. Il offre de meilleures performances et une gestion optimisée de l'espace disque par rapport à npm.
|
||||
|
||||
**MySQL 8.0 ou TiDB** : La base de données relationnelle stocke toutes les données de l'application (formations, séquences, apprenants, inscriptions). TiDB est compatible MySQL et offre une scalabilité horizontale pour les déploiements à grande échelle.
|
||||
|
||||
**Nginx ou Apache** : Un serveur web reverse proxy est recommandé pour gérer le SSL/TLS, la compression et le cache statique. Nginx est préféré pour ses performances supérieures.
|
||||
|
||||
**Systemd** : Le gestionnaire de services Linux permet de gérer l'application comme un service système, assurant le démarrage automatique et la supervision.
|
||||
|
||||
**Git** : Le système de contrôle de version est nécessaire pour cloner le dépôt et effectuer les mises à jour.
|
||||
|
||||
---
|
||||
|
||||
## Architecture de déploiement
|
||||
|
||||
L'application suit une architecture client-serveur moderne avec les composants suivants :
|
||||
|
||||
### Composants principaux
|
||||
|
||||
**Frontend React** : L'interface utilisateur est construite avec React 19 et Tailwind CSS 4. Le build de production génère des fichiers statiques optimisés (HTML, CSS, JavaScript) qui sont servis par le serveur Express.
|
||||
|
||||
**Backend Express + tRPC** : Le serveur Node.js gère les requêtes API via tRPC, offrant une communication type-safe entre le client et le serveur. Toutes les routes API sont préfixées par `/api/`.
|
||||
|
||||
**Base de données MySQL/TiDB** : La couche de persistance utilise Drizzle ORM pour interagir avec la base de données. Le schéma est défini dans `drizzle/schema.ts` et les migrations sont gérées automatiquement.
|
||||
|
||||
**Authentification OAuth** : Le système d'authentification utilise Manus OAuth pour la gestion des utilisateurs. Les sessions sont stockées dans des cookies HTTP-only sécurisés.
|
||||
|
||||
### Flux de requêtes
|
||||
|
||||
Les requêtes utilisateur suivent ce chemin : **Navigateur → Nginx (SSL/TLS) → Express (port 3000) → tRPC → Base de données**. Les fichiers statiques sont servis directement par Express depuis le répertoire `dist/public/`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 : Préparation du serveur
|
||||
|
||||
### Installation de Node.js
|
||||
|
||||
La première étape consiste à installer Node.js version 22.13.0 sur votre serveur Ubuntu. Utilisez les commandes suivantes pour installer Node.js via le gestionnaire de versions nvm :
|
||||
|
||||
```bash
|
||||
# Installer nvm (Node Version Manager)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
|
||||
# Recharger le profil shell
|
||||
source ~/.bashrc
|
||||
|
||||
# Installer Node.js 22.13.0
|
||||
nvm install 22.13.0
|
||||
|
||||
# Définir comme version par défaut
|
||||
nvm use 22.13.0
|
||||
nvm alias default 22.13.0
|
||||
|
||||
# Vérifier l'installation
|
||||
node --version # Doit afficher v22.13.0
|
||||
```
|
||||
|
||||
### Installation de pnpm
|
||||
|
||||
Une fois Node.js installé, installez pnpm globalement :
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
|
||||
# Vérifier l'installation
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
### Configuration de la base de données
|
||||
|
||||
Créez une base de données MySQL dédiée pour l'application. Connectez-vous à MySQL et exécutez les commandes suivantes :
|
||||
|
||||
```sql
|
||||
CREATE DATABASE formation_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'formation_user'@'localhost' IDENTIFIED BY 'MOT_DE_PASSE_SECURISE';
|
||||
GRANT ALL PRIVILEGES ON formation_manager.* TO 'formation_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Remplacez `MOT_DE_PASSE_SECURISE` par un mot de passe fort généré aléatoirement. Notez les informations de connexion pour la configuration ultérieure.
|
||||
|
||||
### Création de l'utilisateur système
|
||||
|
||||
Pour des raisons de sécurité, créez un utilisateur système dédié pour exécuter l'application :
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /bin/bash -d /opt/formation-manager formation-manager
|
||||
sudo mkdir -p /opt/formation-manager
|
||||
sudo chown formation-manager:formation-manager /opt/formation-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 : Clonage et configuration
|
||||
|
||||
### Clonage du dépôt
|
||||
|
||||
Connectez-vous en tant qu'utilisateur `formation-manager` et clonez le dépôt Git :
|
||||
|
||||
```bash
|
||||
sudo su - formation-manager
|
||||
cd /opt/formation-manager
|
||||
git clone <URL_DU_DEPOT> app
|
||||
cd app
|
||||
git checkout 7a737c05
|
||||
```
|
||||
|
||||
Remplacez `<URL_DU_DEPOT>` par l'URL réelle de votre dépôt Git.
|
||||
|
||||
### Configuration des variables d'environnement
|
||||
|
||||
Créez un fichier `.env` à la racine du projet avec les variables suivantes :
|
||||
|
||||
```bash
|
||||
# Base de données
|
||||
DATABASE_URL=mysql://formation_user:MOT_DE_PASSE_SECURISE@localhost:3306/formation_manager
|
||||
|
||||
# Authentification
|
||||
JWT_SECRET=<GENERER_UNE_CLE_ALEATOIRE_64_CARACTERES>
|
||||
OAUTH_SERVER_URL=https://api.manus.im
|
||||
VITE_OAUTH_PORTAL_URL=https://auth.manus.im
|
||||
|
||||
# Identifiants propriétaire
|
||||
OWNER_OPEN_ID=<VOTRE_OPEN_ID_MANUS>
|
||||
OWNER_NAME=<VOTRE_NOM>
|
||||
|
||||
# Configuration application
|
||||
VITE_APP_ID=<VOTRE_APP_ID_MANUS>
|
||||
VITE_APP_TITLE="Gestion des Formations Manager Itinova"
|
||||
VITE_APP_LOGO=/logo.png
|
||||
|
||||
# APIs Manus (fournis automatiquement par la plateforme)
|
||||
BUILT_IN_FORGE_API_URL=<URL_API_MANUS>
|
||||
BUILT_IN_FORGE_API_KEY=<CLE_API_MANUS>
|
||||
VITE_FRONTEND_FORGE_API_KEY=<CLE_API_FRONTEND_MANUS>
|
||||
VITE_FRONTEND_FORGE_API_URL=<URL_API_FRONTEND_MANUS>
|
||||
|
||||
# Analytics (optionnel)
|
||||
VITE_ANALYTICS_ENDPOINT=<URL_ANALYTICS>
|
||||
VITE_ANALYTICS_WEBSITE_ID=<ID_SITE>
|
||||
|
||||
# Production
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
**Important** : Générez une clé JWT sécurisée avec la commande suivante :
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Installation des dépendances
|
||||
|
||||
Installez toutes les dépendances du projet :
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Cette commande télécharge et installe toutes les bibliothèques nécessaires définies dans `package.json`. Le processus peut prendre plusieurs minutes selon votre connexion internet.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 : Migration de la base de données
|
||||
|
||||
### Application du schéma
|
||||
|
||||
Appliquez le schéma de base de données en exécutant :
|
||||
|
||||
```bash
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
Cette commande utilise Drizzle Kit pour créer automatiquement toutes les tables nécessaires dans la base de données. Les tables suivantes seront créées :
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `users` | Utilisateurs et administrateurs |
|
||||
| `formations` | Formations disponibles |
|
||||
| `sequences` | Séquences de formation |
|
||||
| `apprenants` | Apprenants inscrits |
|
||||
| `inscriptions` | Inscriptions aux séquences |
|
||||
| `emailTemplates` | Templates d'emails personnalisés |
|
||||
| `alertes` | Alertes et notifications |
|
||||
|
||||
### Vérification du schéma
|
||||
|
||||
Connectez-vous à MySQL et vérifiez que toutes les tables ont été créées correctement :
|
||||
|
||||
```bash
|
||||
mysql -u formation_user -p formation_manager
|
||||
```
|
||||
|
||||
```sql
|
||||
SHOW TABLES;
|
||||
DESCRIBE users;
|
||||
DESCRIBE formations;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 : Build de production
|
||||
|
||||
### Compilation de l'application
|
||||
|
||||
Compilez l'application pour la production :
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Cette commande effectue les opérations suivantes :
|
||||
|
||||
1. **Compilation TypeScript** : Transpile le code TypeScript du serveur en JavaScript
|
||||
2. **Build Vite** : Compile et optimise le frontend React (minification, tree-shaking, code splitting)
|
||||
3. **Génération des assets** : Crée les fichiers statiques dans `dist/public/`
|
||||
|
||||
Le build de production génère un répertoire `dist/` contenant :
|
||||
|
||||
- `dist/index.js` : Le serveur Express compilé
|
||||
- `dist/public/` : Les fichiers statiques du frontend (HTML, CSS, JS, images)
|
||||
|
||||
### Vérification du build
|
||||
|
||||
Vérifiez que le build s'est terminé sans erreur et que les fichiers ont été générés :
|
||||
|
||||
```bash
|
||||
ls -lh dist/
|
||||
ls -lh dist/public/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 : Configuration du service systemd
|
||||
|
||||
### Création du fichier service
|
||||
|
||||
Créez un fichier de service systemd pour gérer l'application :
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/formation-manager.service
|
||||
```
|
||||
|
||||
Ajoutez le contenu suivant :
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Formation Manager Itinova
|
||||
After=network.target mysql.service
|
||||
Wants=mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=formation-manager
|
||||
Group=formation-manager
|
||||
WorkingDirectory=/opt/formation-manager/app
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="PORT=3000"
|
||||
EnvironmentFile=/opt/formation-manager/app/.env
|
||||
ExecStart=/home/formation-manager/.nvm/versions/node/v22.13.0/bin/node dist/index.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=formation-manager
|
||||
|
||||
# Limites de sécurité
|
||||
LimitNOFILE=65536
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Activation du service
|
||||
|
||||
Rechargez systemd, activez et démarrez le service :
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable formation-manager
|
||||
sudo systemctl start formation-manager
|
||||
```
|
||||
|
||||
### Vérification du statut
|
||||
|
||||
Vérifiez que le service fonctionne correctement :
|
||||
|
||||
```bash
|
||||
sudo systemctl status formation-manager
|
||||
```
|
||||
|
||||
Vous devriez voir `active (running)` en vert. Consultez les logs en cas d'erreur :
|
||||
|
||||
```bash
|
||||
sudo journalctl -u formation-manager -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 : Configuration Nginx (reverse proxy)
|
||||
|
||||
### Installation de Nginx
|
||||
|
||||
Si Nginx n'est pas déjà installé :
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx
|
||||
```
|
||||
|
||||
### Configuration du site
|
||||
|
||||
Créez un fichier de configuration pour votre site :
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/formation-manager
|
||||
```
|
||||
|
||||
Ajoutez la configuration suivante :
|
||||
|
||||
```nginx
|
||||
upstream formation_backend {
|
||||
server 127.0.0.1:3000;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name votre-domaine.com www.votre-domaine.com;
|
||||
|
||||
# Redirection HTTP vers HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name votre-domaine.com www.votre-domaine.com;
|
||||
|
||||
# Certificats SSL (à configurer avec Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/votre-domaine.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/votre-domaine.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/formation-manager-access.log;
|
||||
error_log /var/log/nginx/formation-manager-error.log;
|
||||
|
||||
# Taille maximale des uploads
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Proxy vers Node.js
|
||||
location / {
|
||||
proxy_pass http://formation_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Cache des assets statiques
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://formation_backend;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remplacez `votre-domaine.com` par votre nom de domaine réel.
|
||||
|
||||
### Activation du site
|
||||
|
||||
Activez la configuration et redémarrez Nginx :
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/formation-manager /etc/nginx/sites-enabled/
|
||||
sudo nginx -t # Tester la configuration
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### Configuration SSL avec Let's Encrypt
|
||||
|
||||
Installez Certbot et obtenez un certificat SSL gratuit :
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d votre-domaine.com -d www.votre-domaine.com
|
||||
```
|
||||
|
||||
Suivez les instructions à l'écran. Certbot configurera automatiquement Nginx pour utiliser HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 : Configuration du pare-feu
|
||||
|
||||
### Configuration UFW
|
||||
|
||||
Si vous utilisez UFW (Uncomplicated Firewall), autorisez les ports nécessaires :
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 80/tcp # HTTP
|
||||
sudo ufw allow 443/tcp # HTTPS
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### Sécurisation SSH (recommandé)
|
||||
|
||||
Désactivez l'authentification par mot de passe SSH et utilisez uniquement les clés :
|
||||
|
||||
```bash
|
||||
sudo nano /etc/ssh/sshd_config
|
||||
```
|
||||
|
||||
Modifiez les lignes suivantes :
|
||||
|
||||
```
|
||||
PasswordAuthentication no
|
||||
PermitRootLogin no
|
||||
```
|
||||
|
||||
Redémarrez SSH :
|
||||
|
||||
```bash
|
||||
sudo systemctl restart sshd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 8 : Vérification du déploiement
|
||||
|
||||
### Tests fonctionnels
|
||||
|
||||
Accédez à votre application via le navigateur à l'adresse `https://votre-domaine.com` et vérifiez les éléments suivants :
|
||||
|
||||
| Test | Description | Résultat attendu |
|
||||
|------|-------------|------------------|
|
||||
| Page d'accueil | Chargement de la page d'accueil | Affichage correct sans erreur |
|
||||
| Connexion | Authentification via Manus OAuth | Redirection vers le tableau de bord |
|
||||
| Tableau de bord | Affichage des statistiques | Graphiques et cartes visibles |
|
||||
| Formations | Liste des formations | Tableau avec données |
|
||||
| Séquences | Liste des séquences | Tableau avec code couleur |
|
||||
| Apprenants | Liste des apprenants | Tableau avec badges de statut |
|
||||
| Templates d'emails | Modification d'un template | Éditeur TipTap fonctionnel |
|
||||
|
||||
### Vérification des logs
|
||||
|
||||
Surveillez les logs pour détecter d'éventuelles erreurs :
|
||||
|
||||
```bash
|
||||
# Logs de l'application
|
||||
sudo journalctl -u formation-manager -f
|
||||
|
||||
# Logs Nginx
|
||||
sudo tail -f /var/log/nginx/formation-manager-error.log
|
||||
|
||||
# Logs système
|
||||
sudo tail -f /var/log/syslog
|
||||
```
|
||||
|
||||
### Tests de performance
|
||||
|
||||
Utilisez des outils pour tester les performances de votre application :
|
||||
|
||||
```bash
|
||||
# Test de charge avec Apache Bench
|
||||
ab -n 1000 -c 10 https://votre-domaine.com/
|
||||
|
||||
# Analyse des temps de réponse
|
||||
curl -w "@curl-format.txt" -o /dev/null -s https://votre-domaine.com/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance et mises à jour
|
||||
|
||||
### Sauvegarde de la base de données
|
||||
|
||||
Créez un script de sauvegarde automatique :
|
||||
|
||||
```bash
|
||||
sudo nano /opt/formation-manager/backup-db.sh
|
||||
```
|
||||
|
||||
Contenu du script :
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/opt/formation-manager/backups"
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
mysqldump -u formation_user -p'MOT_DE_PASSE' formation_manager | gzip > $BACKUP_DIR/formation_manager_$DATE.sql.gz
|
||||
|
||||
# Garder seulement les 30 dernières sauvegardes
|
||||
find $BACKUP_DIR -name "formation_manager_*.sql.gz" -mtime +30 -delete
|
||||
```
|
||||
|
||||
Rendez le script exécutable et ajoutez-le au cron :
|
||||
|
||||
```bash
|
||||
chmod +x /opt/formation-manager/backup-db.sh
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
Ajoutez la ligne suivante pour une sauvegarde quotidienne à 2h du matin :
|
||||
|
||||
```
|
||||
0 2 * * * /opt/formation-manager/backup-db.sh
|
||||
```
|
||||
|
||||
### Mise à jour de l'application
|
||||
|
||||
Pour mettre à jour l'application vers une nouvelle version :
|
||||
|
||||
```bash
|
||||
cd /opt/formation-manager/app
|
||||
sudo systemctl stop formation-manager
|
||||
git pull origin main
|
||||
pnpm install
|
||||
pnpm db:push # Appliquer les migrations
|
||||
pnpm run build
|
||||
sudo systemctl start formation-manager
|
||||
```
|
||||
|
||||
### Surveillance et monitoring
|
||||
|
||||
Installez des outils de monitoring pour surveiller les performances :
|
||||
|
||||
**PM2** : Alternative à systemd avec monitoring intégré
|
||||
**Prometheus + Grafana** : Monitoring avancé et dashboards
|
||||
**Uptime Kuma** : Surveillance de disponibilité
|
||||
**New Relic / DataDog** : Solutions SaaS complètes
|
||||
|
||||
---
|
||||
|
||||
## Dépannage
|
||||
|
||||
### L'application ne démarre pas
|
||||
|
||||
Vérifiez les logs pour identifier l'erreur :
|
||||
|
||||
```bash
|
||||
sudo journalctl -u formation-manager -n 100
|
||||
```
|
||||
|
||||
Causes courantes :
|
||||
|
||||
- **Erreur de connexion à la base de données** : Vérifiez `DATABASE_URL` dans `.env`
|
||||
- **Port déjà utilisé** : Changez le port dans `.env` ou arrêtez le processus conflictuel
|
||||
- **Permissions insuffisantes** : Vérifiez les droits sur `/opt/formation-manager/app`
|
||||
|
||||
### Erreur 502 Bad Gateway
|
||||
|
||||
Cette erreur indique que Nginx ne peut pas se connecter au backend Node.js. Vérifications :
|
||||
|
||||
```bash
|
||||
# Vérifier que le service tourne
|
||||
sudo systemctl status formation-manager
|
||||
|
||||
# Vérifier que le port 3000 écoute
|
||||
sudo netstat -tulpn | grep 3000
|
||||
|
||||
# Tester la connexion locale
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
### Problèmes de performance
|
||||
|
||||
Si l'application est lente :
|
||||
|
||||
1. **Optimiser les requêtes SQL** : Ajoutez des index sur les colonnes fréquemment interrogées
|
||||
2. **Activer le cache Redis** : Mettez en cache les résultats des requêtes coûteuses
|
||||
3. **Augmenter les ressources** : Ajoutez plus de RAM ou de CPU
|
||||
4. **Activer la compression Nginx** : Déjà configurée dans l'exemple ci-dessus
|
||||
|
||||
### Base de données corrompue
|
||||
|
||||
En cas de corruption de la base de données, restaurez depuis une sauvegarde :
|
||||
|
||||
```bash
|
||||
gunzip < /opt/formation-manager/backups/formation_manager_YYYYMMDD_HHMMSS.sql.gz | mysql -u formation_user -p formation_manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Bonnes pratiques
|
||||
|
||||
Suivez ces recommandations pour sécuriser votre déploiement :
|
||||
|
||||
**Mettez à jour régulièrement** : Appliquez les mises à jour de sécurité du système d'exploitation et des dépendances Node.js.
|
||||
|
||||
**Utilisez HTTPS uniquement** : Forcez la redirection HTTP vers HTTPS et activez HSTS (HTTP Strict Transport Security).
|
||||
|
||||
**Limitez les accès SSH** : Utilisez des clés SSH au lieu de mots de passe et limitez l'accès par IP si possible.
|
||||
|
||||
**Configurez un WAF** : Utilisez un Web Application Firewall comme ModSecurity ou Cloudflare pour bloquer les attaques courantes.
|
||||
|
||||
**Surveillez les logs** : Configurez des alertes pour détecter les tentatives d'intrusion ou les comportements anormaux.
|
||||
|
||||
**Sauvegardez régulièrement** : Automatisez les sauvegardes de la base de données et testez régulièrement la restauration.
|
||||
|
||||
### Checklist de sécurité
|
||||
|
||||
Avant de mettre en production, vérifiez les points suivants :
|
||||
|
||||
- [ ] Certificat SSL valide configuré
|
||||
- [ ] Pare-feu activé et configuré
|
||||
- [ ] Authentification SSH par clé uniquement
|
||||
- [ ] Variables d'environnement sécurisées (pas de valeurs par défaut)
|
||||
- [ ] Sauvegardes automatiques configurées
|
||||
- [ ] Monitoring et alertes en place
|
||||
- [ ] Logs rotatifs configurés
|
||||
- [ ] Permissions fichiers correctes (pas de 777)
|
||||
- [ ] Base de données accessible uniquement en local
|
||||
- [ ] Rate limiting configuré sur Nginx
|
||||
|
||||
---
|
||||
|
||||
## Support et contact
|
||||
|
||||
Pour toute question ou problème concernant le déploiement :
|
||||
|
||||
- **Documentation technique** : Consultez le README.md du projet
|
||||
- **Support Manus** : https://help.manus.im
|
||||
- **Contact développeur** : o.pareige@itinova.org
|
||||
|
||||
---
|
||||
|
||||
**Fin du guide de déploiement**
|
||||
Binary file not shown.
@@ -1,134 +0,0 @@
|
||||
================================================================================
|
||||
PACKAGE DE DÉPLOIEMENT - FORMATION MANAGER ITINOVA
|
||||
================================================================================
|
||||
|
||||
Version: 7a737c05
|
||||
Date de création: 24 novembre 2025
|
||||
Créé par: Manus AI
|
||||
|
||||
================================================================================
|
||||
FICHIERS GÉNÉRÉS
|
||||
================================================================================
|
||||
|
||||
📦 Archive complète:
|
||||
formation-manager-itinova-deployment-7a737c05.tar.gz (738 KB)
|
||||
|
||||
Contient tous les fichiers nécessaires pour le déploiement:
|
||||
- Guides de déploiement (PDF + Markdown)
|
||||
- Documentation de configuration (PDF + Markdown)
|
||||
- Notes de version
|
||||
- Scripts d'installation et de mise à jour
|
||||
- README avec instructions de démarrage rapide
|
||||
|
||||
📄 Fichiers individuels disponibles dans deployment-package/:
|
||||
- GUIDE_DEPLOIEMENT.pdf (425 KB) - Guide complet d'installation
|
||||
- GUIDE_DEPLOIEMENT.md (19 KB) - Version Markdown du guide
|
||||
- CONFIGURATION.pdf (376 KB) - Documentation des variables d'environnement
|
||||
- CONFIGURATION.md (12 KB) - Version Markdown de la configuration
|
||||
- RELEASE_NOTES.md (9.5 KB) - Notes de version détaillées
|
||||
- deploy.sh (8.6 KB) - Script d'installation automatisé
|
||||
- update.sh (2.5 KB) - Script de mise à jour
|
||||
- README.txt - Instructions de démarrage rapide
|
||||
|
||||
================================================================================
|
||||
UTILISATION
|
||||
================================================================================
|
||||
|
||||
Option 1: Utiliser l'archive complète
|
||||
--------------------------------------
|
||||
1. Téléchargez: formation-manager-itinova-deployment-7a737c05.tar.gz
|
||||
2. Transférez sur votre serveur:
|
||||
scp formation-manager-itinova-deployment-7a737c05.tar.gz user@serveur:/home/user/
|
||||
3. Décompressez: tar -xzf formation-manager-itinova-deployment-7a737c05.tar.gz
|
||||
4. Suivez les instructions dans deployment-package/README.txt
|
||||
|
||||
Option 2: Télécharger les fichiers individuels
|
||||
-----------------------------------------------
|
||||
Téléchargez uniquement les fichiers dont vous avez besoin depuis le dossier
|
||||
deployment-package/
|
||||
|
||||
================================================================================
|
||||
PRÉREQUIS SYSTÈME
|
||||
================================================================================
|
||||
|
||||
Serveur:
|
||||
- Ubuntu 22.04 LTS ou supérieur
|
||||
- 2 CPU cores minimum (4 recommandés)
|
||||
- 4 GB RAM minimum (8 GB recommandés)
|
||||
- 20 GB espace disque minimum
|
||||
- Accès SSH avec droits sudo
|
||||
|
||||
Base de données:
|
||||
- MySQL 8.0+ ou TiDB Cloud
|
||||
- 1 GB espace disque minimum pour la base de données
|
||||
|
||||
Réseau:
|
||||
- Nom de domaine pointant vers le serveur
|
||||
- Ports ouverts: 80 (HTTP), 443 (HTTPS), 22 (SSH)
|
||||
|
||||
================================================================================
|
||||
FONCTIONNALITÉS DE CETTE VERSION
|
||||
================================================================================
|
||||
|
||||
✅ Gestion complète des formations, séquences et apprenants
|
||||
✅ Système d'inscription avec limitation de capacité
|
||||
✅ Templates d'emails personnalisables avec éditeur WYSIWYG (TipTap)
|
||||
✅ Variables dynamiques dans les emails
|
||||
✅ Tableau de bord principal avec statistiques
|
||||
✅ Tableau de bord analytique avancé avec graphiques (Recharts)
|
||||
✅ Export Excel des rapports analytiques
|
||||
✅ Vue calendrier des séquences avec code couleur
|
||||
✅ Page Établissements avec statistiques de participation
|
||||
✅ Badges de statut Actif/Inactif pour les apprenants
|
||||
✅ Code couleur pour taux de remplissage (vert/orange/rouge)
|
||||
✅ Icônes colorées (œil en bleu, corbeille en rouge)
|
||||
✅ Menu réorganisé en 4 zones avec titres en bleu
|
||||
✅ Authentification locale (sans redirection OAuth Manus)
|
||||
|
||||
================================================================================
|
||||
ÉTAPES DE DÉPLOIEMENT RÉSUMÉES
|
||||
================================================================================
|
||||
|
||||
1. Préparer le serveur (Ubuntu 22.04)
|
||||
2. Installer les dépendances (Node.js, MySQL, Nginx)
|
||||
3. Cloner le dépôt Git
|
||||
4. Configurer les variables d'environnement (.env)
|
||||
5. Installer les dépendances Node.js (pnpm install)
|
||||
6. Appliquer les migrations de base de données (pnpm db:push)
|
||||
7. Compiler l'application (pnpm run build)
|
||||
8. Configurer Nginx comme reverse proxy
|
||||
9. Obtenir un certificat SSL avec Let's Encrypt
|
||||
10. Créer et démarrer le service systemd
|
||||
11. Tester l'application
|
||||
|
||||
Temps d'installation estimé: 30-45 minutes
|
||||
|
||||
================================================================================
|
||||
IDENTIFIANTS PAR DÉFAUT
|
||||
================================================================================
|
||||
|
||||
Après l'installation, connectez-vous avec:
|
||||
|
||||
Identifiant: adminServFormation
|
||||
Mot de passe: Itinova69!
|
||||
|
||||
⚠️ IMPORTANT: Changez ce mot de passe immédiatement après la première connexion!
|
||||
|
||||
================================================================================
|
||||
SUPPORT
|
||||
================================================================================
|
||||
|
||||
Documentation: Consultez les PDF inclus dans le package
|
||||
Support Manus: https://help.manus.im
|
||||
Contact: o.pareige@itinova.org
|
||||
|
||||
================================================================================
|
||||
CHECKSUM (pour vérification d'intégrité)
|
||||
================================================================================
|
||||
|
||||
Pour vérifier l'intégrité de l'archive après téléchargement:
|
||||
sha256sum formation-manager-itinova-deployment-7a737c05.tar.gz
|
||||
|
||||
================================================================================
|
||||
FIN
|
||||
================================================================================
|
||||
@@ -1,193 +0,0 @@
|
||||
================================================================================
|
||||
PACKAGE DE DÉPLOIEMENT - FORMATION MANAGER ITINOVA
|
||||
Version: 7a737c05
|
||||
Date: 24 novembre 2025
|
||||
================================================================================
|
||||
|
||||
Ce package contient tous les fichiers nécessaires pour déployer l'application
|
||||
Formation Manager Itinova sur un serveur VPS de production.
|
||||
|
||||
================================================================================
|
||||
CONTENU DU PACKAGE
|
||||
================================================================================
|
||||
|
||||
1. GUIDE_DEPLOIEMENT.pdf (425 KB)
|
||||
Guide complet de déploiement avec instructions pas à pas pour installer
|
||||
l'application sur un VPS Ubuntu 22.04. Inclut la configuration de la base
|
||||
de données, Nginx, SSL, et la mise en service systemd.
|
||||
|
||||
2. GUIDE_DEPLOIEMENT.md (19 KB)
|
||||
Version Markdown du guide de déploiement (même contenu que le PDF).
|
||||
|
||||
3. CONFIGURATION.pdf (376 KB)
|
||||
Documentation complète de toutes les variables d'environnement et
|
||||
paramètres de configuration. Explique chaque variable, sa fonction,
|
||||
et les valeurs recommandées.
|
||||
|
||||
4. CONFIGURATION.md (12 KB)
|
||||
Version Markdown de la documentation de configuration.
|
||||
|
||||
5. RELEASE_NOTES.md (9.5 KB)
|
||||
Notes de version détaillées listant toutes les fonctionnalités,
|
||||
corrections de bugs et améliorations de cette version.
|
||||
|
||||
6. deploy.sh (8.6 KB)
|
||||
Script bash automatisé pour l'installation initiale complète de
|
||||
l'application. Installe toutes les dépendances, configure la base
|
||||
de données, compile l'application et crée le service systemd.
|
||||
|
||||
7. update.sh (2.5 KB)
|
||||
Script bash pour les mises à jour rapides de l'application. Récupère
|
||||
les dernières modifications, recompile et redémarre le service.
|
||||
|
||||
8. README.txt (ce fichier)
|
||||
Instructions de démarrage rapide.
|
||||
|
||||
================================================================================
|
||||
DÉMARRAGE RAPIDE
|
||||
================================================================================
|
||||
|
||||
ÉTAPE 1 : Prérequis
|
||||
-------------------
|
||||
- Serveur VPS Ubuntu 22.04 ou supérieur
|
||||
- Accès SSH avec droits sudo
|
||||
- Nom de domaine pointant vers le serveur (pour SSL)
|
||||
- Base de données MySQL ou TiDB accessible
|
||||
|
||||
ÉTAPE 2 : Transfert des fichiers
|
||||
---------------------------------
|
||||
Transférez le contenu de ce package sur votre serveur :
|
||||
|
||||
scp -r deployment-package/* user@votre-serveur.com:/home/user/
|
||||
|
||||
ÉTAPE 3 : Lecture de la documentation
|
||||
--------------------------------------
|
||||
Avant de commencer, lisez attentivement :
|
||||
|
||||
1. GUIDE_DEPLOIEMENT.pdf - Pour comprendre le processus complet
|
||||
2. CONFIGURATION.pdf - Pour préparer vos variables d'environnement
|
||||
|
||||
ÉTAPE 4 : Exécution du script de déploiement
|
||||
---------------------------------------------
|
||||
Sur le serveur, exécutez le script d'installation :
|
||||
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
|
||||
Le script vous guidera à travers toutes les étapes d'installation.
|
||||
|
||||
ÉTAPE 5 : Configuration post-installation
|
||||
------------------------------------------
|
||||
Après l'installation :
|
||||
|
||||
1. Configurez SMTP dans l'interface web (Configuration > Configuration SMTP)
|
||||
2. Créez vos premiers utilisateurs (Gestion > Utilisateurs)
|
||||
3. Personnalisez les templates d'emails (Configuration > Templates d'emails)
|
||||
4. Testez l'envoi d'emails
|
||||
|
||||
================================================================================
|
||||
MISES À JOUR FUTURES
|
||||
================================================================================
|
||||
|
||||
Pour mettre à jour l'application vers une version plus récente :
|
||||
|
||||
1. Transférez le nouveau fichier update.sh sur le serveur
|
||||
2. Exécutez : chmod +x update.sh && ./update.sh
|
||||
3. Le script sauvegarde automatiquement la base de données avant la mise à jour
|
||||
|
||||
================================================================================
|
||||
STRUCTURE DE L'APPLICATION
|
||||
================================================================================
|
||||
|
||||
Après installation, l'application sera installée dans :
|
||||
/opt/formation-manager/
|
||||
|
||||
Fichiers de configuration :
|
||||
/opt/formation-manager/.env
|
||||
|
||||
Logs de l'application :
|
||||
sudo journalctl -u formation-manager -f
|
||||
|
||||
Service systemd :
|
||||
sudo systemctl status formation-manager
|
||||
sudo systemctl restart formation-manager
|
||||
sudo systemctl stop formation-manager
|
||||
|
||||
Configuration Nginx :
|
||||
/etc/nginx/sites-available/formation-manager
|
||||
|
||||
================================================================================
|
||||
IDENTIFIANTS PAR DÉFAUT
|
||||
================================================================================
|
||||
|
||||
Après l'installation, connectez-vous avec :
|
||||
|
||||
Identifiant : adminServFormation
|
||||
Mot de passe : Itinova69!
|
||||
|
||||
⚠️ IMPORTANT : Changez ce mot de passe immédiatement après la première connexion
|
||||
via le menu Gestion > Utilisateurs > Modifier l'utilisateur adminServFormation
|
||||
|
||||
================================================================================
|
||||
SUPPORT ET ASSISTANCE
|
||||
================================================================================
|
||||
|
||||
Documentation technique :
|
||||
- README.md du projet (dans le dépôt Git)
|
||||
- GUIDE_DEPLOIEMENT.pdf (ce package)
|
||||
- CONFIGURATION.pdf (ce package)
|
||||
|
||||
Support Manus :
|
||||
https://help.manus.im
|
||||
|
||||
Contact développeur :
|
||||
o.pareige@itinova.org
|
||||
|
||||
================================================================================
|
||||
SÉCURITÉ
|
||||
================================================================================
|
||||
|
||||
Recommandations de sécurité importantes :
|
||||
|
||||
✓ Changez le mot de passe administrateur par défaut
|
||||
✓ Utilisez HTTPS (SSL/TLS) avec Let's Encrypt
|
||||
✓ Configurez un pare-feu (ufw) pour limiter les ports ouverts
|
||||
✓ Sauvegardez régulièrement la base de données
|
||||
✓ Gardez le système à jour (apt update && apt upgrade)
|
||||
✓ Utilisez des mots de passe forts pour la base de données
|
||||
✓ Ne commitez jamais le fichier .env dans Git
|
||||
✓ Limitez les accès SSH (clés SSH uniquement)
|
||||
|
||||
================================================================================
|
||||
DÉPANNAGE
|
||||
================================================================================
|
||||
|
||||
L'application ne démarre pas :
|
||||
- Vérifiez les logs : sudo journalctl -u formation-manager -n 100
|
||||
- Vérifiez le fichier .env : toutes les variables sont-elles définies ?
|
||||
- Testez la connexion DB : mysql -h HOST -u USER -p DATABASE
|
||||
|
||||
Les emails ne sont pas envoyés :
|
||||
- Vérifiez la configuration SMTP dans l'interface
|
||||
- Utilisez la fonction "Test d'envoi" dans Configuration > Configuration SMTP
|
||||
- Consultez les logs pour les erreurs SMTP
|
||||
|
||||
Erreur 502 Bad Gateway :
|
||||
- Le service formation-manager est-il démarré ? sudo systemctl status formation-manager
|
||||
- Vérifiez les logs Nginx : sudo tail -f /var/log/nginx/error.log
|
||||
|
||||
================================================================================
|
||||
LICENCE ET COPYRIGHT
|
||||
================================================================================
|
||||
|
||||
Formation Manager Itinova
|
||||
Version 7a737c05
|
||||
|
||||
Développé par Manus AI pour Itinova
|
||||
© 2025 Itinova - Tous droits réservés
|
||||
|
||||
Ce logiciel est fourni "tel quel", sans garantie d'aucune sorte.
|
||||
|
||||
================================================================================
|
||||
FIN DU README
|
||||
================================================================================
|
||||
@@ -1,245 +0,0 @@
|
||||
# Notes de version - Formation Manager Itinova
|
||||
|
||||
## Version 7a737c05 (24 novembre 2025)
|
||||
|
||||
### 🎉 Nouvelles fonctionnalités
|
||||
|
||||
#### Gestion des templates d'emails
|
||||
- **Éditeur de texte riche (TipTap)** : Interface WYSIWYG complète pour formater les emails (gras, italique, titres, listes)
|
||||
- **Variables dynamiques** : 7 boutons pour insérer rapidement des variables ({{nomApprenant}}, {{nomFormation}}, {{dateDebut}}, etc.)
|
||||
- **Prévisualisation en temps réel** : Visualisation instantanée du rendu final pendant la modification
|
||||
- **Bouton "Aperçu email"** : Modal affichant l'email complet avec variables remplacées par des données d'exemple
|
||||
- **Remplacement automatique** : Les variables sont remplacées par les données réelles lors de l'envoi des emails
|
||||
|
||||
#### Tableau de bord analytique avancé
|
||||
- **Graphiques d'évolution temporelle** : Visualisation des inscriptions par mois sur 12 mois
|
||||
- **Taux de remplissage mensuel** : Graphique linéaire montrant l'évolution du taux de remplissage
|
||||
- **Top 10 établissements** : Graphique à barres des établissements les plus actifs
|
||||
- **Répartition par fonction** : Graphique circulaire (Directeurs, Chefs de service, Autre)
|
||||
- **Filtres de période** : Sélection rapide (dernier mois, trimestre, année, toutes les données)
|
||||
- **Export Excel** : Téléchargement d'un rapport complet avec 5 feuilles détaillées
|
||||
|
||||
#### Gestion des établissements
|
||||
- **Page dédiée** : Liste complète de tous les établissements avec statistiques
|
||||
- **Statistiques par établissement** : Nombre d'apprenants, apprenants actifs, taux de participation
|
||||
- **Code couleur** : Indicateur visuel du taux de participation (vert >70%, orange 40-70%, rouge <40%)
|
||||
- **Tri et recherche** : Filtrage rapide par nom d'établissement
|
||||
|
||||
#### Badges de statut des apprenants
|
||||
- **Badge "Actif"** (vert) : Apprenant inscrit à au moins une séquence
|
||||
- **Badge "Inactif"** (gris) : Apprenant sans inscription active
|
||||
- **Colonne dédiée** : Nouvelle colonne dans le tableau des apprenants
|
||||
|
||||
### 🎨 Améliorations visuelles
|
||||
|
||||
#### Interface générale
|
||||
- **Menu réorganisé** : 4 zones distinctes avec séparateurs et titres en bleu
|
||||
- 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)
|
||||
|
||||
#### Gestion des séquences
|
||||
- **Taux de remplissage coloré** : Vert (<70%), Orange (70-90%), Rouge (>90%)
|
||||
- **Formations en couleur** : Chaque formation a une couleur unique (10 couleurs disponibles)
|
||||
- **Icônes colorées** : Œil en bleu (visualisation), Corbeille en rouge (suppression)
|
||||
|
||||
#### Gestion des apprenants
|
||||
- **Fonction en couleur** : Directeurs (bleu), Chefs de service (vert), Autre (gris)
|
||||
- **Icônes colorées** : Œil en bleu, Corbeille en rouge
|
||||
- **Badge de statut** : Actif/Inactif visible immédiatement
|
||||
|
||||
#### Tableau de bord principal
|
||||
- **Graphique par établissement** : Ajout d'un second graphique à côté de la répartition par fonction
|
||||
- **Cohérence des données** : Uniformisation des calculs entre tableau de bord principal et analytique
|
||||
|
||||
### 🐛 Corrections de bugs
|
||||
|
||||
- **Erreur calendrier** : Correction de l'erreur React #31 au clic sur un événement (objets Date convertis en string)
|
||||
- **Menu invisible** : Ajout du wrapper DashboardLayout sur la page Établissements
|
||||
- **Incohérence statistiques** : Uniformisation du calcul de répartition par fonction (comptage des apprenants uniques)
|
||||
- **Couleurs formations** : Correction de l'algorithme pour assigner une couleur unique à chaque formation
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- **Guide de déploiement complet** : Instructions détaillées pour déployer sur VPS (GUIDE_DEPLOIEMENT.md)
|
||||
- **Script de déploiement** : Script bash automatisé pour installation initiale (deploy.sh)
|
||||
- **Script de mise à jour** : Script simplifié pour les mises à jour rapides (update.sh)
|
||||
- **Documentation de configuration** : Guide complet des variables d'environnement et paramètres (CONFIGURATION.md)
|
||||
|
||||
### 🔧 Améliorations techniques
|
||||
|
||||
- **React Quill remplacé par TipTap** : Compatibilité avec React 19
|
||||
- **Recharts intégré** : Bibliothèque de graphiques pour le tableau de bord analytique
|
||||
- **ExcelJS intégré** : Export Excel des rapports analytiques
|
||||
- **Requêtes analytiques optimisées** : Nouvelles fonctions dans analyticsDb.ts et etablissementsDb.ts
|
||||
|
||||
### 📊 Statistiques de cette version
|
||||
|
||||
- **Nouvelles pages** : 2 (AdminAnalytics, AdminEtablissements)
|
||||
- **Nouveaux composants** : 1 (CapacityProgressBar amélioré)
|
||||
- **Nouvelles fonctions backend** : 15+ (analytics, établissements, templates)
|
||||
- **Lignes de code ajoutées** : ~3000
|
||||
- **Dépendances ajoutées** : 4 (@tiptap/react, @tiptap/starter-kit, recharts, exceljs)
|
||||
|
||||
---
|
||||
|
||||
## Versions précédentes
|
||||
|
||||
### Version 65a2d003 (23 novembre 2025)
|
||||
|
||||
#### Corrections
|
||||
- Correction de l'erreur d'affichage au clic sur un événement du calendrier
|
||||
- Correction du problème de menu invisible sur la page Établissements
|
||||
|
||||
### Version a0049436 (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Badges de statut Actif/Inactif pour les apprenants
|
||||
- Page Établissements avec statistiques de participation
|
||||
|
||||
### Version 193ffd7e (23 novembre 2025)
|
||||
|
||||
#### Corrections
|
||||
- Correction de l'algorithme de couleur des formations (mapping basé sur l'ordre)
|
||||
|
||||
### Version 9a38fc9d (23 novembre 2025)
|
||||
|
||||
#### Améliorations visuelles
|
||||
- Icône corbeille en rouge et icône œil en bleu dans la page des apprenants
|
||||
- Fonction colorée (Directeurs, Chefs de service) dans le tableau des apprenants
|
||||
|
||||
### Version d2bb185e (23 novembre 2025)
|
||||
|
||||
#### Améliorations visuelles
|
||||
- Icône œil en bleu dans la gestion des séquences
|
||||
- Texte des formations coloré avec une couleur unique par formation
|
||||
|
||||
### Version 41fc4874 (23 novembre 2025)
|
||||
|
||||
#### Améliorations visuelles
|
||||
- Taux de remplissage coloré dans la gestion des séquences (vert/orange/rouge)
|
||||
- Icône corbeille en rouge
|
||||
|
||||
### Version 3ec648f8 (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Graphique de répartition par établissement sur le tableau de bord principal
|
||||
|
||||
### Version a807183e (23 novembre 2025)
|
||||
|
||||
#### Corrections
|
||||
- Correction de l'incohérence des statistiques de répartition par fonction
|
||||
|
||||
### Version 03efb59c (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Filtres de période dans le tableau de bord analytique
|
||||
- Export Excel des rapports analytiques
|
||||
|
||||
### Version 64db3f1a (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Tableau de bord analytique avancé avec graphiques Recharts
|
||||
|
||||
### Version 33161d1a (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Remplacement automatique des variables dans les emails
|
||||
- Bouton "Aperçu email" avec modal de prévisualisation
|
||||
|
||||
### Version 4e825dbf (23 novembre 2025)
|
||||
|
||||
#### Corrections
|
||||
- Remplacement de React Quill par TipTap (compatibilité React 19)
|
||||
|
||||
### Version e16bdb22 (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Éditeur WYSIWYG (React Quill) pour les templates d'emails
|
||||
- Boutons de variables dynamiques
|
||||
|
||||
### Version 3de0e393 (23 novembre 2025)
|
||||
|
||||
#### Améliorations
|
||||
- Prévisualisation en temps réel du contenu des templates d'emails
|
||||
|
||||
### Version e4abeb5d (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Champ "Corps du message" dans les templates d'emails avec validation backend
|
||||
|
||||
### Version 62644d54 (23 novembre 2025)
|
||||
|
||||
#### Nouvelles fonctionnalités
|
||||
- Champ "Corps du message" dans l'interface de gestion des templates d'emails
|
||||
|
||||
---
|
||||
|
||||
## Roadmap future
|
||||
|
||||
### Fonctionnalités planifiées
|
||||
|
||||
#### Court terme (1-2 mois)
|
||||
- [ ] Système d'alertes automatiques configurables
|
||||
- [ ] Export PDF des rapports analytiques
|
||||
- [ ] Vue calendrier des séquences avec code couleur
|
||||
- [ ] Filtre par formation dans le calendrier
|
||||
- [ ] Recherche globale dans le header
|
||||
- [ ] Badges de notification dans le menu
|
||||
|
||||
#### Moyen terme (3-6 mois)
|
||||
- [ ] Système de tags pour formations et séquences
|
||||
- [ ] Rapports mensuels automatiques par email
|
||||
- [ ] Vue détaillée par établissement avec liste des apprenants
|
||||
- [ ] Statistiques d'emails envoyés
|
||||
- [ ] Templates d'emails prédéfinis
|
||||
- [ ] Comparaison de périodes dans les analytics
|
||||
|
||||
#### Long terme (6-12 mois)
|
||||
- [ ] API REST publique pour intégrations tierces
|
||||
- [ ] Application mobile (React Native)
|
||||
- [ ] Système de notifications push
|
||||
- [ ] Intégration avec calendriers externes (Google Calendar, Outlook)
|
||||
- [ ] Module de facturation et paiements
|
||||
- [ ] Gestion multi-organisations
|
||||
|
||||
---
|
||||
|
||||
## Migration depuis une version antérieure
|
||||
|
||||
### Depuis la version 65a2d003 ou antérieure
|
||||
|
||||
1. Sauvegardez votre base de données
|
||||
2. Mettez à jour le code : `git pull origin main && git checkout 7a737c05`
|
||||
3. Installez les nouvelles dépendances : `pnpm install`
|
||||
4. Appliquez les migrations : `pnpm db:push`
|
||||
5. Recompilez : `pnpm run build`
|
||||
6. Redémarrez le service : `sudo systemctl restart formation-manager`
|
||||
|
||||
**Nouvelles tables créées** : `alertes`
|
||||
**Colonnes ajoutées** : `emailTemplates.bodyContent`
|
||||
|
||||
### Vérification post-migration
|
||||
|
||||
- [ ] Accès à la page Tableau de bord analytique
|
||||
- [ ] Accès à la page Établissements
|
||||
- [ ] Modification d'un template d'email avec le nouvel éditeur
|
||||
- [ ] Export Excel depuis le tableau de bord analytique
|
||||
- [ ] Vérification des badges de statut dans la liste des apprenants
|
||||
|
||||
---
|
||||
|
||||
## Support et contact
|
||||
|
||||
Pour toute question concernant cette version :
|
||||
|
||||
- **Documentation** : Consultez GUIDE_DEPLOIEMENT.md et CONFIGURATION.md
|
||||
- **Support Manus** : https://help.manus.im
|
||||
- **Contact développeur** : o.pareige@itinova.org
|
||||
|
||||
---
|
||||
|
||||
**Équipe de développement**
|
||||
Manus AI - Formation Manager Itinova
|
||||
© 2025 Itinova
|
||||
@@ -1,273 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Script de déploiement - Gestion des Formations Manager Itinova
|
||||
# Version: 1.0.0
|
||||
# Description: Script automatisé pour déployer l'application en production
|
||||
###############################################################################
|
||||
|
||||
set -e # Arrêter en cas d'erreur
|
||||
|
||||
# Couleurs pour les messages
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Fonction pour afficher les messages
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Fonction pour vérifier les prérequis
|
||||
check_prerequisites() {
|
||||
log_info "Vérification des prérequis..."
|
||||
|
||||
# Vérifier Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
log_error "Node.js n'est pas installé"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
log_error "Node.js version 18+ est requis (version actuelle: $(node -v))"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Node.js version: $(node -v) ✓"
|
||||
|
||||
# Vérifier pnpm
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
log_warn "pnpm n'est pas installé. Installation..."
|
||||
npm install -g pnpm
|
||||
fi
|
||||
log_info "pnpm version: $(pnpm -v) ✓"
|
||||
|
||||
# Vérifier MySQL
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
log_warn "MySQL client n'est pas installé"
|
||||
else
|
||||
log_info "MySQL client: ✓"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour créer le fichier .env si nécessaire
|
||||
setup_env() {
|
||||
log_info "Configuration des variables d'environnement..."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
log_warn "Fichier .env non trouvé. Création à partir du template..."
|
||||
|
||||
cat > .env << 'EOF'
|
||||
# Base de données
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/formation_manager
|
||||
|
||||
# JWT Secret (IMPORTANT: Générer une clé aléatoire sécurisée)
|
||||
JWT_SECRET=changez_cette_cle_secrete_par_une_valeur_aleatoire_tres_longue
|
||||
|
||||
# OAuth Manus
|
||||
OAUTH_SERVER_URL=https://api.manus.im
|
||||
VITE_OAUTH_PORTAL_URL=https://login.manus.im
|
||||
VITE_APP_ID=
|
||||
|
||||
# Propriétaire
|
||||
OWNER_OPEN_ID=
|
||||
OWNER_NAME=
|
||||
|
||||
# Application
|
||||
VITE_APP_TITLE=Gestion des Formations Manager Itinova
|
||||
VITE_APP_LOGO=/logo.svg
|
||||
|
||||
# APIs Manus (optionnel)
|
||||
BUILT_IN_FORGE_API_URL=https://forge.manus.im
|
||||
BUILT_IN_FORGE_API_KEY=
|
||||
VITE_FRONTEND_FORGE_API_KEY=
|
||||
VITE_FRONTEND_FORGE_API_URL=https://forge.manus.im
|
||||
|
||||
# Analytics (optionnel)
|
||||
VITE_ANALYTICS_ENDPOINT=
|
||||
VITE_ANALYTICS_WEBSITE_ID=
|
||||
|
||||
# Environnement
|
||||
NODE_ENV=production
|
||||
EOF
|
||||
|
||||
log_warn "Fichier .env créé. IMPORTANT: Modifiez les valeurs avant de continuer!"
|
||||
log_warn "Éditez le fichier .env avec vos paramètres de production"
|
||||
read -p "Appuyez sur Entrée après avoir configuré le fichier .env..."
|
||||
else
|
||||
log_info "Fichier .env trouvé ✓"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour installer les dépendances
|
||||
install_dependencies() {
|
||||
log_info "Installation des dépendances..."
|
||||
pnpm install --frozen-lockfile
|
||||
log_info "Dépendances installées ✓"
|
||||
}
|
||||
|
||||
# Fonction pour sauvegarder la base de données
|
||||
backup_database() {
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
log_warn "DATABASE_URL non définie, sauvegarde de la base de données ignorée"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Sauvegarde de la base de données..."
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/db_backup_$TIMESTAMP.sql"
|
||||
|
||||
# Extraire les informations de connexion de DATABASE_URL
|
||||
# Format: mysql://user:password@host:port/database
|
||||
DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
|
||||
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')
|
||||
|
||||
if command -v mysqldump &> /dev/null; then
|
||||
mysqldump -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS $DB_NAME > $BACKUP_FILE 2>/dev/null || {
|
||||
log_warn "Impossible de créer la sauvegarde de la base de données"
|
||||
return
|
||||
}
|
||||
|
||||
gzip $BACKUP_FILE
|
||||
log_info "Sauvegarde créée: $BACKUP_FILE.gz ✓"
|
||||
|
||||
# Supprimer les sauvegardes de plus de 30 jours
|
||||
find $BACKUP_DIR -name "db_backup_*.sql.gz" -mtime +30 -delete
|
||||
else
|
||||
log_warn "mysqldump non disponible, sauvegarde ignorée"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour appliquer les migrations
|
||||
run_migrations() {
|
||||
log_info "Application des migrations de base de données..."
|
||||
pnpm db:push
|
||||
log_info "Migrations appliquées ✓"
|
||||
}
|
||||
|
||||
# Fonction pour build l'application
|
||||
build_app() {
|
||||
log_info "Build de l'application..."
|
||||
pnpm build
|
||||
log_info "Build terminé ✓"
|
||||
}
|
||||
|
||||
# Fonction pour redémarrer le service
|
||||
restart_service() {
|
||||
if command -v systemctl &> /dev/null; then
|
||||
SERVICE_NAME="formation-manager"
|
||||
|
||||
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||
log_info "Redémarrage du service $SERVICE_NAME..."
|
||||
sudo systemctl restart $SERVICE_NAME
|
||||
|
||||
# Attendre que le service soit actif
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||
log_info "Service redémarré avec succès ✓"
|
||||
else
|
||||
log_error "Échec du redémarrage du service"
|
||||
sudo systemctl status $SERVICE_NAME
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "Le service $SERVICE_NAME n'est pas actif"
|
||||
log_info "Démarrage du service..."
|
||||
sudo systemctl start $SERVICE_NAME
|
||||
fi
|
||||
else
|
||||
log_warn "systemctl non disponible. Redémarrage manuel requis."
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour vérifier le déploiement
|
||||
verify_deployment() {
|
||||
log_info "Vérification du déploiement..."
|
||||
|
||||
# Vérifier que le serveur répond
|
||||
if command -v curl &> /dev/null; then
|
||||
sleep 2
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
|
||||
log_info "Application accessible ✓"
|
||||
else
|
||||
log_warn "L'application ne répond pas correctement (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction principale
|
||||
main() {
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Déploiement - Gestion des Formations Manager Itinova ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Vérifier que nous sommes dans le bon répertoire
|
||||
if [ ! -f "package.json" ]; then
|
||||
log_error "Ce script doit être exécuté depuis la racine du projet"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Étapes de déploiement
|
||||
check_prerequisites
|
||||
setup_env
|
||||
|
||||
# Charger les variables d'environnement
|
||||
if [ -f .env ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
# Demander confirmation avant de continuer
|
||||
echo ""
|
||||
log_warn "Le déploiement va:"
|
||||
echo " 1. Sauvegarder la base de données"
|
||||
echo " 2. Installer les dépendances"
|
||||
echo " 3. Appliquer les migrations"
|
||||
echo " 4. Builder l'application"
|
||||
echo " 5. Redémarrer le service"
|
||||
echo ""
|
||||
read -p "Continuer? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "Déploiement annulé"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
backup_database
|
||||
install_dependencies
|
||||
run_migrations
|
||||
build_app
|
||||
restart_service
|
||||
verify_deployment
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Déploiement terminé avec succès! ✓ ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
log_info "L'application est maintenant accessible"
|
||||
log_info "Vérifiez les logs avec: sudo journalctl -u formation-manager -f"
|
||||
}
|
||||
|
||||
# Exécuter le script
|
||||
main "$@"
|
||||
2123
deployment-package/drizzle/meta/0000_snapshot.json
Normal file
2123
deployment-package/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
deployment-package/drizzle/meta/_journal.json
Normal file
13
deployment-package/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1768296151204,
|
||||
"tag": "0000_clean_justice",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1
deployment-package/drizzle/relations.ts
Normal file
1
deployment-package/drizzle/relations.ts
Normal file
@@ -0,0 +1 @@
|
||||
import {} from "./schema";
|
||||
549
deployment-package/drizzle/schema.ts
Normal file
549
deployment-package/drizzle/schema.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import { boolean, int, mysqlEnum, mysqlTable, text, timestamp, varchar, datetime, decimal } from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* Core user table backing auth flow.
|
||||
* Extend this file with additional tables as your product grows.
|
||||
* Columns use camelCase to match both database fields and generated types.
|
||||
*/
|
||||
export const users = mysqlTable("users", {
|
||||
/**
|
||||
* Surrogate primary key. Auto-incremented numeric value managed by the database.
|
||||
* Use this for relations between tables.
|
||||
*/
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
|
||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 320 }),
|
||||
/** Identifiant de connexion unique pour l'utilisateur */
|
||||
username: varchar("username", { length: 100 }).unique(),
|
||||
/** Mot de passe hashé (bcrypt) */
|
||||
password: varchar("password", { length: 255 }),
|
||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
||||
role: mysqlEnum("role", ["user", "admin", "formateur"]).default("user").notNull(),
|
||||
/** Statut du compte (actif/inactif) */
|
||||
isActive: boolean("isActive").default(true).notNull(),
|
||||
/** ID du formateur associé (si role = formateur) */
|
||||
formateurId: int("formateurId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = typeof users.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des tokens de réinitialisation de mot de passe
|
||||
*/
|
||||
export const passwordResetTokens = mysqlTable("passwordResetTokens", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
|
||||
export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des formations (ex: Manager Itinova)
|
||||
*/
|
||||
export const formations = mysqlTable("formations", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
/** Lien unique pour l'inscription à cette formation */
|
||||
lienUnique: varchar("lienUnique", { length: 100 }).notNull().unique(),
|
||||
actif: boolean("actif").default(true).notNull(),
|
||||
/** Mode de génération des attestations (auto = génération automatique, manuel = import de documents) */
|
||||
modeAttestation: mysqlEnum("modeAttestation", ["auto", "manuel"]).default("auto").notNull(),
|
||||
/** Mode d'envoi des attestations (auto = envoi automatique, manuel = envoi manuel) */
|
||||
modeEnvoi: mysqlEnum("modeEnvoi", ["auto", "manuel"]).default("manuel").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Formation = typeof formations.$inferSelect;
|
||||
export type InsertFormation = typeof formations.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des séquences de formation (ex: Groupe A, Groupe B)
|
||||
* Chaque séquence peut avoir jusqu'à 4 dates de formation
|
||||
*/
|
||||
export const sequences = mysqlTable("sequences", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
formationId: int("formationId").notNull(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
lieu: varchar("lieu", { length: 255 }).notNull(),
|
||||
capaciteMax: int("capaciteMax").notNull(),
|
||||
statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee", "fermee", "annulee"]).default("ouverte").notNull(),
|
||||
/** Public cible de la séquence (Directeur, Chef de service, Autre, Tous) */
|
||||
publicCible: mysqlEnum("publicCible", ["directeur", "chef_service", "autre", "tous"]).default("tous").notNull(),
|
||||
/** ID du formateur assigné à cette séquence */
|
||||
formateurId: int("formateurId"),
|
||||
/** Token unique pour le QR code d'émargement */
|
||||
qrCodeToken: varchar("qrCodeToken", { length: 100 }).unique(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Sequence = typeof sequences.$inferSelect;
|
||||
export type InsertSequence = typeof sequences.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des dates de formation pour chaque séquence
|
||||
* Une séquence peut avoir jusqu'à 4 dates
|
||||
*/
|
||||
export const datesFormation = mysqlTable("datesFormation", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
/** Ordre de la date (1, 2, 3 ou 4) */
|
||||
ordre: int("ordre").notNull(),
|
||||
dateDebut: datetime("dateDebut").notNull(),
|
||||
dateFin: datetime("dateFin").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type DateFormation = typeof datesFormation.$inferSelect;
|
||||
export type InsertDateFormation = typeof datesFormation.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des apprenants
|
||||
*/
|
||||
export const apprenants = mysqlTable("apprenants", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
prenom: varchar("prenom", { length: 255 }).notNull(),
|
||||
email: varchar("email", { length: 320 }).notNull().unique(),
|
||||
codeEtablissement: varchar("codeEtablissement", { length: 50 }),
|
||||
/** Fonction de l'apprenant (Directeur, Chef de service, Autre) */
|
||||
fonction: varchar("fonction", { length: 100 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Apprenant = typeof apprenants.$inferSelect;
|
||||
export type InsertApprenant = typeof apprenants.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des inscriptions
|
||||
*/
|
||||
export const inscriptions = mysqlTable("inscriptions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(),
|
||||
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Inscription = typeof inscriptions.$inferSelect;
|
||||
export type InsertInscription = typeof inscriptions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table de configuration des emails
|
||||
*/
|
||||
export const emailConfig = mysqlTable("emailConfig", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Provider d'envoi d'emails (resend, smtp, simulation) */
|
||||
provider: mysqlEnum("provider", ["resend", "smtp", "simulation"]).default("simulation").notNull(),
|
||||
/** Clé API Resend */
|
||||
resendApiKey: varchar("resendApiKey", { length: 255 }),
|
||||
/** Hôte SMTP */
|
||||
smtpHost: varchar("smtpHost", { length: 255 }),
|
||||
/** Port SMTP */
|
||||
smtpPort: int("smtpPort"),
|
||||
/** Sécurité SMTP (TLS, SSL, None) */
|
||||
smtpSecure: mysqlEnum("smtpSecure", ["tls", "ssl", "none"]),
|
||||
/** Utilisateur SMTP */
|
||||
smtpUser: varchar("smtpUser", { length: 255 }),
|
||||
/** Mot de passe SMTP */
|
||||
smtpPassword: varchar("smtpPassword", { length: 255 }),
|
||||
/** Email expéditeur */
|
||||
fromEmail: varchar("fromEmail", { length: 320 }).notNull(),
|
||||
/** Nom de l'expéditeur */
|
||||
fromName: varchar("fromName", { length: 255 }).notNull(),
|
||||
/** Mode de fonctionnement (simulation ou production) */
|
||||
mode: mysqlEnum("mode", ["simulation", "production"]).default("simulation").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type EmailConfig = typeof emailConfig.$inferSelect;
|
||||
export type InsertEmailConfig = typeof emailConfig.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des templates d'emails
|
||||
*/
|
||||
export const emailTemplates = mysqlTable("emailTemplates", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Type de template (inscription, teaser, rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
|
||||
type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull().unique(),
|
||||
titre: varchar("titre", { length: 255 }).notNull(),
|
||||
/** Corps du message avec variables dynamiques */
|
||||
bodyContent: text("bodyContent").notNull(),
|
||||
couleurPrincipale: varchar("couleurPrincipale", { length: 7 }).default("#283581").notNull(),
|
||||
couleurSecondaire: varchar("couleurSecondaire", { length: 7 }).default("#0578BE").notNull(),
|
||||
piedDePage: text("piedDePage"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||
export type InsertEmailTemplate = typeof emailTemplates.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des formateurs
|
||||
*/
|
||||
export const formateurs = mysqlTable("formateurs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
email: varchar("email", { length: 320 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Formateur = typeof formateurs.$inferSelect;
|
||||
export type InsertFormateur = typeof formateurs.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des rappels automatiques
|
||||
*/
|
||||
export const rappels = mysqlTable("rappels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
nom: varchar("nom", { length: 255 }).notNull(),
|
||||
/** Type de template d'email à utiliser (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
|
||||
templateType: mysqlEnum("templateType", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(),
|
||||
/** Nombre de jours avant la séquence */
|
||||
joursAvant: int("joursAvant").notNull(),
|
||||
/** Heure d'envoi (format HH:mm) */
|
||||
heureEnvoi: varchar("heureEnvoi", { length: 5 }).default("09:00").notNull(),
|
||||
/** Timing du rappel (pre_formation ou post_formation) */
|
||||
timing: mysqlEnum("timing", ["pre_formation", "post_formation"]).default("pre_formation").notNull(),
|
||||
/** Nom du fichier joint (optionnel) */
|
||||
nomFichier: varchar("nomFichier", { length: 255 }),
|
||||
/** URL du fichier sur S3 (optionnel) */
|
||||
urlFichier: varchar("urlFichier", { length: 500 }),
|
||||
/** Clé S3 du fichier (optionnel) */
|
||||
s3Key: varchar("s3Key", { length: 500 }),
|
||||
/** Type MIME du fichier (optionnel) */
|
||||
typeFichier: varchar("typeFichier", { length: 100 }),
|
||||
/** Taille du fichier en octets (optionnel) */
|
||||
tailleFichier: int("tailleFichier"),
|
||||
actif: boolean("actif").default(true).notNull(),
|
||||
derniereExecution: datetime("derniereExecution"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Rappel = typeof rappels.$inferSelect;
|
||||
export type InsertRappel = typeof rappels.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table de liaison entre rappels et dates de formation
|
||||
* Si aucune date n'est associée, le rappel s'applique à toutes les dates
|
||||
*/
|
||||
export const rappelDates = mysqlTable("rappelDates", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
rappelId: int("rappelId").notNull(),
|
||||
dateFormationId: int("dateFormationId").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type RappelDate = typeof rappelDates.$inferSelect;
|
||||
export type InsertRappelDate = typeof rappelDates.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des logs de rappels envoyés
|
||||
*/
|
||||
export const logsRappels = mysqlTable("logsRappels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
rappelId: int("rappelId").notNull(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
email: varchar("email", { length: 320 }).notNull(),
|
||||
/** Type de rappel (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */
|
||||
type: mysqlEnum("type", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(),
|
||||
statut: mysqlEnum("statut", ["succes", "echec"]).notNull(),
|
||||
messageErreur: text("messageErreur"),
|
||||
/** Nombre de tentatives d'envoi */
|
||||
nbTentatives: int("nbTentatives").default(1).notNull(),
|
||||
/** Date du prochain essai en cas d'échec */
|
||||
prochainEssai: datetime("prochainEssai"),
|
||||
dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type LogRappel = typeof logsRappels.$inferSelect;
|
||||
export type InsertLogRappel = typeof logsRappels.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des questionnaires de satisfaction et d'évaluation
|
||||
*/
|
||||
export const questionnaires = mysqlTable("questionnaires", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
titre: varchar("titre", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
/** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */
|
||||
type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(),
|
||||
actif: boolean("actif").default(true).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 d'un questionnaire
|
||||
*/
|
||||
export const questions = mysqlTable("questions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
questionnaireId: int("questionnaireId").notNull(),
|
||||
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 les questions à choix multiples (JSON) */
|
||||
options: text("options"),
|
||||
/** Valeur minimale pour les échelles */
|
||||
valeurMin: int("valeurMin"),
|
||||
/** Valeur maximale pour les échelles */
|
||||
valeurMax: int("valeurMax"),
|
||||
obligatoire: boolean("obligatoire").default(false).notNull(),
|
||||
ordre: int("ordre").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Question = typeof questions.$inferSelect;
|
||||
export type InsertQuestion = typeof questions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des réponses aux questionnaires
|
||||
*/
|
||||
export const reponsesQuestionnaires = mysqlTable("reponsesQuestionnaires", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
questionnaireId: int("questionnaireId").notNull(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
dateReponse: timestamp("dateReponse").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ReponseQuestionnaire = typeof reponsesQuestionnaires.$inferSelect;
|
||||
export type InsertReponseQuestionnaire = typeof reponsesQuestionnaires.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des réponses individuelles aux questions
|
||||
*/
|
||||
export const reponsesQuestions = mysqlTable("reponsesQuestions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
reponseQuestionnaireId: int("reponseQuestionnaireId").notNull(),
|
||||
questionId: int("questionId").notNull(),
|
||||
/** Réponse textuelle */
|
||||
reponseTexte: text("reponseTexte"),
|
||||
/** Réponse numérique (pour échelles et oui/non) */
|
||||
reponseNumerique: int("reponseNumerique"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ReponseQuestion = typeof reponsesQuestions.$inferSelect;
|
||||
export type InsertReponseQuestion = typeof reponsesQuestions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des envois de questionnaires
|
||||
*/
|
||||
export const envoisQuestionnaires = mysqlTable("envoisQuestionnaires", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
questionnaireId: int("questionnaireId").notNull(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
/** Token unique pour accéder au questionnaire */
|
||||
token: varchar("token", { length: 100 }).notNull().unique(),
|
||||
dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
|
||||
dateReponse: timestamp("dateReponse"),
|
||||
statut: mysqlEnum("statut", ["envoye", "repondu"]).default("envoye").notNull(),
|
||||
});
|
||||
|
||||
export type EnvoiQuestionnaire = typeof envoisQuestionnaires.$inferSelect;
|
||||
export type InsertEnvoiQuestionnaire = typeof envoisQuestionnaires.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des supports de formation uploadés par les formateurs
|
||||
*/
|
||||
export const supportsFormation = mysqlTable("supportsFormation", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
formateurId: int("formateurId").notNull(),
|
||||
nomFichier: varchar("nomFichier", { length: 255 }).notNull(),
|
||||
typeFichier: varchar("typeFichier", { length: 100 }).notNull(),
|
||||
tailleFichier: int("tailleFichier").notNull(),
|
||||
urlFichier: varchar("urlFichier", { length: 500 }).notNull(),
|
||||
s3Key: varchar("s3Key", { length: 500 }).notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type SupportFormation = typeof supportsFormation.$inferSelect;
|
||||
export type InsertSupportFormation = typeof supportsFormation.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des présences (émargement)
|
||||
*/
|
||||
export const presences = mysqlTable("presences", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
inscriptionId: int("inscriptionId").notNull(),
|
||||
dateFormationId: int("dateFormationId").notNull(),
|
||||
heurePresence: timestamp("heurePresence").defaultNow().notNull(),
|
||||
/** Mode de validation (qrcode, manuel) */
|
||||
modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(),
|
||||
/** ID de l'utilisateur qui a validé (formateur ou admin) */
|
||||
validateurId: int("validateurId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Presence = typeof presences.$inferSelect;
|
||||
export type InsertPresence = typeof presences.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des attestations de formation
|
||||
*/
|
||||
export const attestations = mysqlTable("attestations", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
inscriptionId: int("inscriptionId").notNull(),
|
||||
urlPdf: varchar("urlPdf", { length: 500 }),
|
||||
s3Key: varchar("s3Key", { length: 500 }),
|
||||
/** URL du document uploadé manuellement */
|
||||
documentUrl: varchar("documentUrl", { length: 500 }),
|
||||
/** Clé S3 du document uploadé manuellement */
|
||||
documentS3Key: varchar("documentS3Key", { length: 500 }),
|
||||
/** ID de l'utilisateur qui a uploadé le document */
|
||||
uploadedBy: int("uploadedBy"),
|
||||
/** Date d'upload du document */
|
||||
uploadedAt: timestamp("uploadedAt"),
|
||||
/** Email envoyé ou non */
|
||||
emailEnvoye: boolean("emailEnvoye").default(false).notNull(),
|
||||
/** Date d'envoi de l'email */
|
||||
dateEnvoiEmail: timestamp("dateEnvoiEmail"),
|
||||
dateGeneration: timestamp("dateGeneration").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Attestation = typeof attestations.$inferSelect;
|
||||
export type InsertAttestation = typeof attestations.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table de configuration des attestations
|
||||
*/
|
||||
export const configAttestation = mysqlTable("configAttestation", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Texte personnalisable de l'attestation avec variables dynamiques */
|
||||
texteAttestation: text("texteAttestation").notNull(),
|
||||
/** Nom du signataire */
|
||||
nomSignataire: varchar("nomSignataire", { length: 255 }).notNull(),
|
||||
/** Fonction du signataire */
|
||||
fonctionSignataire: varchar("fonctionSignataire", { length: 255 }).notNull(),
|
||||
/** URL du logo sur S3 */
|
||||
logoUrl: varchar("logoUrl", { length: 500 }),
|
||||
/** Clé S3 du logo */
|
||||
logoS3Key: varchar("logoS3Key", { length: 500 }),
|
||||
/** URL de la signature sur S3 */
|
||||
signatureUrl: varchar("signatureUrl", { length: 500 }),
|
||||
/** Clé S3 de la signature */
|
||||
signatureS3Key: varchar("signatureS3Key", { length: 500 }),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type ConfigAttestation = typeof configAttestation.$inferSelect;
|
||||
export type InsertConfigAttestation = typeof configAttestation.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des notifications envoyées
|
||||
*/
|
||||
export const logsNotifications = mysqlTable("logsNotifications", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Type de notification */
|
||||
type: mysqlEnum("type", [
|
||||
"inscription",
|
||||
"annulation",
|
||||
"capacite_atteinte",
|
||||
"place_disponible",
|
||||
"remerciement",
|
||||
"formateur_inscription",
|
||||
"formateur_annulation"
|
||||
]).notNull(),
|
||||
/** Email du destinataire */
|
||||
destinataire: varchar("destinataire", { length: 320 }).notNull(),
|
||||
/** ID de la séquence concernée */
|
||||
sequenceId: int("sequenceId"),
|
||||
/** ID de l'apprenant concerné */
|
||||
apprenantId: int("apprenantId"),
|
||||
/** Statut de l'envoi */
|
||||
statut: mysqlEnum("statut", ["succes", "echec"]).notNull(),
|
||||
/** Message d'erreur en cas d'échec */
|
||||
messageErreur: text("messageErreur"),
|
||||
dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type LogNotification = typeof logsNotifications.$inferSelect;
|
||||
export type InsertLogNotification = typeof logsNotifications.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table des paramètres de l'application
|
||||
* Stocke les paramètres globaux de configuration
|
||||
*/
|
||||
export const parametres = mysqlTable("parametres", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** URL publique de l'application pour les QR codes d'émargement */
|
||||
urlPublique: varchar("urlPublique", { length: 500 }).notNull().default("https://formations.itinova.org"),
|
||||
/** Délai d'expiration des QR codes en minutes (par défaut 30 minutes) */
|
||||
delaiExpirationQR: int("delaiExpirationQR").default(30).notNull(),
|
||||
/** Durée de validité des tokens d'émargement en heures (par défaut 24 heures) */
|
||||
dureeValiditeToken: int("dureeValiditeToken").default(24).notNull(),
|
||||
/** Activer ou désactiver les notifications automatiques */
|
||||
notificationsActives: boolean("notificationsActives").default(true).notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Parametre = typeof parametres.$inferSelect;
|
||||
export type InsertParametre = typeof parametres.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table de l'historique des modifications des paramètres
|
||||
* Permet de tracer qui a modifié quoi et quand
|
||||
*/
|
||||
export const historiqueParametres = mysqlTable("historiqueParametres", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** ID de l'utilisateur qui a effectué la modification */
|
||||
userId: int("userId").notNull(),
|
||||
/** Nom de l'utilisateur (pour historique même si user supprimé) */
|
||||
userName: varchar("userName", { length: 255 }).notNull(),
|
||||
/** Champ modifié */
|
||||
champModifie: varchar("champModifie", { length: 100 }).notNull(),
|
||||
/** Ancienne valeur (JSON) */
|
||||
ancienneValeur: text("ancienneValeur"),
|
||||
/** Nouvelle valeur (JSON) */
|
||||
nouvelleValeur: text("nouvelleValeur"),
|
||||
dateModification: timestamp("dateModification").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type HistoriqueParametre = typeof historiqueParametres.$inferSelect;
|
||||
export type InsertHistoriqueParametre = typeof historiqueParametres.$inferInsert;
|
||||
|
||||
/**
|
||||
* Table de l'historique des envois d'attestations de formation
|
||||
* Permet de tracer tous les envois d'attestations
|
||||
*/
|
||||
export const historiqueAttestations = mysqlTable("historiqueAttestations", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sequenceId: int("sequenceId").notNull(),
|
||||
apprenantId: int("apprenantId").notNull(),
|
||||
dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(),
|
||||
statut: mysqlEnum("statut", ["envoye", "erreur"]).notNull(),
|
||||
messageErreur: text("messageErreur"),
|
||||
urlAttestation: varchar("urlAttestation", { length: 500 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect;
|
||||
export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert;
|
||||
136
deployment-package/package.json
Normal file
136
deployment-package/package.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "formation-manager-itinova",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx watch server/_core/index.ts",
|
||||
"build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tiptap/extension-color": "^3.11.0",
|
||||
"@tiptap/extension-link": "^3.11.0",
|
||||
"@tiptap/extension-text-style": "^3.11.0",
|
||||
"@tiptap/react": "^3.11.0",
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"axios": "^1.12.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie": "^1.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^12.23.22",
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "6.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.15.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.11",
|
||||
"openai": "^4.67.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.64.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.4.0",
|
||||
"superjson": "^1.13.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.7.0",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"add": "^2.0.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "^0.25.0",
|
||||
"pnpm": "^10.15.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.19.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-manus-runtime": "^0.0.56",
|
||||
"vitest": "^2.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"wouter@3.7.1": "patches/wouter@3.7.1.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"tailwindcss>nanoid": "3.3.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
11959
deployment-package/pnpm-lock.yaml
generated
Normal file
11959
deployment-package/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
deployment-package/source_server/__tests__/localAuth.test.ts
Normal file
106
deployment-package/source_server/__tests__/localAuth.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { getDb } from "../db";
|
||||
import { users } from "../../drizzle/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
describe("Local Authentication", () => {
|
||||
beforeAll(async () => {
|
||||
// Vérifier que la base de données est accessible
|
||||
const db = await getDb();
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have an admin user with username adminServFormation", async () => {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Database not available");
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "adminServFormation"))
|
||||
.limit(1);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].username).toBe("adminServFormation");
|
||||
expect(result[0].role).toBe("admin");
|
||||
expect(result[0].password).toBeDefined();
|
||||
expect(result[0].password).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should authenticate with correct credentials", async () => {
|
||||
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "adminServFormation",
|
||||
password: "Itinova69!",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.user).toBeDefined();
|
||||
expect(data.user.username).toBe("adminServFormation");
|
||||
expect(data.user.role).toBe("admin");
|
||||
expect(data.user.password).toBeUndefined(); // Le mot de passe ne doit pas être retourné
|
||||
});
|
||||
|
||||
it("should reject authentication with incorrect password", async () => {
|
||||
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "adminServFormation",
|
||||
password: "wrongpassword",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.message).toContain("incorrect");
|
||||
});
|
||||
|
||||
it("should reject authentication with non-existent username", async () => {
|
||||
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "nonexistentuser",
|
||||
password: "anypassword",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.message).toContain("incorrect");
|
||||
});
|
||||
|
||||
it("should reject authentication with missing credentials", async () => {
|
||||
const response = await fetch("http://localhost:3000/api/auth/local/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "adminServFormation",
|
||||
// password manquant
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.message).toContain("requis");
|
||||
});
|
||||
});
|
||||
32
deployment-package/source_server/_core/appUrl.ts
Normal file
32
deployment-package/source_server/_core/appUrl.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Récupère l'URL de base de l'application
|
||||
* En production, utilise l'URL publique configurée
|
||||
* En développement, utilise localhost
|
||||
*/
|
||||
export function getAppBaseUrl(): string {
|
||||
// Si une URL publique est configurée, l'utiliser
|
||||
if (process.env.APP_PUBLIC_URL) {
|
||||
return process.env.APP_PUBLIC_URL;
|
||||
}
|
||||
|
||||
// En production, essayer de détecter l'URL depuis les variables d'environnement Manus
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// Pour les applications Manus déployées, l'URL est généralement disponible
|
||||
// via les variables d'environnement du système
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Si on est sur un serveur VPS avec un domaine configuré
|
||||
if (process.env.DOMAIN_NAME) {
|
||||
return `https://${process.env.DOMAIN_NAME}`;
|
||||
}
|
||||
|
||||
// Sinon, utiliser l'URL du serveur si disponible
|
||||
if (process.env.SERVER_URL) {
|
||||
return process.env.SERVER_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// Par défaut, utiliser localhost (développement)
|
||||
const port = process.env.PORT || 3000;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
28
deployment-package/source_server/_core/context.ts
Normal file
28
deployment-package/source_server/_core/context.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import { sdk } from "./sdk";
|
||||
|
||||
export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
res: CreateExpressContextOptions["res"];
|
||||
user: User | null;
|
||||
};
|
||||
|
||||
export async function createContext(
|
||||
opts: CreateExpressContextOptions
|
||||
): Promise<TrpcContext> {
|
||||
let user: User | null = null;
|
||||
|
||||
try {
|
||||
user = await sdk.authenticateRequest(opts.req);
|
||||
} catch (error) {
|
||||
// Authentication is optional for public procedures.
|
||||
user = null;
|
||||
}
|
||||
|
||||
return {
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
user,
|
||||
};
|
||||
}
|
||||
48
deployment-package/source_server/_core/cookies.ts
Normal file
48
deployment-package/source_server/_core/cookies.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CookieOptions, Request } from "express";
|
||||
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function isIpAddress(host: string) {
|
||||
// Basic IPv4 check and IPv6 presence detection.
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
||||
return host.includes(":");
|
||||
}
|
||||
|
||||
function isSecureRequest(req: Request) {
|
||||
if (req.protocol === "https") return true;
|
||||
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (!forwardedProto) return false;
|
||||
|
||||
const protoList = Array.isArray(forwardedProto)
|
||||
? forwardedProto
|
||||
: forwardedProto.split(",");
|
||||
|
||||
return protoList.some(proto => proto.trim().toLowerCase() === "https");
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions(
|
||||
req: Request
|
||||
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
|
||||
// const hostname = req.hostname;
|
||||
// const shouldSetDomain =
|
||||
// hostname &&
|
||||
// !LOCAL_HOSTS.has(hostname) &&
|
||||
// !isIpAddress(hostname) &&
|
||||
// hostname !== "127.0.0.1" &&
|
||||
// hostname !== "::1";
|
||||
|
||||
// const domain =
|
||||
// shouldSetDomain && !hostname.startsWith(".")
|
||||
// ? `.${hostname}`
|
||||
// : shouldSetDomain
|
||||
// ? hostname
|
||||
// : undefined;
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: isSecureRequest(req),
|
||||
};
|
||||
}
|
||||
64
deployment-package/source_server/_core/dataApi.ts
Normal file
64
deployment-package/source_server/_core/dataApi.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Quick example (matches curl usage):
|
||||
* await callDataApi("Youtube/search", {
|
||||
* query: { gl: "US", hl: "en", q: "manus" },
|
||||
* })
|
||||
*/
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type DataApiCallOptions = {
|
||||
query?: Record<string, unknown>;
|
||||
body?: Record<string, unknown>;
|
||||
pathParams?: Record<string, unknown>;
|
||||
formData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function callDataApi(
|
||||
apiId: string,
|
||||
options: DataApiCallOptions = {}
|
||||
): Promise<unknown> {
|
||||
if (!ENV.forgeApiUrl) {
|
||||
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
|
||||
}
|
||||
if (!ENV.forgeApiKey) {
|
||||
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
|
||||
}
|
||||
|
||||
// Build the full URL by appending the service path to the base URL
|
||||
const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
|
||||
const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString();
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"connect-protocol-version": "1",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiId,
|
||||
query: options.query,
|
||||
body: options.body,
|
||||
path_params: options.pathParams,
|
||||
multipart_form_data: options.formData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (payload && typeof payload === "object" && "jsonData" in payload) {
|
||||
try {
|
||||
return JSON.parse((payload as Record<string, string>).jsonData ?? "{}");
|
||||
} catch {
|
||||
return (payload as Record<string, unknown>).jsonData;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
206
deployment-package/source_server/_core/emailSender.ts
Normal file
206
deployment-package/source_server/_core/emailSender.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Service d'envoi d'emails réels via Resend API
|
||||
*
|
||||
* Configuration requise:
|
||||
* - RESEND_API_KEY: Clé API Resend (obtenir sur https://resend.com)
|
||||
* - RESEND_FROM_EMAIL: Adresse email d'envoi (doit être vérifiée dans Resend)
|
||||
*
|
||||
* Si ces variables ne sont pas configurées, les emails seront simulés
|
||||
* et envoyés comme notifications au propriétaire du projet.
|
||||
*/
|
||||
|
||||
import { ENV } from "./env";
|
||||
import { notifyOwner } from "./notification";
|
||||
import { getActiveEmailConfig } from "../db";
|
||||
import { sendViaSMTP } from "./smtpSender";
|
||||
|
||||
interface EmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResendEmailRequest {
|
||||
from: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email via Resend API
|
||||
*/
|
||||
async function sendViaResend(params: EmailParams, config?: { apiKey: string; fromEmail: string; fromName: string }): Promise<boolean> {
|
||||
// Utiliser la config fournie ou les variables d'environnement
|
||||
const resendApiKey = config?.apiKey || process.env.RESEND_API_KEY;
|
||||
const fromEmail = config?.fromEmail || process.env.RESEND_FROM_EMAIL || 'noreply@manusvm.computer';
|
||||
const fromName = config?.fromName || 'Formation Manager Itinova';
|
||||
|
||||
if (!resendApiKey) {
|
||||
console.warn('[Email] RESEND_API_KEY non configurée, email simulé');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: ResendEmailRequest = {
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: [params.to],
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
};
|
||||
|
||||
// Ajouter les pièces jointes si présentes
|
||||
if (params.attachments && params.attachments.length > 0) {
|
||||
payload.attachments = params.attachments.map(att => ({
|
||||
filename: att.filename,
|
||||
content: att.content, // Resend accepte base64 ou string
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('[Email] Erreur Resend:', response.status, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[Email] Email envoyé via Resend:', result.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Email] Exception lors de l\'envoi via Resend:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simule l'envoi d'un email en créant une notification pour le propriétaire
|
||||
*/
|
||||
async function simulateEmail(params: EmailParams): Promise<boolean> {
|
||||
console.log('=== EMAIL SIMULÉ ===');
|
||||
console.log('To:', params.to);
|
||||
console.log('Subject:', params.subject);
|
||||
|
||||
const emailContent = `
|
||||
Destinataire: ${params.to}
|
||||
Sujet: ${params.subject}
|
||||
|
||||
${params.html.replace(/<[^>]*>/g, '').substring(0, 500)}...
|
||||
|
||||
${params.attachments ? `Pièces jointes: ${params.attachments.map(a => a.filename).join(', ')}` : ''}
|
||||
|
||||
⚠️ Cet email est simulé. Pour envoyer de vrais emails:
|
||||
1. Créez un compte sur https://resend.com
|
||||
2. Ajoutez RESEND_API_KEY dans les secrets du projet
|
||||
3. Ajoutez RESEND_FROM_EMAIL (ex: noreply@votredomaine.com)`;
|
||||
|
||||
try {
|
||||
await notifyOwner({
|
||||
title: `📧 Email simulé: ${params.subject}`,
|
||||
content: emailContent,
|
||||
});
|
||||
console.log('✓ Email simulé (notification envoyée au propriétaire)');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la simulation d\'email:', error);
|
||||
}
|
||||
|
||||
console.log('===================');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email (réel ou simulé selon la configuration)
|
||||
*/
|
||||
export async function sendEmail(params: EmailParams): Promise<boolean> {
|
||||
// Récupérer la configuration depuis la base de données
|
||||
const config = await getActiveEmailConfig();
|
||||
|
||||
console.log('[Email] Configuration récupérée:', config ? {
|
||||
provider: config.provider,
|
||||
mode: config.mode,
|
||||
fromEmail: config.fromEmail,
|
||||
hasSmtpHost: !!config.smtpHost,
|
||||
hasSmtpUser: !!config.smtpUser,
|
||||
hasSmtpPassword: !!config.smtpPassword,
|
||||
} : 'Aucune configuration');
|
||||
|
||||
// Si mode simulation ou pas de config, simuler
|
||||
if (!config || config.mode === 'simulation') {
|
||||
console.log('[Email] Mode simulation activé ou pas de config');
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
// Si mode production
|
||||
if (config.mode === 'production') {
|
||||
console.log('[Email] Mode production activé');
|
||||
|
||||
// Essayer SMTP en priorité si configuré
|
||||
if (config.provider === 'smtp' && config.smtpHost && config.smtpPort && config.smtpUser && config.smtpPassword) {
|
||||
console.log('[Email] Tentative d\'envoi via SMTP...');
|
||||
|
||||
const sent = await sendViaSMTP(params, {
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure || 'tls',
|
||||
user: config.smtpUser,
|
||||
password: config.smtpPassword,
|
||||
fromEmail: config.fromEmail,
|
||||
fromName: config.fromName,
|
||||
});
|
||||
|
||||
// Si l'envoi échoue, simuler en fallback
|
||||
if (!sent) {
|
||||
console.log('[Email] Échec de l\'envoi SMTP, basculement en simulation');
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
console.log('[Email] Email envoyé avec succès via SMTP');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sinon essayer Resend
|
||||
if (config.provider === 'resend' && config.apiKey) {
|
||||
console.log('[Email] Tentative d\'envoi via Resend...');
|
||||
const sent = await sendViaResend(params, {
|
||||
apiKey: config.apiKey,
|
||||
fromEmail: config.fromEmail,
|
||||
fromName: config.fromName,
|
||||
});
|
||||
|
||||
// Si l'envoi échoue, simuler en fallback
|
||||
if (!sent) {
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: simuler
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le service d'envoi d'emails réel est configuré
|
||||
*/
|
||||
export function isEmailServiceConfigured(): boolean {
|
||||
return !!process.env.RESEND_API_KEY;
|
||||
}
|
||||
11
deployment-package/source_server/_core/env.ts
Normal file
11
deployment-package/source_server/_core/env.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const ENV = {
|
||||
appId: process.env.VITE_APP_ID ?? "",
|
||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||
jwtSecret: process.env.JWT_SECRET ?? "",
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
};
|
||||
59
deployment-package/source_server/_core/fileUpload.ts
Normal file
59
deployment-package/source_server/_core/fileUpload.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { storagePut } from "../storage";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
|
||||
// Configuration de multer pour l'upload en mémoire
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10 Mo max
|
||||
},
|
||||
});
|
||||
|
||||
// Middleware pour l'upload d'un seul fichier
|
||||
export const uploadSingle = upload.single("file");
|
||||
|
||||
// Handler pour l'upload de fichier vers S3
|
||||
export async function handleFileUpload(req: Request, res: Response) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "Aucun fichier fourni" });
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
||||
const ext = path.extname(file.originalname);
|
||||
const baseName = path.basename(file.originalname, ext);
|
||||
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const fileName = `${safeBaseName}-${randomSuffix}${ext}`;
|
||||
|
||||
// Chemin S3 pour les pièces jointes de rappels
|
||||
const s3Key = `rappels-attachments/${fileName}`;
|
||||
|
||||
// Upload vers S3
|
||||
const { url, key } = await storagePut(
|
||||
s3Key,
|
||||
file.buffer,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
console.log(`[FileUpload] Fichier uploadé: ${fileName} (${file.size} octets)`);
|
||||
|
||||
return res.json({
|
||||
url,
|
||||
key,
|
||||
filename: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[FileUpload] Erreur:", error);
|
||||
return res.status(500).json({
|
||||
error: "Erreur lors de l'upload du fichier"
|
||||
});
|
||||
}
|
||||
}
|
||||
92
deployment-package/source_server/_core/imageGeneration.ts
Normal file
92
deployment-package/source_server/_core/imageGeneration.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Image generation helper using internal ImageService
|
||||
*
|
||||
* Example usage:
|
||||
* const { url: imageUrl } = await generateImage({
|
||||
* prompt: "A serene landscape with mountains"
|
||||
* });
|
||||
*
|
||||
* For editing:
|
||||
* const { url: imageUrl } = await generateImage({
|
||||
* prompt: "Add a rainbow to this landscape",
|
||||
* originalImages: [{
|
||||
* url: "https://example.com/original.jpg",
|
||||
* mimeType: "image/jpeg"
|
||||
* }]
|
||||
* });
|
||||
*/
|
||||
import { storagePut } from "server/storage";
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type GenerateImageOptions = {
|
||||
prompt: string;
|
||||
originalImages?: Array<{
|
||||
url?: string;
|
||||
b64Json?: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GenerateImageResponse = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export async function generateImage(
|
||||
options: GenerateImageOptions
|
||||
): Promise<GenerateImageResponse> {
|
||||
if (!ENV.forgeApiUrl) {
|
||||
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
|
||||
}
|
||||
if (!ENV.forgeApiKey) {
|
||||
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
|
||||
}
|
||||
|
||||
// Build the full URL by appending the service path to the base URL
|
||||
const baseUrl = ENV.forgeApiUrl.endsWith("/")
|
||||
? ENV.forgeApiUrl
|
||||
: `${ENV.forgeApiUrl}/`;
|
||||
const fullUrl = new URL(
|
||||
"images.v1.ImageService/GenerateImage",
|
||||
baseUrl
|
||||
).toString();
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"connect-protocol-version": "1",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: options.prompt,
|
||||
original_images: options.originalImages || [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
image: {
|
||||
b64Json: string;
|
||||
mimeType: string;
|
||||
};
|
||||
};
|
||||
const base64Data = result.image.b64Json;
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
// Save to S3
|
||||
const { url } = await storagePut(
|
||||
`generated/${Date.now()}.png`,
|
||||
buffer,
|
||||
result.image.mimeType
|
||||
);
|
||||
return {
|
||||
url,
|
||||
};
|
||||
}
|
||||
82
deployment-package/source_server/_core/index.ts
Normal file
82
deployment-package/source_server/_core/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
import net from "net";
|
||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { registerOAuthRoutes } from "./oauth";
|
||||
import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import localAuthRouter from "./localAuth";
|
||||
import uploadImageRouter from "./uploadImage";
|
||||
import uploadFileRouter from "./uploadFile";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { initRappelScheduler } from "../rappelScheduler";
|
||||
import { initRappelRetryScheduler } from "../rappelRetry";
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer();
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||||
for (let port = startPort; port < startPort + 20; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port found starting from ${startPort}`);
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
// Configure body parser with larger size limit for file uploads
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||
// OAuth callback under /api/oauth/callback
|
||||
registerOAuthRoutes(app);
|
||||
// Local authentication under /api/auth/local
|
||||
app.use("/api/auth/local", localAuthRouter);
|
||||
// Image upload under /api/upload-image
|
||||
app.use("/api/upload-image", uploadImageRouter);
|
||||
// File upload under /api/upload-file
|
||||
app.use("/api/upload-file", uploadFileRouter);
|
||||
// tRPC API
|
||||
app.use(
|
||||
"/api/trpc",
|
||||
createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
})
|
||||
);
|
||||
// development mode uses Vite, production mode uses static files
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await setupVite(app, server);
|
||||
} else {
|
||||
serveStatic(app);
|
||||
}
|
||||
|
||||
const preferredPort = parseInt(process.env.PORT || "3000");
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
|
||||
if (port !== preferredPort) {
|
||||
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
|
||||
}
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
|
||||
// Initialiser le scheduler de rappels automatiques
|
||||
initRappelScheduler();
|
||||
|
||||
// Initialiser le scheduler de réessai automatique
|
||||
initRappelRetryScheduler();
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
332
deployment-package/source_server/_core/llm.ts
Normal file
332
deployment-package/source_server/_core/llm.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||
|
||||
export type TextContent = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ImageContent = {
|
||||
type: "image_url";
|
||||
image_url: {
|
||||
url: string;
|
||||
detail?: "auto" | "low" | "high";
|
||||
};
|
||||
};
|
||||
|
||||
export type FileContent = {
|
||||
type: "file_url";
|
||||
file_url: {
|
||||
url: string;
|
||||
mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageContent = string | TextContent | ImageContent | FileContent;
|
||||
|
||||
export type Message = {
|
||||
role: Role;
|
||||
content: MessageContent | MessageContent[];
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolChoicePrimitive = "none" | "auto" | "required";
|
||||
export type ToolChoiceByName = { name: string };
|
||||
export type ToolChoiceExplicit = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolChoice =
|
||||
| ToolChoicePrimitive
|
||||
| ToolChoiceByName
|
||||
| ToolChoiceExplicit;
|
||||
|
||||
export type InvokeParams = {
|
||||
messages: Message[];
|
||||
tools?: Tool[];
|
||||
toolChoice?: ToolChoice;
|
||||
tool_choice?: ToolChoice;
|
||||
maxTokens?: number;
|
||||
max_tokens?: number;
|
||||
outputSchema?: OutputSchema;
|
||||
output_schema?: OutputSchema;
|
||||
responseFormat?: ResponseFormat;
|
||||
response_format?: ResponseFormat;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type InvokeResult = {
|
||||
id: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: Role;
|
||||
content: string | Array<TextContent | ImageContent | FileContent>;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
finish_reason: string | null;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type JsonSchema = {
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
export type OutputSchema = JsonSchema;
|
||||
|
||||
export type ResponseFormat =
|
||||
| { type: "text" }
|
||||
| { type: "json_object" }
|
||||
| { type: "json_schema"; json_schema: JsonSchema };
|
||||
|
||||
const ensureArray = (
|
||||
value: MessageContent | MessageContent[]
|
||||
): MessageContent[] => (Array.isArray(value) ? value : [value]);
|
||||
|
||||
const normalizeContentPart = (
|
||||
part: MessageContent
|
||||
): TextContent | ImageContent | FileContent => {
|
||||
if (typeof part === "string") {
|
||||
return { type: "text", text: part };
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part.type === "image_url") {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part.type === "file_url") {
|
||||
return part;
|
||||
}
|
||||
|
||||
throw new Error("Unsupported message content part");
|
||||
};
|
||||
|
||||
const normalizeMessage = (message: Message) => {
|
||||
const { role, name, tool_call_id } = message;
|
||||
|
||||
if (role === "tool" || role === "function") {
|
||||
const content = ensureArray(message.content)
|
||||
.map(part => (typeof part === "string" ? part : JSON.stringify(part)))
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
role,
|
||||
name,
|
||||
tool_call_id,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
const contentParts = ensureArray(message.content).map(normalizeContentPart);
|
||||
|
||||
// If there's only text content, collapse to a single string for compatibility
|
||||
if (contentParts.length === 1 && contentParts[0].type === "text") {
|
||||
return {
|
||||
role,
|
||||
name,
|
||||
content: contentParts[0].text,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
name,
|
||||
content: contentParts,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeToolChoice = (
|
||||
toolChoice: ToolChoice | undefined,
|
||||
tools: Tool[] | undefined
|
||||
): "none" | "auto" | ToolChoiceExplicit | undefined => {
|
||||
if (!toolChoice) return undefined;
|
||||
|
||||
if (toolChoice === "none" || toolChoice === "auto") {
|
||||
return toolChoice;
|
||||
}
|
||||
|
||||
if (toolChoice === "required") {
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new Error(
|
||||
"tool_choice 'required' was provided but no tools were configured"
|
||||
);
|
||||
}
|
||||
|
||||
if (tools.length > 1) {
|
||||
throw new Error(
|
||||
"tool_choice 'required' needs a single tool or specify the tool name explicitly"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "function",
|
||||
function: { name: tools[0].function.name },
|
||||
};
|
||||
}
|
||||
|
||||
if ("name" in toolChoice) {
|
||||
return {
|
||||
type: "function",
|
||||
function: { name: toolChoice.name },
|
||||
};
|
||||
}
|
||||
|
||||
return toolChoice;
|
||||
};
|
||||
|
||||
const resolveApiUrl = () =>
|
||||
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
|
||||
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
|
||||
: "https://forge.manus.im/v1/chat/completions";
|
||||
|
||||
const assertApiKey = () => {
|
||||
if (!ENV.forgeApiKey) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeResponseFormat = ({
|
||||
responseFormat,
|
||||
response_format,
|
||||
outputSchema,
|
||||
output_schema,
|
||||
}: {
|
||||
responseFormat?: ResponseFormat;
|
||||
response_format?: ResponseFormat;
|
||||
outputSchema?: OutputSchema;
|
||||
output_schema?: OutputSchema;
|
||||
}):
|
||||
| { type: "json_schema"; json_schema: JsonSchema }
|
||||
| { type: "text" }
|
||||
| { type: "json_object" }
|
||||
| undefined => {
|
||||
const explicitFormat = responseFormat || response_format;
|
||||
if (explicitFormat) {
|
||||
if (
|
||||
explicitFormat.type === "json_schema" &&
|
||||
!explicitFormat.json_schema?.schema
|
||||
) {
|
||||
throw new Error(
|
||||
"responseFormat json_schema requires a defined schema object"
|
||||
);
|
||||
}
|
||||
return explicitFormat;
|
||||
}
|
||||
|
||||
const schema = outputSchema || output_schema;
|
||||
if (!schema) return undefined;
|
||||
|
||||
if (!schema.name || !schema.schema) {
|
||||
throw new Error("outputSchema requires both name and schema");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: schema.name,
|
||||
schema: schema.schema,
|
||||
...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
assertApiKey();
|
||||
|
||||
const {
|
||||
messages,
|
||||
tools,
|
||||
toolChoice,
|
||||
tool_choice,
|
||||
outputSchema,
|
||||
output_schema,
|
||||
responseFormat,
|
||||
response_format,
|
||||
} = params;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: "gemini-2.5-flash",
|
||||
messages: messages.map(normalizeMessage),
|
||||
};
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
payload.tools = tools;
|
||||
}
|
||||
|
||||
const normalizedToolChoice = normalizeToolChoice(
|
||||
toolChoice || tool_choice,
|
||||
tools
|
||||
);
|
||||
if (normalizedToolChoice) {
|
||||
payload.tool_choice = normalizedToolChoice;
|
||||
}
|
||||
|
||||
payload.max_tokens = 32768
|
||||
payload.thinking = {
|
||||
"budget_tokens": 128
|
||||
}
|
||||
|
||||
const normalizedResponseFormat = normalizeResponseFormat({
|
||||
responseFormat,
|
||||
response_format,
|
||||
outputSchema,
|
||||
output_schema,
|
||||
});
|
||||
|
||||
if (normalizedResponseFormat) {
|
||||
payload.response_format = normalizedResponseFormat;
|
||||
}
|
||||
|
||||
const response = await fetch(resolveApiUrl(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as InvokeResult;
|
||||
}
|
||||
102
deployment-package/source_server/_core/localAuth.ts
Normal file
102
deployment-package/source_server/_core/localAuth.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDb } from "../db";
|
||||
import { users } from "../../drizzle/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./cookies";
|
||||
import { sdk } from "./sdk";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Route d'authentification locale avec username/password
|
||||
* POST /api/auth/local/login
|
||||
* Body: { username: string, password: string }
|
||||
*/
|
||||
router.post("/login", async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validation des champs
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Identifiant et mot de passe requis",
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur par username
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Erreur de connexion à la base de données",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Identifiant ou mot de passe incorrect",
|
||||
});
|
||||
}
|
||||
|
||||
const user = result[0];
|
||||
|
||||
// Vérifier que l'utilisateur a un mot de passe défini
|
||||
if (!user.password) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Cet utilisateur n'a pas de mot de passe défini",
|
||||
});
|
||||
}
|
||||
|
||||
// Comparer le mot de passe
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Identifiant ou mot de passe incorrect",
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour la date de dernière connexion
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastSignedIn: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Créer le token de session (compatible avec le système OAuth)
|
||||
const token = await sdk.createSessionToken(user.openId, {
|
||||
name: user.name || "",
|
||||
expiresInMs: 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||
});
|
||||
|
||||
// Définir le cookie de session
|
||||
const cookieOptions = getSessionCookieOptions(req);
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions);
|
||||
|
||||
// Retourner l'utilisateur (sans le mot de passe)
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
return res.json({
|
||||
success: true,
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[LocalAuth] Error during login:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Erreur lors de la connexion",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
319
deployment-package/source_server/_core/map.ts
Normal file
319
deployment-package/source_server/_core/map.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Google Maps API Integration for Manus WebDev Templates
|
||||
*
|
||||
* Main function: makeRequest<T>(endpoint, params) - Makes authenticated requests to Google Maps APIs
|
||||
* All credentials are automatically injected. Array parameters use | as separator.
|
||||
*
|
||||
* See API examples below the type definitions for usage patterns.
|
||||
*/
|
||||
|
||||
import { ENV } from "./env";
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
type MapsConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
function getMapsConfig(): MapsConfig {
|
||||
const baseUrl = ENV.forgeApiUrl;
|
||||
const apiKey = ENV.forgeApiKey;
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error(
|
||||
"Google Maps proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Request Handler
|
||||
// ============================================================================
|
||||
|
||||
interface RequestOptions {
|
||||
method?: "GET" | "POST";
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated requests to Google Maps APIs
|
||||
*
|
||||
* @param endpoint - The API endpoint (e.g., "/maps/api/geocode/json")
|
||||
* @param params - Query parameters for the request
|
||||
* @param options - Additional request options
|
||||
* @returns The API response
|
||||
*/
|
||||
export async function makeRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { baseUrl, apiKey } = getMapsConfig();
|
||||
|
||||
// Construct full URL: baseUrl + /v1/maps/proxy + endpoint
|
||||
const url = new URL(`${baseUrl}/v1/maps/proxy${endpoint}`);
|
||||
|
||||
// Add API key as query parameter (standard Google Maps API authentication)
|
||||
url.searchParams.append("key", apiKey);
|
||||
|
||||
// Add other query parameters
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Google Maps API request failed (${response.status} ${response.statusText}): ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
export type TravelMode = "driving" | "walking" | "bicycling" | "transit";
|
||||
export type MapType = "roadmap" | "satellite" | "terrain" | "hybrid";
|
||||
export type SpeedUnit = "KPH" | "MPH";
|
||||
|
||||
export type LatLng = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
export type DirectionsResult = {
|
||||
routes: Array<{
|
||||
legs: Array<{
|
||||
distance: { text: string; value: number };
|
||||
duration: { text: string; value: number };
|
||||
start_address: string;
|
||||
end_address: string;
|
||||
start_location: LatLng;
|
||||
end_location: LatLng;
|
||||
steps: Array<{
|
||||
distance: { text: string; value: number };
|
||||
duration: { text: string; value: number };
|
||||
html_instructions: string;
|
||||
travel_mode: string;
|
||||
start_location: LatLng;
|
||||
end_location: LatLng;
|
||||
}>;
|
||||
}>;
|
||||
overview_polyline: { points: string };
|
||||
summary: string;
|
||||
warnings: string[];
|
||||
waypoint_order: number[];
|
||||
}>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DistanceMatrixResult = {
|
||||
rows: Array<{
|
||||
elements: Array<{
|
||||
distance: { text: string; value: number };
|
||||
duration: { text: string; value: number };
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
origin_addresses: string[];
|
||||
destination_addresses: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GeocodingResult = {
|
||||
results: Array<{
|
||||
address_components: Array<{
|
||||
long_name: string;
|
||||
short_name: string;
|
||||
types: string[];
|
||||
}>;
|
||||
formatted_address: string;
|
||||
geometry: {
|
||||
location: LatLng;
|
||||
location_type: string;
|
||||
viewport: {
|
||||
northeast: LatLng;
|
||||
southwest: LatLng;
|
||||
};
|
||||
};
|
||||
place_id: string;
|
||||
types: string[];
|
||||
}>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PlacesSearchResult = {
|
||||
results: Array<{
|
||||
place_id: string;
|
||||
name: string;
|
||||
formatted_address: string;
|
||||
geometry: {
|
||||
location: LatLng;
|
||||
};
|
||||
rating?: number;
|
||||
user_ratings_total?: number;
|
||||
business_status?: string;
|
||||
types: string[];
|
||||
}>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PlaceDetailsResult = {
|
||||
result: {
|
||||
place_id: string;
|
||||
name: string;
|
||||
formatted_address: string;
|
||||
formatted_phone_number?: string;
|
||||
international_phone_number?: string;
|
||||
website?: string;
|
||||
rating?: number;
|
||||
user_ratings_total?: number;
|
||||
reviews?: Array<{
|
||||
author_name: string;
|
||||
rating: number;
|
||||
text: string;
|
||||
time: number;
|
||||
}>;
|
||||
opening_hours?: {
|
||||
open_now: boolean;
|
||||
weekday_text: string[];
|
||||
};
|
||||
geometry: {
|
||||
location: LatLng;
|
||||
};
|
||||
};
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ElevationResult = {
|
||||
results: Array<{
|
||||
elevation: number;
|
||||
location: LatLng;
|
||||
resolution: number;
|
||||
}>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type TimeZoneResult = {
|
||||
dstOffset: number;
|
||||
rawOffset: number;
|
||||
status: string;
|
||||
timeZoneId: string;
|
||||
timeZoneName: string;
|
||||
};
|
||||
|
||||
export type RoadsResult = {
|
||||
snappedPoints: Array<{
|
||||
location: LatLng;
|
||||
originalIndex?: number;
|
||||
placeId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Google Maps API Reference
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GEOCODING - Convert between addresses and coordinates
|
||||
* Endpoint: /maps/api/geocode/json
|
||||
* Input: { address: string } OR { latlng: string } // latlng: "37.42,-122.08"
|
||||
* Output: GeocodingResult // results[0].geometry.location, results[0].formatted_address
|
||||
*/
|
||||
|
||||
/**
|
||||
* DIRECTIONS - Get navigation routes between locations
|
||||
* Endpoint: /maps/api/directions/json
|
||||
* Input: { origin: string, destination: string, mode?: TravelMode, waypoints?: string, alternatives?: boolean }
|
||||
* Output: DirectionsResult // routes[0].legs[0].distance, duration, steps
|
||||
*/
|
||||
|
||||
/**
|
||||
* DISTANCE MATRIX - Calculate travel times/distances for multiple origin-destination pairs
|
||||
* Endpoint: /maps/api/distancematrix/json
|
||||
* Input: { origins: string, destinations: string, mode?: TravelMode, units?: "metric"|"imperial" } // origins: "NYC|Boston"
|
||||
* Output: DistanceMatrixResult // rows[0].elements[1] = first origin to second destination
|
||||
*/
|
||||
|
||||
/**
|
||||
* PLACE SEARCH - Find businesses/POIs by text query
|
||||
* Endpoint: /maps/api/place/textsearch/json
|
||||
* Input: { query: string, location?: string, radius?: number, type?: string } // location: "40.7,-74.0"
|
||||
* Output: PlacesSearchResult // results[].name, rating, geometry.location, place_id
|
||||
*/
|
||||
|
||||
/**
|
||||
* NEARBY SEARCH - Find places near a specific location
|
||||
* Endpoint: /maps/api/place/nearbysearch/json
|
||||
* Input: { location: string, radius: number, type?: string, keyword?: string } // location: "40.7,-74.0"
|
||||
* Output: PlacesSearchResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* PLACE DETAILS - Get comprehensive information about a specific place
|
||||
* Endpoint: /maps/api/place/details/json
|
||||
* Input: { place_id: string, fields?: string } // fields: "name,rating,opening_hours,website"
|
||||
* Output: PlaceDetailsResult // result.name, rating, opening_hours, etc.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ELEVATION - Get altitude data for geographic points
|
||||
* Endpoint: /maps/api/elevation/json
|
||||
* Input: { locations?: string, path?: string, samples?: number } // locations: "39.73,-104.98|36.45,-116.86"
|
||||
* Output: ElevationResult // results[].elevation (meters)
|
||||
*/
|
||||
|
||||
/**
|
||||
* TIME ZONE - Get timezone information for a location
|
||||
* Endpoint: /maps/api/timezone/json
|
||||
* Input: { location: string, timestamp: number } // timestamp: Math.floor(Date.now()/1000)
|
||||
* Output: TimeZoneResult // timeZoneId, timeZoneName
|
||||
*/
|
||||
|
||||
/**
|
||||
* ROADS - Snap GPS traces to roads, find nearest roads, get speed limits
|
||||
* - /v1/snapToRoads: Input: { path: string, interpolate?: boolean } // path: "lat,lng|lat,lng"
|
||||
* - /v1/nearestRoads: Input: { points: string } // points: "lat,lng|lat,lng"
|
||||
* - /v1/speedLimits: Input: { path: string, units?: SpeedUnit }
|
||||
* Output: RoadsResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* PLACE AUTOCOMPLETE - Real-time place suggestions as user types
|
||||
* Endpoint: /maps/api/place/autocomplete/json
|
||||
* Input: { input: string, location?: string, radius?: number }
|
||||
* Output: { predictions: Array<{ description: string, place_id: string }> }
|
||||
*/
|
||||
|
||||
/**
|
||||
* STATIC MAPS - Generate map images as URLs (for emails, reports, <img> tags)
|
||||
* Endpoint: /maps/api/staticmap
|
||||
* Input: URL params - center: string, zoom: number, size: string, markers?: string, maptype?: MapType
|
||||
* Output: Image URL (not JSON) - use directly in <img src={url} />
|
||||
* Note: Construct URL manually with getMapsConfig() for auth
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
114
deployment-package/source_server/_core/notification.ts
Normal file
114
deployment-package/source_server/_core/notification.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TITLE_MAX_LENGTH = 1200;
|
||||
const CONTENT_MAX_LENGTH = 20000;
|
||||
|
||||
const trimValue = (value: string): string => value.trim();
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string): string => {
|
||||
const normalizedBase = baseUrl.endsWith("/")
|
||||
? baseUrl
|
||||
: `${baseUrl}/`;
|
||||
return new URL(
|
||||
"webdevtoken.v1.WebDevService/SendNotification",
|
||||
normalizedBase
|
||||
).toString();
|
||||
};
|
||||
|
||||
const validatePayload = (input: NotificationPayload): NotificationPayload => {
|
||||
if (!isNonEmptyString(input.title)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Notification title is required.",
|
||||
});
|
||||
}
|
||||
if (!isNonEmptyString(input.content)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Notification content is required.",
|
||||
});
|
||||
}
|
||||
|
||||
const title = trimValue(input.title);
|
||||
const content = trimValue(input.content);
|
||||
|
||||
if (title.length > TITLE_MAX_LENGTH) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (content.length > CONTENT_MAX_LENGTH) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { title, content };
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a project-owner notification through the Manus Notification Service.
|
||||
* Returns `true` if the request was accepted, `false` when the upstream service
|
||||
* cannot be reached (callers can fall back to email/slack). Validation errors
|
||||
* bubble up as TRPC errors so callers can fix the payload.
|
||||
*/
|
||||
export async function notifyOwner(
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const { title, content } = validatePayload(payload);
|
||||
|
||||
if (!ENV.forgeApiUrl) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Notification service URL is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ENV.forgeApiKey) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Notification service API key is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
"content-type": "application/json",
|
||||
"connect-protocol-version": "1",
|
||||
},
|
||||
body: JSON.stringify({ title, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
console.warn(
|
||||
`[Notification] Failed to notify owner (${response.status} ${response.statusText})${
|
||||
detail ? `: ${detail}` : ""
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Notification] Error calling notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
53
deployment-package/source_server/_core/oauth.ts
Normal file
53
deployment-package/source_server/_core/oauth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import * as db from "../db";
|
||||
import { getSessionCookieOptions } from "./cookies";
|
||||
import { sdk } from "./sdk";
|
||||
|
||||
function getQueryParam(req: Request, key: string): string | undefined {
|
||||
const value = req.query[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function registerOAuthRoutes(app: Express) {
|
||||
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
|
||||
const code = getQueryParam(req, "code");
|
||||
const state = getQueryParam(req, "state");
|
||||
|
||||
if (!code || !state) {
|
||||
res.status(400).json({ error: "code and state are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
|
||||
if (!userInfo.openId) {
|
||||
res.status(400).json({ error: "openId missing from user info" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: new Date(),
|
||||
});
|
||||
|
||||
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
|
||||
name: userInfo.name || "",
|
||||
expiresInMs: ONE_YEAR_MS,
|
||||
});
|
||||
|
||||
const cookieOptions = getSessionCookieOptions(req);
|
||||
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
||||
|
||||
res.redirect(302, "/");
|
||||
} catch (error) {
|
||||
console.error("[OAuth] Callback failed", error);
|
||||
res.status(500).json({ error: "OAuth callback failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
330
deployment-package/source_server/_core/sdk.ts
Normal file
330
deployment-package/source_server/_core/sdk.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import { ForbiddenError } from "@shared/_core/errors";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
import type {
|
||||
ExchangeTokenRequest,
|
||||
ExchangeTokenResponse,
|
||||
GetUserInfoResponse,
|
||||
GetUserInfoWithJwtRequest,
|
||||
GetUserInfoWithJwtResponse,
|
||||
} from "./types/manusTypes";
|
||||
// Utility function
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
export type SessionPayload = {
|
||||
openId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
|
||||
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
|
||||
|
||||
class OAuthService {
|
||||
constructor(private client: ReturnType<typeof axios.create>) {
|
||||
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
|
||||
if (!ENV.oAuthServerUrl) {
|
||||
console.error(
|
||||
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeState(state: string): string {
|
||||
const redirectUri = atob(state);
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
async getTokenByCode(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
const payload: ExchangeTokenRequest = {
|
||||
clientId: ENV.appId,
|
||||
grantType: "authorization_code",
|
||||
code,
|
||||
redirectUri: this.decodeState(state),
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<ExchangeTokenResponse>(
|
||||
EXCHANGE_TOKEN_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUserInfoByToken(
|
||||
token: ExchangeTokenResponse
|
||||
): Promise<GetUserInfoResponse> {
|
||||
const { data } = await this.client.post<GetUserInfoResponse>(
|
||||
GET_USER_INFO_PATH,
|
||||
{
|
||||
accessToken: token.accessToken,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const createOAuthHttpClient = (): AxiosInstance =>
|
||||
axios.create({
|
||||
baseURL: ENV.oAuthServerUrl,
|
||||
timeout: AXIOS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
class SDKServer {
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly oauthService: OAuthService;
|
||||
|
||||
constructor(client: AxiosInstance = createOAuthHttpClient()) {
|
||||
this.client = client;
|
||||
this.oauthService = new OAuthService(this.client);
|
||||
}
|
||||
|
||||
private deriveLoginMethod(
|
||||
platforms: unknown,
|
||||
fallback: string | null | undefined
|
||||
): string | null {
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
if (!Array.isArray(platforms) || platforms.length === 0) return null;
|
||||
const set = new Set<string>(
|
||||
platforms.filter((p): p is string => typeof p === "string")
|
||||
);
|
||||
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
|
||||
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
|
||||
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
|
||||
if (
|
||||
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
|
||||
set.has("REGISTERED_PLATFORM_AZURE")
|
||||
)
|
||||
return "microsoft";
|
||||
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
|
||||
const first = Array.from(set)[0];
|
||||
return first ? first.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for access token
|
||||
* @example
|
||||
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
*/
|
||||
async exchangeCodeForToken(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
return this.oauthService.getTokenByCode(code, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information using access token
|
||||
* @example
|
||||
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
|
||||
const data = await this.oauthService.getUserInfoByToken({
|
||||
accessToken,
|
||||
} as ExchangeTokenResponse);
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoResponse;
|
||||
}
|
||||
|
||||
private parseCookies(cookieHeader: string | undefined) {
|
||||
if (!cookieHeader) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
const parsed = parseCookieHeader(cookieHeader);
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
|
||||
private getSessionSecret() {
|
||||
const secret = ENV.cookieSecret;
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session token for a Manus user openId
|
||||
* @example
|
||||
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
|
||||
*/
|
||||
async createSessionToken(
|
||||
openId: string,
|
||||
options: { expiresInMs?: number; name?: string } = {}
|
||||
): Promise<string> {
|
||||
return this.signSession(
|
||||
{
|
||||
openId,
|
||||
appId: ENV.appId,
|
||||
name: options.name || "",
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async signSession(
|
||||
payload: SessionPayload,
|
||||
options: { expiresInMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const issuedAt = Date.now();
|
||||
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
||||
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
||||
const secretKey = this.getSessionSecret();
|
||||
|
||||
return new SignJWT({
|
||||
openId: payload.openId,
|
||||
appId: payload.appId,
|
||||
name: payload.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
.sign(secretKey);
|
||||
}
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
||||
if (!cookieValue) {
|
||||
console.warn("[Auth] Missing session cookie");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const secretKey = this.getSessionSecret();
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(openId) ||
|
||||
!isNonEmptyString(appId) ||
|
||||
!isNonEmptyString(name)
|
||||
) {
|
||||
console.warn("[Auth] Session payload missing required fields");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Session verification failed", String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInfoWithJwt(
|
||||
jwtToken: string
|
||||
): Promise<GetUserInfoWithJwtResponse> {
|
||||
const payload: GetUserInfoWithJwtRequest = {
|
||||
jwtToken,
|
||||
projectId: ENV.appId,
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
|
||||
GET_USER_INFO_WITH_JWT_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoWithJwtResponse;
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<User> {
|
||||
// Regular authentication flow
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
|
||||
// Try to verify as local JWT first
|
||||
let session: { openId: string; appId?: string; name: string } | null = null;
|
||||
let isLocalAuth = false;
|
||||
|
||||
if (sessionCookie) {
|
||||
try {
|
||||
const secretKey = this.getSessionSecret();
|
||||
const { payload } = await jwtVerify(sessionCookie, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, name, appId } = payload as Record<string, unknown>;
|
||||
|
||||
// Check if it's a local JWT (has openId but may not have appId)
|
||||
if (isNonEmptyString(openId) && isNonEmptyString(name)) {
|
||||
session = { openId, name, appId: appId as string | undefined };
|
||||
isLocalAuth = !appId; // Local auth doesn't have appId
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Auth] JWT verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// If local JWT verification failed, try OAuth session
|
||||
if (!session) {
|
||||
session = await this.verifySession(sessionCookie);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw ForbiddenError("Invalid session cookie");
|
||||
}
|
||||
|
||||
const sessionUserId = session.openId;
|
||||
const signedInAt = new Date();
|
||||
let user = await db.getUserByOpenId(sessionUserId);
|
||||
|
||||
// If user not in DB, sync from OAuth server automatically (only for OAuth sessions)
|
||||
if (!user && !isLocalAuth) {
|
||||
try {
|
||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
user = await db.getUserByOpenId(userInfo.openId);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to sync user from OAuth:", error);
|
||||
throw ForbiddenError("Failed to sync user info");
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw ForbiddenError("User not found");
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: user.openId,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export const sdk = new SDKServer();
|
||||
103
deployment-package/source_server/_core/smtpSender.ts
Normal file
103
deployment-package/source_server/_core/smtpSender.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Service d'envoi d'emails via SMTP avec nodemailer
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
interface EmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SMTPConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: 'none' | 'tls' | 'ssl';
|
||||
user: string;
|
||||
password: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un transporteur nodemailer à partir de la configuration SMTP
|
||||
*/
|
||||
function createTransporter(config: SMTPConfig): Transporter {
|
||||
const secure = config.secure === 'ssl'; // true pour SSL (port 465), false pour TLS/STARTTLS
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: secure,
|
||||
auth: {
|
||||
user: config.user,
|
||||
pass: config.password,
|
||||
},
|
||||
// Options supplémentaires pour améliorer la compatibilité
|
||||
tls: {
|
||||
// Ne pas échouer sur les certificats invalides (à utiliser avec précaution en production)
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email via SMTP
|
||||
*/
|
||||
export async function sendViaSMTP(params: EmailParams, config: SMTPConfig): Promise<boolean> {
|
||||
try {
|
||||
const transporter = createTransporter(config);
|
||||
|
||||
// Préparer les pièces jointes
|
||||
const attachments = params.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
content: att.content,
|
||||
contentType: att.contentType,
|
||||
}));
|
||||
|
||||
// Envoyer l'email
|
||||
const info = await transporter.sendMail({
|
||||
from: `${config.fromName} <${config.fromEmail}>`,
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
attachments: attachments,
|
||||
});
|
||||
|
||||
console.log('[Email] Email envoyé via SMTP:', info.messageId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Email] Erreur lors de l\'envoi via SMTP:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion SMTP
|
||||
*/
|
||||
export async function testSMTPConnection(config: SMTPConfig): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const transporter = createTransporter(config);
|
||||
|
||||
// Vérifier la connexion
|
||||
await transporter.verify();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Connexion SMTP réussie',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Email] Erreur de connexion SMTP:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erreur de connexion SMTP',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
deployment-package/source_server/_core/systemRouter.ts
Normal file
29
deployment-package/source_server/_core/systemRouter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { notifyOwner } from "./notification";
|
||||
import { adminProcedure, publicProcedure, router } from "./trpc";
|
||||
|
||||
export const systemRouter = router({
|
||||
health: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
timestamp: z.number().min(0, "timestamp cannot be negative"),
|
||||
})
|
||||
)
|
||||
.query(() => ({
|
||||
ok: true,
|
||||
})),
|
||||
|
||||
notifyOwner: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1, "title is required"),
|
||||
content: z.string().min(1, "content is required"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const delivered = await notifyOwner(input);
|
||||
return {
|
||||
success: delivered,
|
||||
} as const;
|
||||
}),
|
||||
});
|
||||
45
deployment-package/source_server/_core/trpc.ts
Normal file
45
deployment-package/source_server/_core/trpc.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { TrpcContext } from "./context";
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const requireUser = t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(requireUser);
|
||||
|
||||
export const adminProcedure = t.procedure.use(
|
||||
t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
6
deployment-package/source_server/_core/types/cookie.d.ts
vendored
Normal file
6
deployment-package/source_server/_core/types/cookie.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "cookie" {
|
||||
export function parse(
|
||||
str: string,
|
||||
options?: Record<string, unknown>
|
||||
): Record<string, string>;
|
||||
}
|
||||
69
deployment-package/source_server/_core/types/manusTypes.ts
Normal file
69
deployment-package/source_server/_core/types/manusTypes.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// WebDev Auth TypeScript types
|
||||
// Auto-generated from protobuf definitions
|
||||
// Generated on: 2025-09-24T05:57:57.338Z
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
redirectUri: string;
|
||||
projectId: string;
|
||||
state: string;
|
||||
responseType: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeResponse {
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
export interface ExchangeTokenRequest {
|
||||
grantType: string;
|
||||
code: string;
|
||||
refreshToken?: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface ExchangeTokenResponse {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
refreshToken?: string;
|
||||
scope: string;
|
||||
idToken: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoRequest {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoResponse {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
platform?: string | null;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
|
||||
export interface CanAccessRequest {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface CanAccessResponse {
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
export interface GetUserInfoWithJwtRequest {
|
||||
jwtToken: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoWithJwtResponse {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
platform?: string | null;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
67
deployment-package/source_server/_core/uploadFile.ts
Normal file
67
deployment-package/source_server/_core/uploadFile.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { storagePut } from "../storage";
|
||||
import { randomBytes } from "crypto";
|
||||
import path from "path";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Configuration de multer pour gérer les uploads en mémoire
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Route d'upload de fichiers vers S3
|
||||
* POST /api/upload-file
|
||||
* Body: multipart/form-data avec un champ "file"
|
||||
*/
|
||||
router.post("/", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Aucun fichier fourni",
|
||||
});
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
const randomSuffix = randomBytes(8).toString("hex");
|
||||
const ext = path.extname(file.originalname);
|
||||
const baseName = path.basename(file.originalname, ext);
|
||||
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const fileName = `rappels-attachments/${Date.now()}-${randomSuffix}-${safeBaseName}${ext}`;
|
||||
|
||||
// Upload vers S3
|
||||
const result = await storagePut(
|
||||
fileName,
|
||||
file.buffer,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
console.log(`[UploadFile] Fichier uploadé: ${file.originalname} (${file.size} octets)`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
url: result.url,
|
||||
key: fileName,
|
||||
filename: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
message: "Fichier uploadé avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UploadFile] Error:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Erreur lors de l'upload du fichier",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
deployment-package/source_server/_core/uploadImage.ts
Normal file
67
deployment-package/source_server/_core/uploadImage.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { storagePut } from "../storage";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Configuration de multer pour gérer les uploads en mémoire
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB max
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Vérifier que c'est bien une image
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Le fichier doit être une image"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Route d'upload d'images vers S3
|
||||
* POST /api/upload-image
|
||||
* Body: multipart/form-data avec un champ "file"
|
||||
*/
|
||||
router.post("/", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Aucun fichier fourni",
|
||||
});
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
const randomSuffix = randomBytes(8).toString("hex");
|
||||
const extension = file.originalname.split(".").pop() || "jpg";
|
||||
const fileName = `attestations/${Date.now()}-${randomSuffix}.${extension}`;
|
||||
|
||||
// Upload vers S3
|
||||
const result = await storagePut(
|
||||
fileName,
|
||||
file.buffer,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
url: result.url,
|
||||
s3Key: fileName,
|
||||
message: "Image uploadée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UploadImage] Error:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Erreur lors de l'upload de l'image",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
deployment-package/source_server/_core/vite.ts
Normal file
67
deployment-package/source_server/_core/vite.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import express, { type Express } from "express";
|
||||
import fs from "fs";
|
||||
import { type Server } from "http";
|
||||
import { nanoid } from "nanoid";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import viteConfig from "../../vite.config";
|
||||
|
||||
export async function setupVite(app: Express, server: Server) {
|
||||
const serverOptions = {
|
||||
middlewareMode: true,
|
||||
hmr: { server },
|
||||
allowedHosts: true as const,
|
||||
};
|
||||
|
||||
const vite = await createViteServer({
|
||||
...viteConfig,
|
||||
configFile: false,
|
||||
server: serverOptions,
|
||||
appType: "custom",
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
app.use("*", async (req, res, next) => {
|
||||
const url = req.originalUrl;
|
||||
|
||||
try {
|
||||
const clientTemplate = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../..",
|
||||
"client",
|
||||
"index.html"
|
||||
);
|
||||
|
||||
// always reload the index.html file from disk incase it changes
|
||||
let template = await fs.promises.readFile(clientTemplate, "utf-8");
|
||||
template = template.replace(
|
||||
`src="/src/main.tsx"`,
|
||||
`src="/src/main.tsx?v=${nanoid()}"`
|
||||
);
|
||||
const page = await vite.transformIndexHtml(url, template);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
||||
} catch (e) {
|
||||
vite.ssrFixStacktrace(e as Error);
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath =
|
||||
process.env.NODE_ENV === "development"
|
||||
? path.resolve(import.meta.dirname, "../..", "dist", "public")
|
||||
: path.resolve(import.meta.dirname, "public");
|
||||
if (!fs.existsSync(distPath)) {
|
||||
console.error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// fall through to index.html if the file doesn't exist
|
||||
app.use("*", (_req, res) => {
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
284
deployment-package/source_server/_core/voiceTranscription.ts
Normal file
284
deployment-package/source_server/_core/voiceTranscription.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Voice transcription helper using internal Speech-to-Text service
|
||||
*
|
||||
* Frontend implementation guide:
|
||||
* 1. Capture audio using MediaRecorder API
|
||||
* 2. Upload audio to storage (e.g., S3) to get URL
|
||||
* 3. Call transcription with the URL
|
||||
*
|
||||
* Example usage:
|
||||
* ```tsx
|
||||
* // Frontend component
|
||||
* const transcribeMutation = trpc.voice.transcribe.useMutation({
|
||||
* onSuccess: (data) => {
|
||||
* console.log(data.text); // Full transcription
|
||||
* console.log(data.language); // Detected language
|
||||
* console.log(data.segments); // Timestamped segments
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // After uploading audio to storage
|
||||
* transcribeMutation.mutate({
|
||||
* audioUrl: uploadedAudioUrl,
|
||||
* language: 'en', // optional
|
||||
* prompt: 'Transcribe the meeting' // optional
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
import { ENV } from "./env";
|
||||
|
||||
export type TranscribeOptions = {
|
||||
audioUrl: string; // URL to the audio file (e.g., S3 URL)
|
||||
language?: string; // Optional: specify language code (e.g., "en", "es", "zh")
|
||||
prompt?: string; // Optional: custom prompt for the transcription
|
||||
};
|
||||
|
||||
// Native Whisper API segment format
|
||||
export type WhisperSegment = {
|
||||
id: number;
|
||||
seek: number;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
tokens: number[];
|
||||
temperature: number;
|
||||
avg_logprob: number;
|
||||
compression_ratio: number;
|
||||
no_speech_prob: number;
|
||||
};
|
||||
|
||||
// Native Whisper API response format
|
||||
export type WhisperResponse = {
|
||||
task: "transcribe";
|
||||
language: string;
|
||||
duration: number;
|
||||
text: string;
|
||||
segments: WhisperSegment[];
|
||||
};
|
||||
|
||||
export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly
|
||||
|
||||
export type TranscriptionError = {
|
||||
error: string;
|
||||
code: "FILE_TOO_LARGE" | "INVALID_FORMAT" | "TRANSCRIPTION_FAILED" | "UPLOAD_FAILED" | "SERVICE_ERROR";
|
||||
details?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transcribe audio to text using the internal Speech-to-Text service
|
||||
*
|
||||
* @param options - Audio data and metadata
|
||||
* @returns Transcription result or error
|
||||
*/
|
||||
export async function transcribeAudio(
|
||||
options: TranscribeOptions
|
||||
): Promise<TranscriptionResponse | TranscriptionError> {
|
||||
try {
|
||||
// Step 1: Validate environment configuration
|
||||
if (!ENV.forgeApiUrl) {
|
||||
return {
|
||||
error: "Voice transcription service is not configured",
|
||||
code: "SERVICE_ERROR",
|
||||
details: "BUILT_IN_FORGE_API_URL is not set"
|
||||
};
|
||||
}
|
||||
if (!ENV.forgeApiKey) {
|
||||
return {
|
||||
error: "Voice transcription service authentication is missing",
|
||||
code: "SERVICE_ERROR",
|
||||
details: "BUILT_IN_FORGE_API_KEY is not set"
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Download audio from URL
|
||||
let audioBuffer: Buffer;
|
||||
let mimeType: string;
|
||||
try {
|
||||
const response = await fetch(options.audioUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: "Failed to download audio file",
|
||||
code: "INVALID_FORMAT",
|
||||
details: `HTTP ${response.status}: ${response.statusText}`
|
||||
};
|
||||
}
|
||||
|
||||
audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||
mimeType = response.headers.get('content-type') || 'audio/mpeg';
|
||||
|
||||
// Check file size (16MB limit)
|
||||
const sizeMB = audioBuffer.length / (1024 * 1024);
|
||||
if (sizeMB > 16) {
|
||||
return {
|
||||
error: "Audio file exceeds maximum size limit",
|
||||
code: "FILE_TOO_LARGE",
|
||||
details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: "Failed to fetch audio file",
|
||||
code: "SERVICE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error"
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Create FormData for multipart upload to Whisper API
|
||||
const formData = new FormData();
|
||||
|
||||
// Create a Blob from the buffer and append to form
|
||||
const filename = `audio.${getFileExtension(mimeType)}`;
|
||||
const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType });
|
||||
formData.append("file", audioBlob, filename);
|
||||
|
||||
formData.append("model", "whisper-1");
|
||||
formData.append("response_format", "verbose_json");
|
||||
|
||||
// Add prompt - use custom prompt if provided, otherwise generate based on language
|
||||
const prompt = options.prompt || (
|
||||
options.language
|
||||
? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}`
|
||||
: "Transcribe the user's voice to text"
|
||||
);
|
||||
formData.append("prompt", prompt);
|
||||
|
||||
// Step 4: Call the transcription service
|
||||
const baseUrl = ENV.forgeApiUrl.endsWith("/")
|
||||
? ENV.forgeApiUrl
|
||||
: `${ENV.forgeApiUrl}/`;
|
||||
|
||||
const fullUrl = new URL(
|
||||
"v1/audio/transcriptions",
|
||||
baseUrl
|
||||
).toString();
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
return {
|
||||
error: "Transcription service request failed",
|
||||
code: "TRANSCRIPTION_FAILED",
|
||||
details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}`
|
||||
};
|
||||
}
|
||||
|
||||
// Step 5: Parse and return the transcription result
|
||||
const whisperResponse = await response.json() as WhisperResponse;
|
||||
|
||||
// Validate response structure
|
||||
if (!whisperResponse.text || typeof whisperResponse.text !== 'string') {
|
||||
return {
|
||||
error: "Invalid transcription response",
|
||||
code: "SERVICE_ERROR",
|
||||
details: "Transcription service returned an invalid response format"
|
||||
};
|
||||
}
|
||||
|
||||
return whisperResponse; // Return native Whisper API response directly
|
||||
|
||||
} catch (error) {
|
||||
// Handle unexpected errors
|
||||
return {
|
||||
error: "Voice transcription failed",
|
||||
code: "SERVICE_ERROR",
|
||||
details: error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get file extension from MIME type
|
||||
*/
|
||||
function getFileExtension(mimeType: string): string {
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'audio/webm': 'webm',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/mp4': 'm4a',
|
||||
};
|
||||
|
||||
return mimeToExt[mimeType] || 'audio';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get full language name from ISO code
|
||||
*/
|
||||
function getLanguageName(langCode: string): string {
|
||||
const langMap: Record<string, string> = {
|
||||
'en': 'English',
|
||||
'es': 'Spanish',
|
||||
'fr': 'French',
|
||||
'de': 'German',
|
||||
'it': 'Italian',
|
||||
'pt': 'Portuguese',
|
||||
'ru': 'Russian',
|
||||
'ja': 'Japanese',
|
||||
'ko': 'Korean',
|
||||
'zh': 'Chinese',
|
||||
'ar': 'Arabic',
|
||||
'hi': 'Hindi',
|
||||
'nl': 'Dutch',
|
||||
'pl': 'Polish',
|
||||
'tr': 'Turkish',
|
||||
'sv': 'Swedish',
|
||||
'da': 'Danish',
|
||||
'no': 'Norwegian',
|
||||
'fi': 'Finnish',
|
||||
};
|
||||
|
||||
return langMap[langCode] || langCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example tRPC procedure implementation:
|
||||
*
|
||||
* ```ts
|
||||
* // In server/routers.ts
|
||||
* import { transcribeAudio } from "./_core/voiceTranscription";
|
||||
*
|
||||
* export const voiceRouter = router({
|
||||
* transcribe: protectedProcedure
|
||||
* .input(z.object({
|
||||
* audioUrl: z.string(),
|
||||
* language: z.string().optional(),
|
||||
* prompt: z.string().optional(),
|
||||
* }))
|
||||
* .mutation(async ({ input, ctx }) => {
|
||||
* const result = await transcribeAudio(input);
|
||||
*
|
||||
* // Check if it's an error
|
||||
* if ('error' in result) {
|
||||
* throw new TRPCError({
|
||||
* code: 'BAD_REQUEST',
|
||||
* message: result.error,
|
||||
* cause: result,
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* // Optionally save transcription to database
|
||||
* await db.insert(transcriptions).values({
|
||||
* userId: ctx.user.id,
|
||||
* text: result.text,
|
||||
* duration: result.duration,
|
||||
* language: result.language,
|
||||
* audioUrl: input.audioUrl,
|
||||
* createdAt: new Date(),
|
||||
* });
|
||||
*
|
||||
* return result;
|
||||
* }),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
147
deployment-package/source_server/analyticsDb.ts
Normal file
147
deployment-package/source_server/analyticsDb.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Requêtes analytiques pour le tableau de bord
|
||||
*/
|
||||
|
||||
import { eq, sql, and, gte, lte, desc } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { inscriptions, sequences, apprenants, datesFormation, formations } from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Récupère les statistiques d'inscriptions par mois
|
||||
* @param startDate - Date de début de la période
|
||||
* @param endDate - Date de fin de la période
|
||||
*/
|
||||
export async function getInscriptionsByMonth(startDate?: Date, endDate?: Date) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const conditions = [];
|
||||
if (startDate) {
|
||||
conditions.push(gte(inscriptions.dateInscription, startDate));
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(lte(inscriptions.dateInscription, endDate));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
mois: sql<string>`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`,
|
||||
total: sql<number>`COUNT(*)`,
|
||||
confirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
|
||||
listeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
|
||||
annulees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.groupBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`)
|
||||
.orderBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le taux de remplissage des séquences par mois
|
||||
*/
|
||||
export async function getTauxRemplissageByMonth() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
mois: sql<string>`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`,
|
||||
capaciteTotale: sql<number>`SUM(${sequences.capaciteMax})`,
|
||||
inscritsConfirmes: sql<number>`SUM(
|
||||
(SELECT COUNT(*)
|
||||
FROM ${inscriptions}
|
||||
WHERE ${inscriptions.sequenceId} = ${sequences.id}
|
||||
AND ${inscriptions.statut} = 'confirmee')
|
||||
)`,
|
||||
tauxRemplissage: sql<number>`ROUND(
|
||||
(SUM(
|
||||
(SELECT COUNT(*)
|
||||
FROM ${inscriptions}
|
||||
WHERE ${inscriptions.sequenceId} = ${sequences.id}
|
||||
AND ${inscriptions.statut} = 'confirmee')
|
||||
) / SUM(${sequences.capaciteMax})) * 100,
|
||||
2
|
||||
)`,
|
||||
})
|
||||
.from(sequences)
|
||||
.groupBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`)
|
||||
.orderBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques de participation par établissement
|
||||
*/
|
||||
export async function getParticipationByEtablissement() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
codeEtablissement: apprenants.codeEtablissement,
|
||||
totalApprenants: sql<number>`COUNT(DISTINCT ${apprenants.id})`,
|
||||
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
|
||||
inscriptionsConfirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
|
||||
inscriptionsListeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
|
||||
inscriptionsAnnulees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(apprenants)
|
||||
.leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId))
|
||||
.groupBy(apprenants.codeEtablissement)
|
||||
.orderBy(desc(sql<number>`COUNT(${inscriptions.id})`));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques globales du tableau de bord
|
||||
*/
|
||||
export async function getGlobalStats() {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const [stats] = await db
|
||||
.select({
|
||||
totalFormations: sql<number>`(SELECT COUNT(*) FROM ${formations} WHERE ${formations.actif} = 1)`,
|
||||
totalSequences: sql<number>`(SELECT COUNT(*) FROM ${sequences})`,
|
||||
sequencesOuvertes: sql<number>`(SELECT COUNT(*) FROM ${sequences} WHERE ${sequences.statut} = 'ouverte')`,
|
||||
totalApprenants: sql<number>`(SELECT COUNT(*) FROM ${apprenants})`,
|
||||
totalInscriptions: sql<number>`(SELECT COUNT(*) FROM ${inscriptions})`,
|
||||
inscriptionsConfirmees: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee')`,
|
||||
inscriptionsListeAttente: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'liste_attente')`,
|
||||
tauxRemplissageMoyen: sql<number>`ROUND(
|
||||
(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee') /
|
||||
(SELECT SUM(${sequences.capaciteMax}) FROM ${sequences}) * 100,
|
||||
2
|
||||
)`,
|
||||
})
|
||||
.from(sql`dual`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques de participation par fonction
|
||||
*/
|
||||
export async function getParticipationByFonction() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
fonction: apprenants.fonction,
|
||||
totalApprenants: sql<number>`COUNT(DISTINCT ${apprenants.id})`,
|
||||
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
|
||||
inscriptionsConfirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(apprenants)
|
||||
.leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId))
|
||||
.groupBy(apprenants.fonction)
|
||||
.orderBy(desc(sql<number>`COUNT(${inscriptions.id})`));
|
||||
|
||||
return result;
|
||||
}
|
||||
145
deployment-package/source_server/analyticsExportService.ts
Normal file
145
deployment-package/source_server/analyticsExportService.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Service d'export des rapports analytiques en PDF et Excel
|
||||
*/
|
||||
|
||||
import ExcelJS from 'exceljs';
|
||||
import { getGlobalStats, getInscriptionsByMonth, getTauxRemplissageByMonth, getParticipationByEtablissement, getParticipationByFonction } from './analyticsDb';
|
||||
|
||||
/**
|
||||
* Génère un rapport Excel des statistiques analytiques
|
||||
*/
|
||||
export async function generateAnalyticsExcel(startDate?: Date, endDate?: Date): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
// Récupérer toutes les données
|
||||
const globalStats = await getGlobalStats();
|
||||
const inscriptionsByMonth = await getInscriptionsByMonth(startDate, endDate);
|
||||
const tauxRemplissage = await getTauxRemplissageByMonth();
|
||||
const participationEtablissement = await getParticipationByEtablissement();
|
||||
const participationFonction = await getParticipationByFonction();
|
||||
|
||||
// Feuille 1: Statistiques globales
|
||||
const statsSheet = workbook.addWorksheet('Statistiques globales');
|
||||
statsSheet.columns = [
|
||||
{ header: 'Indicateur', key: 'indicateur', width: 30 },
|
||||
{ header: 'Valeur', key: 'valeur', width: 15 },
|
||||
];
|
||||
|
||||
if (globalStats) {
|
||||
statsSheet.addRows([
|
||||
{ indicateur: 'Total Formations actives', valeur: globalStats.totalFormations },
|
||||
{ indicateur: 'Total Séquences', valeur: globalStats.totalSequences },
|
||||
{ indicateur: 'Séquences ouvertes', valeur: globalStats.sequencesOuvertes },
|
||||
{ indicateur: 'Total Apprenants', valeur: globalStats.totalApprenants },
|
||||
{ indicateur: 'Total Inscriptions', valeur: globalStats.totalInscriptions },
|
||||
{ indicateur: 'Inscriptions confirmées', valeur: globalStats.inscriptionsConfirmees },
|
||||
{ indicateur: 'Inscriptions en liste d\'attente', valeur: globalStats.inscriptionsListeAttente },
|
||||
{ indicateur: 'Taux de remplissage moyen (%)', valeur: globalStats.tauxRemplissageMoyen },
|
||||
]);
|
||||
}
|
||||
|
||||
// Feuille 2: Inscriptions par mois
|
||||
const inscriptionsSheet = workbook.addWorksheet('Inscriptions par mois');
|
||||
inscriptionsSheet.columns = [
|
||||
{ header: 'Mois', key: 'mois', width: 15 },
|
||||
{ header: 'Total', key: 'total', width: 12 },
|
||||
{ header: 'Confirmées', key: 'confirmees', width: 12 },
|
||||
{ header: 'Liste d\'attente', key: 'listeAttente', width: 15 },
|
||||
{ header: 'Annulées', key: 'annulees', width: 12 },
|
||||
];
|
||||
|
||||
inscriptionsByMonth.forEach(item => {
|
||||
inscriptionsSheet.addRow({
|
||||
mois: item.mois,
|
||||
total: Number(item.total),
|
||||
confirmees: Number(item.confirmees),
|
||||
listeAttente: Number(item.listeAttente),
|
||||
annulees: Number(item.annulees),
|
||||
});
|
||||
});
|
||||
|
||||
// Feuille 3: Taux de remplissage
|
||||
const tauxSheet = workbook.addWorksheet('Taux de remplissage');
|
||||
tauxSheet.columns = [
|
||||
{ header: 'Mois', key: 'mois', width: 15 },
|
||||
{ header: 'Capacité totale', key: 'capaciteTotale', width: 15 },
|
||||
{ header: 'Inscrits confirmés', key: 'inscritsConfirmes', width: 18 },
|
||||
{ header: 'Taux de remplissage (%)', key: 'tauxRemplissage', width: 20 },
|
||||
];
|
||||
|
||||
tauxRemplissage.forEach(item => {
|
||||
tauxSheet.addRow({
|
||||
mois: item.mois,
|
||||
capaciteTotale: Number(item.capaciteTotale),
|
||||
inscritsConfirmes: Number(item.inscritsConfirmes),
|
||||
tauxRemplissage: Number(item.tauxRemplissage),
|
||||
});
|
||||
});
|
||||
|
||||
// Feuille 4: Participation par établissement
|
||||
const etablissementSheet = workbook.addWorksheet('Participation par établissement');
|
||||
etablissementSheet.columns = [
|
||||
{ header: 'Code établissement', key: 'codeEtablissement', width: 20 },
|
||||
{ header: 'Total apprenants', key: 'totalApprenants', width: 15 },
|
||||
{ header: 'Total inscriptions', key: 'totalInscriptions', width: 18 },
|
||||
{ header: 'Confirmées', key: 'confirmees', width: 12 },
|
||||
{ header: 'Liste d\'attente', key: 'listeAttente', width: 15 },
|
||||
{ header: 'Annulées', key: 'annulees', width: 12 },
|
||||
];
|
||||
|
||||
participationEtablissement.forEach(item => {
|
||||
etablissementSheet.addRow({
|
||||
codeEtablissement: item.codeEtablissement,
|
||||
totalApprenants: Number(item.totalApprenants),
|
||||
totalInscriptions: Number(item.totalInscriptions),
|
||||
confirmees: Number(item.inscriptionsConfirmees),
|
||||
listeAttente: Number(item.inscriptionsListeAttente),
|
||||
annulees: Number(item.inscriptionsAnnulees),
|
||||
});
|
||||
});
|
||||
|
||||
// Feuille 5: Participation par fonction
|
||||
const fonctionSheet = workbook.addWorksheet('Participation par fonction');
|
||||
fonctionSheet.columns = [
|
||||
{ header: 'Fonction', key: 'fonction', width: 20 },
|
||||
{ header: 'Total apprenants', key: 'totalApprenants', width: 15 },
|
||||
{ header: 'Total inscriptions', key: 'totalInscriptions', width: 18 },
|
||||
{ header: 'Confirmées', key: 'confirmees', width: 12 },
|
||||
];
|
||||
|
||||
participationFonction.forEach(item => {
|
||||
fonctionSheet.addRow({
|
||||
fonction: item.fonction === 'directeur' ? 'Directeurs' :
|
||||
item.fonction === 'chef_service' ? 'Chefs de service' : 'Autres',
|
||||
totalApprenants: Number(item.totalApprenants),
|
||||
totalInscriptions: Number(item.totalInscriptions),
|
||||
confirmees: Number(item.inscriptionsConfirmees),
|
||||
});
|
||||
});
|
||||
|
||||
// Styliser les en-têtes
|
||||
[statsSheet, inscriptionsSheet, tauxSheet, etablissementSheet, fonctionSheet].forEach(sheet => {
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF3B82F6' },
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
});
|
||||
|
||||
// Générer le buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un rapport PDF des statistiques analytiques
|
||||
* Note: Cette fonction retourne actuellement un placeholder
|
||||
* Une implémentation complète nécessiterait une bibliothèque comme puppeteer ou pdfkit
|
||||
*/
|
||||
export async function generateAnalyticsPDF(startDate?: Date, endDate?: Date): Promise<Buffer> {
|
||||
// Pour l'instant, retourner un message indiquant que la fonctionnalité est en développement
|
||||
// Une implémentation complète nécessiterait de générer un HTML et de le convertir en PDF
|
||||
throw new Error("L'export PDF sera implémenté dans une prochaine version");
|
||||
}
|
||||
430
deployment-package/source_server/attestationService.ts
Normal file
430
deployment-package/source_server/attestationService.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { getDb } from "./db";
|
||||
import { attestations, configAttestation, inscriptions, apprenants, sequences, formations, datesFormation } from "../drizzle/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { storagePut } from "./storage";
|
||||
import PDFDocument from "pdfkit";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Génère une attestation de formation en PDF
|
||||
*/
|
||||
export async function genererAttestationPDF(inscriptionId: number): Promise<{ s3Key: string; pdfUrl: string }> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Base de données non disponible");
|
||||
}
|
||||
|
||||
// Récupérer les données de l'inscription
|
||||
const [inscriptionData] = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.where(eq(inscriptions.id, inscriptionId))
|
||||
.limit(1);
|
||||
|
||||
if (!inscriptionData) {
|
||||
throw new Error("Inscription introuvable");
|
||||
}
|
||||
|
||||
const { inscription, apprenant, sequence, formation } = inscriptionData;
|
||||
|
||||
// Récupérer les dates de la séquence
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, sequence.id));
|
||||
|
||||
// Récupérer la configuration de l'attestation
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configAttestation)
|
||||
.limit(1);
|
||||
|
||||
// Créer le PDF
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
doc.on("end", async () => {
|
||||
try {
|
||||
const pdfBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Générer une clé S3 unique
|
||||
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
||||
const s3Key = `attestations/${apprenant.id}-${sequence.id}-${randomSuffix}.pdf`;
|
||||
|
||||
// Upload vers S3
|
||||
const { url } = await storagePut(s3Key, pdfBuffer, "application/pdf");
|
||||
|
||||
resolve({ s3Key, pdfUrl: url });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
doc.on("error", reject);
|
||||
|
||||
// Construction du PDF
|
||||
(async () => {
|
||||
try {
|
||||
// Logo en haut à droite si disponible
|
||||
if (config?.logoUrl) {
|
||||
try {
|
||||
const logoResponse = await fetch(config.logoUrl);
|
||||
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
|
||||
doc.image(logoBuffer, doc.page.width - 150, 50, { width: 100 });
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement du logo:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Titre
|
||||
doc.fontSize(24).font("Helvetica-Bold").text("ATTESTATION DE FORMATION", { align: "center" });
|
||||
doc.moveDown(2);
|
||||
|
||||
// Texte personnalisable ou texte par défaut
|
||||
const texteAttestation = config?.texteAttestation ||
|
||||
"Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
|
||||
|
||||
// Remplacer les variables
|
||||
const nomComplet = `${apprenant.prenom} ${apprenant.nom}`;
|
||||
const dateDebut = dates.length > 0 ? format(new Date(dates[0].dateDebut), "dd MMMM yyyy", { locale: fr }) : "";
|
||||
const dateFin = dates.length > 0 ? format(new Date(dates[dates.length - 1].dateFin), "dd MMMM yyyy", { locale: fr }) : "";
|
||||
|
||||
const texteRempli = texteAttestation
|
||||
.replace(/\{\{nomComplet\}\}/g, nomComplet)
|
||||
.replace(/\{\{nomFormation\}\}/g, formation.nom)
|
||||
.replace(/\{\{dateDebut\}\}/g, dateDebut)
|
||||
.replace(/\{\{dateFin\}\}/g, dateFin);
|
||||
|
||||
doc.fontSize(12).font("Helvetica").text(texteRempli, { align: "justify" });
|
||||
doc.moveDown(2);
|
||||
|
||||
// Détails de la formation
|
||||
doc.fontSize(10).font("Helvetica-Bold").text("Détails de la formation :", { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
doc.font("Helvetica");
|
||||
doc.text(`Formation : ${formation.nom}`);
|
||||
doc.text(`Séquence : ${sequence.nom}`);
|
||||
doc.text(`Lieu : ${sequence.lieu}`);
|
||||
doc.moveDown(0.5);
|
||||
doc.text("Dates :");
|
||||
dates.forEach((date) => {
|
||||
const dateStr = format(new Date(date.dateDebut), "dd/MM/yyyy", { locale: fr });
|
||||
const heureDebut = format(new Date(date.dateDebut), "HH:mm");
|
||||
const heureFin = format(new Date(date.dateFin), "HH:mm");
|
||||
doc.text(` • ${dateStr} de ${heureDebut} à ${heureFin}`);
|
||||
});
|
||||
|
||||
doc.moveDown(3);
|
||||
|
||||
// Signature
|
||||
doc.fontSize(10);
|
||||
doc.text(`Fait le ${format(new Date(), "dd MMMM yyyy", { locale: fr })}`, { align: "right" });
|
||||
doc.moveDown(3);
|
||||
|
||||
// Signature si disponible
|
||||
if (config?.signatureUrl) {
|
||||
try {
|
||||
const signatureResponse = await fetch(config.signatureUrl);
|
||||
const signatureBuffer = Buffer.from(await signatureResponse.arrayBuffer());
|
||||
const signatureX = doc.page.width - 200;
|
||||
const signatureY = doc.y;
|
||||
doc.image(signatureBuffer, signatureX, signatureY, { width: 150, height: 50 });
|
||||
doc.moveDown(3);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement de la signature:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (config?.nomSignataire) {
|
||||
doc.text(config.nomSignataire, { align: "right" });
|
||||
}
|
||||
if (config?.fonctionSignataire) {
|
||||
doc.text(config.fonctionSignataire, { align: "right" });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
doc.end();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une attestation dans la base de données
|
||||
*/
|
||||
export async function enregistrerAttestation(
|
||||
inscriptionId: number,
|
||||
apprenantId: number,
|
||||
sequenceId: number,
|
||||
s3Key: string,
|
||||
pdfUrl: string
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Base de données non disponible");
|
||||
}
|
||||
|
||||
const [result] = await db.insert(attestations).values({
|
||||
inscriptionId,
|
||||
apprenantId,
|
||||
sequenceId,
|
||||
s3Key,
|
||||
pdfUrl,
|
||||
emailEnvoye: false,
|
||||
});
|
||||
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'attestation d'un apprenant pour une séquence
|
||||
*/
|
||||
export async function getAttestation(apprenantId: number, sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const [attestation] = await db
|
||||
.select()
|
||||
.from(attestations)
|
||||
.where(
|
||||
and(
|
||||
eq(attestations.apprenantId, apprenantId),
|
||||
eq(attestations.sequenceId, sequenceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return attestation || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les attestations d'un apprenant
|
||||
*/
|
||||
export async function getAttestationsApprenant(apprenantId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
attestation: attestations,
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
})
|
||||
.from(attestations)
|
||||
.innerJoin(sequences, eq(attestations.sequenceId, sequences.id))
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.where(eq(attestations.apprenantId, apprenantId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque une attestation comme envoyée par email
|
||||
*/
|
||||
export async function marquerAttestationEnvoyee(attestationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
await db
|
||||
.update(attestations)
|
||||
.set({
|
||||
emailEnvoye: true,
|
||||
dateEnvoiEmail: new Date(),
|
||||
})
|
||||
.where(eq(attestations.id, attestationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère ou crée la configuration des attestations
|
||||
*/
|
||||
export async function getOrCreateConfigAttestation() {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
let [config] = await db.select().from(configAttestation).limit(1);
|
||||
|
||||
if (!config) {
|
||||
// Créer une configuration par défaut
|
||||
await db.insert(configAttestation).values({
|
||||
texteAttestation: "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.",
|
||||
});
|
||||
|
||||
[config] = await db.select().from(configAttestation).limit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration des attestations
|
||||
*/
|
||||
export async function updateConfigAttestation(data: {
|
||||
logoS3Key?: string;
|
||||
logoUrl?: string;
|
||||
signatureS3Key?: string;
|
||||
signatureUrl?: string;
|
||||
nomSignataire?: string;
|
||||
fonctionSignataire?: string;
|
||||
texteAttestation?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
const [existing] = await db.select().from(configAttestation).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(configAttestation).set(data).where(eq(configAttestation.id, existing.id));
|
||||
} else {
|
||||
await db.insert(configAttestation).values(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une prévisualisation du modèle d'attestation avec des données fictives
|
||||
*/
|
||||
export async function genererPreviewAttestation(): Promise<{ pdfUrl: string }> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Base de données non disponible");
|
||||
}
|
||||
|
||||
// Récupérer la configuration de l'attestation
|
||||
const config = await getOrCreateConfigAttestation();
|
||||
if (!config) {
|
||||
throw new Error("Configuration d'attestation introuvable");
|
||||
}
|
||||
|
||||
// Données fictives pour la prévisualisation
|
||||
const donneesFictives = {
|
||||
apprenant: {
|
||||
nom: "Dupont",
|
||||
prenom: "Jean",
|
||||
email: "jean.dupont@example.com",
|
||||
},
|
||||
formation: {
|
||||
nom: "Formation Manager Itinova",
|
||||
nbJours: 5,
|
||||
},
|
||||
sequence: {
|
||||
nom: "Groupe A",
|
||||
lieu: "Paris",
|
||||
},
|
||||
dates: [
|
||||
{ date: new Date("2026-02-10") },
|
||||
{ date: new Date("2026-02-11") },
|
||||
{ date: new Date("2026-02-12") },
|
||||
{ date: new Date("2026-02-13") },
|
||||
{ date: new Date("2026-02-14") },
|
||||
],
|
||||
};
|
||||
|
||||
// Créer le PDF
|
||||
const doc = new PDFDocument({ size: "A4", margin: 50 });
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
doc.on("data", (chunk) => chunks.push(chunk));
|
||||
|
||||
// En-tête avec logo si disponible
|
||||
if (config.logoUrl) {
|
||||
try {
|
||||
const response = await fetch(config.logoUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
doc.image(buffer, 50, 45, { width: 100 });
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement du logo:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Titre
|
||||
doc
|
||||
.fontSize(24)
|
||||
.font("Helvetica-Bold")
|
||||
.text("ATTESTATION DE FORMATION", 0, 150, { align: "center" });
|
||||
|
||||
doc.moveDown(2);
|
||||
|
||||
// Texte de l'attestation avec remplacement des variables
|
||||
let texte = config.texteAttestation || "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
|
||||
|
||||
const dateDebut = format(donneesFictives.dates[0].date, "d MMMM yyyy", { locale: fr });
|
||||
const dateFin = format(donneesFictives.dates[donneesFictives.dates.length - 1].date, "d MMMM yyyy", { locale: fr });
|
||||
|
||||
texte = texte
|
||||
.replace(/\{\{nomComplet\}\}/g, `${donneesFictives.apprenant.prenom} ${donneesFictives.apprenant.nom}`)
|
||||
.replace(/\{\{nomFormation\}\}/g, donneesFictives.formation.nom)
|
||||
.replace(/\{\{dateDebut\}\}/g, dateDebut)
|
||||
.replace(/\{\{dateFin\}\}/g, dateFin)
|
||||
.replace(/\{\{lieu\}\}/g, donneesFictives.sequence.lieu)
|
||||
.replace(/\{\{nbJours\}\}/g, donneesFictives.formation.nbJours.toString());
|
||||
|
||||
doc
|
||||
.fontSize(12)
|
||||
.font("Helvetica")
|
||||
.text(texte, { align: "justify", lineGap: 5 });
|
||||
|
||||
doc.moveDown(2);
|
||||
|
||||
// Dates de formation
|
||||
doc.fontSize(10).font("Helvetica-Bold").text("Dates de formation :");
|
||||
doc.font("Helvetica");
|
||||
donneesFictives.dates.forEach((d) => {
|
||||
doc.text(`• ${format(d.date, "EEEE d MMMM yyyy", { locale: fr })}`, { indent: 20 });
|
||||
});
|
||||
|
||||
doc.moveDown(2);
|
||||
|
||||
// Signature
|
||||
doc.fontSize(10).font("Helvetica").text(`Fait à ${donneesFictives.sequence.lieu}, le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`);
|
||||
|
||||
doc.moveDown(1);
|
||||
|
||||
if (config.signatureUrl) {
|
||||
try {
|
||||
const response = await fetch(config.signatureUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
doc.image(buffer, doc.x, doc.y, { width: 150 });
|
||||
doc.moveDown(3);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement de la signature:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.nomSignataire) {
|
||||
doc.font("Helvetica-Bold").text(config.nomSignataire);
|
||||
}
|
||||
if (config.fonctionSignataire) {
|
||||
doc.font("Helvetica").text(config.fonctionSignataire);
|
||||
}
|
||||
|
||||
// Finaliser le PDF
|
||||
doc.end();
|
||||
|
||||
await new Promise((resolve) => doc.on("end", resolve));
|
||||
|
||||
const pdfBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Uploader sur S3
|
||||
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
||||
const s3Key = `attestations/preview/preview-${randomSuffix}.pdf`;
|
||||
const { url: pdfUrl } = await storagePut(s3Key, pdfBuffer, "application/pdf");
|
||||
|
||||
return { pdfUrl };
|
||||
}
|
||||
93
deployment-package/source_server/attestationsDb.ts
Normal file
93
deployment-package/source_server/attestationsDb.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { historiqueAttestations, sequences, apprenants, formations } from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Enregistrer un envoi d'attestation dans l'historique
|
||||
*/
|
||||
export async function enregistrerEnvoiAttestation(data: {
|
||||
sequenceId: number;
|
||||
apprenantId: number;
|
||||
statut: "envoye" | "erreur";
|
||||
messageErreur?: string;
|
||||
urlAttestation?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.insert(historiqueAttestations).values({
|
||||
sequenceId: data.sequenceId,
|
||||
apprenantId: data.apprenantId,
|
||||
statut: data.statut,
|
||||
messageErreur: data.messageErreur || null,
|
||||
urlAttestation: data.urlAttestation || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer l'historique des envois d'attestations
|
||||
* Avec les informations de la séquence, formation et apprenant
|
||||
*/
|
||||
export async function getHistoriqueAttestations() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: historiqueAttestations.id,
|
||||
dateEnvoi: historiqueAttestations.dateEnvoi,
|
||||
statut: historiqueAttestations.statut,
|
||||
messageErreur: historiqueAttestations.messageErreur,
|
||||
urlAttestation: historiqueAttestations.urlAttestation,
|
||||
sequence: {
|
||||
id: sequences.id,
|
||||
nom: sequences.nom,
|
||||
},
|
||||
formation: {
|
||||
id: formations.id,
|
||||
nom: formations.nom,
|
||||
},
|
||||
apprenant: {
|
||||
id: apprenants.id,
|
||||
nom: apprenants.nom,
|
||||
prenom: apprenants.prenom,
|
||||
email: apprenants.email,
|
||||
},
|
||||
})
|
||||
.from(historiqueAttestations)
|
||||
.innerJoin(sequences, eq(historiqueAttestations.sequenceId, sequences.id))
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
|
||||
.orderBy(desc(historiqueAttestations.dateEnvoi));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer l'historique des attestations pour une séquence spécifique
|
||||
*/
|
||||
export async function getHistoriqueAttestationsBySequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: historiqueAttestations.id,
|
||||
dateEnvoi: historiqueAttestations.dateEnvoi,
|
||||
statut: historiqueAttestations.statut,
|
||||
messageErreur: historiqueAttestations.messageErreur,
|
||||
urlAttestation: historiqueAttestations.urlAttestation,
|
||||
apprenant: {
|
||||
id: apprenants.id,
|
||||
nom: apprenants.nom,
|
||||
prenom: apprenants.prenom,
|
||||
email: apprenants.email,
|
||||
},
|
||||
})
|
||||
.from(historiqueAttestations)
|
||||
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
|
||||
.where(eq(historiqueAttestations.sequenceId, sequenceId))
|
||||
.orderBy(desc(historiqueAttestations.dateEnvoi));
|
||||
|
||||
return results;
|
||||
}
|
||||
60
deployment-package/source_server/dateUtils.ts
Normal file
60
deployment-package/source_server/dateUtils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utilitaires pour la gestion des dates
|
||||
* Gère correctement les conversions entre heure locale et UTC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une chaîne datetime-local (format: "2025-01-15T14:30") en Date
|
||||
* en construisant la date avec une chaîne ISO qui force l'heure locale
|
||||
* Préserve l'heure locale sans conversion UTC
|
||||
*/
|
||||
export function parseLocalDateTime(dateTimeString: string): Date {
|
||||
if (!dateTimeString) {
|
||||
throw new Error('Date string is required');
|
||||
}
|
||||
|
||||
// Le format datetime-local est "YYYY-MM-DDTHH:mm"
|
||||
const match = dateTimeString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid date format: ${dateTimeString}. Expected YYYY-MM-DDTHH:mm`);
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
|
||||
// Créer un objet Date en utilisant le constructeur avec composants
|
||||
// MySQL stocke en UTC, donc on doit compenser le décalage horaire
|
||||
const date = new Date(
|
||||
parseInt(year),
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hours),
|
||||
parseInt(minutes),
|
||||
0
|
||||
);
|
||||
|
||||
// Compenser le décalage UTC en ajoutant le décalage du fuseau horaire
|
||||
// Cela garantit que l'heure stockée en UTC correspond à l'heure locale saisie
|
||||
const offset = date.getTimezoneOffset(); // en minutes (négatif pour Europe/Paris)
|
||||
date.setMinutes(date.getMinutes() - offset);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid date string: ${dateTimeString}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une Date en chaîne datetime-local pour les inputs HTML
|
||||
* Format: "YYYY-MM-DDTHH:mm"
|
||||
*/
|
||||
export function formatToLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
1272
deployment-package/source_server/db.ts
Normal file
1272
deployment-package/source_server/db.ts
Normal file
File diff suppressed because it is too large
Load Diff
177
deployment-package/source_server/emailPreview.ts
Normal file
177
deployment-package/source_server/emailPreview.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Génération d'aperçus d'emails avec données réelles
|
||||
*/
|
||||
|
||||
import * as db from "./db";
|
||||
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
|
||||
|
||||
export interface EmailPreviewParams {
|
||||
sequenceId: number;
|
||||
type: 'teaser' | 'rappel' | 'rappel_j1';
|
||||
apprenantId?: number; // Si non fourni, prendre le premier apprenant inscrit
|
||||
}
|
||||
|
||||
export async function generateEmailPreview(params: EmailPreviewParams): Promise<{
|
||||
html: string;
|
||||
subject: string;
|
||||
recipient: {
|
||||
email: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
};
|
||||
}> {
|
||||
// Récupérer la séquence
|
||||
const sequence = await db.getSequenceById(params.sequenceId);
|
||||
if (!sequence) {
|
||||
throw new Error('Séquence non trouvée');
|
||||
}
|
||||
|
||||
// Récupérer la formation
|
||||
const formation = await db.getFormationById(sequence.formationId);
|
||||
if (!formation) {
|
||||
throw new Error('Formation non trouvée');
|
||||
}
|
||||
|
||||
// Récupérer les dates
|
||||
const dates = await db.getDatesBySequence(sequence.id);
|
||||
if (dates.length === 0) {
|
||||
throw new Error('Aucune date trouvée pour cette séquence');
|
||||
}
|
||||
|
||||
// Récupérer le formateur si disponible
|
||||
let formateurNom: string | undefined;
|
||||
if (sequence.formateurId) {
|
||||
const formateur = await db.getFormateurById(sequence.formateurId);
|
||||
formateurNom = formateur?.nom;
|
||||
}
|
||||
|
||||
// Récupérer un apprenant inscrit
|
||||
const inscriptions = await db.getInscriptionsBySequence(params.sequenceId);
|
||||
const confirmedInscriptions = inscriptions.filter(i => i.inscription.statut === 'confirmee' && i.apprenant);
|
||||
|
||||
if (confirmedInscriptions.length === 0) {
|
||||
throw new Error('Aucun apprenant confirmé trouvé pour cette séquence');
|
||||
}
|
||||
|
||||
// Utiliser l'apprenant spécifié ou le premier de la liste
|
||||
let selectedInscription = confirmedInscriptions[0];
|
||||
if (params.apprenantId) {
|
||||
const found = confirmedInscriptions.find(i => i.apprenant!.id === params.apprenantId);
|
||||
if (found) {
|
||||
selectedInscription = found;
|
||||
}
|
||||
}
|
||||
|
||||
const apprenant = selectedInscription.apprenant!;
|
||||
|
||||
// Préparer les données pour l'email
|
||||
const fonctionLabel = apprenant.fonction === 'directeur' ? 'Directeur' :
|
||||
apprenant.fonction === 'chef_service' ? 'Chef de service' : '';
|
||||
const salutation = fonctionLabel ? `${fonctionLabel} ${apprenant.prenom} ${apprenant.nom}` : apprenant.prenom;
|
||||
|
||||
const datesHTML = dates.map(date => `
|
||||
<div class="date-item">
|
||||
<strong>Date ${date.ordre} :</strong> ${new Date(date.dateDebut).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Générer le contenu selon le type
|
||||
let content: string;
|
||||
let subject: string;
|
||||
|
||||
if (params.type === 'teaser') {
|
||||
content = `
|
||||
<h2>Votre formation approche !</h2>
|
||||
<p>Bonjour ${salutation},</p>
|
||||
|
||||
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${formation.nom}</strong>.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Séquence :</strong> ${sequence.nom}</p>
|
||||
<h4>Dates :</h4>
|
||||
${datesHTML}
|
||||
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
|
||||
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<p>Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
subject = `Votre formation ${formation.nom} approche !`;
|
||||
} else if (params.type === 'rappel') {
|
||||
// Rappel J-7
|
||||
content = `
|
||||
<h2>Rappel : Votre formation commence bientôt !</h2>
|
||||
<p>Bonjour ${salutation},</p>
|
||||
|
||||
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence dans une semaine.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Séquence :</strong> ${sequence.nom}</p>
|
||||
<h4>Dates :</h4>
|
||||
${datesHTML}
|
||||
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
|
||||
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<p>N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
subject = `Rappel J-7 : Formation ${formation.nom}`;
|
||||
} else {
|
||||
// Rappel J-1
|
||||
content = `
|
||||
<h2>Rappel : Votre formation commence demain !</h2>
|
||||
<p>Bonjour ${salutation},</p>
|
||||
|
||||
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence <strong>demain</strong>.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Séquence :</strong> ${sequence.nom}</p>
|
||||
<h4>Dates :</h4>
|
||||
${datesHTML}
|
||||
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
|
||||
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
|
||||
<p>N'oubliez pas d'apporter le matériel nécessaire.</p>
|
||||
|
||||
<p>À demain !</p>
|
||||
`;
|
||||
subject = `Rappel J-1 : Formation ${formation.nom} - C'est demain !`;
|
||||
}
|
||||
|
||||
// Préparer les variables
|
||||
const variables = {
|
||||
nomApprenant: apprenant.nom,
|
||||
prenomApprenant: apprenant.prenom,
|
||||
nomFormation: formation.nom,
|
||||
nomSequence: sequence.nom,
|
||||
dateDebut: new Date(dates[0].dateDebut).toLocaleDateString('fr-FR'),
|
||||
dateFin: new Date(dates[dates.length - 1].dateFin).toLocaleDateString('fr-FR'),
|
||||
datesHTML: datesHTML, // Ajouter le HTML des dates
|
||||
lieu: sequence.lieu || '',
|
||||
formateur: formateurNom || '',
|
||||
};
|
||||
|
||||
// Générer le HTML final avec le template
|
||||
const html = await generateEmailFromTemplate(params.type === 'teaser' ? 'teaser' : 'rappel', content, variables);
|
||||
|
||||
return {
|
||||
html,
|
||||
subject,
|
||||
recipient: {
|
||||
email: apprenant.email,
|
||||
nom: apprenant.nom,
|
||||
prenom: apprenant.prenom,
|
||||
},
|
||||
};
|
||||
}
|
||||
1023
deployment-package/source_server/emailService.ts
Normal file
1023
deployment-package/source_server/emailService.ts
Normal file
File diff suppressed because it is too large
Load Diff
180
deployment-package/source_server/emailTemplateGenerator.ts
Normal file
180
deployment-package/source_server/emailTemplateGenerator.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Générateur de templates d'emails personnalisés
|
||||
* Utilise les templates stockés en base de données
|
||||
*/
|
||||
|
||||
import * as db from "./db";
|
||||
import { EmailTemplate } from "../drizzle/schema";
|
||||
import { replaceEmailVariables, EmailVariables } from "./emailTemplateUtils";
|
||||
|
||||
/**
|
||||
* Génère le HTML d'un email en utilisant le template personnalisé
|
||||
* @param templateType - Type de template (à utiliser)
|
||||
* @param content - Contenu de l'email (legacy, sera remplacé par bodyContent)
|
||||
* @param variables - Variables à remplacer dans le template
|
||||
*/
|
||||
export async function generateEmailFromTemplate(
|
||||
templateType: string,
|
||||
content: string,
|
||||
variables?: EmailVariables
|
||||
): Promise<string> {
|
||||
// Récupérer le template depuis la base de données
|
||||
const template = await db.getEmailTemplateByType(templateType);
|
||||
|
||||
// Si pas de template trouvé, utiliser le template par défaut
|
||||
if (!template) {
|
||||
const defaultContent = variables ? replaceEmailVariables(content, variables) : content;
|
||||
return getDefaultEmailTemplate(defaultContent);
|
||||
}
|
||||
|
||||
return buildEmailHTML(template, content, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le HTML de l'email avec le template
|
||||
*/
|
||||
function buildEmailHTML(template: EmailTemplate, content: string, variables?: EmailVariables): string {
|
||||
// Utiliser bodyContent si disponible, sinon utiliser content (legacy)
|
||||
let emailBody = template.bodyContent || content;
|
||||
|
||||
// Remplacer les variables si fournies
|
||||
if (variables) {
|
||||
emailBody = replaceEmailVariables(emailBody, variables);
|
||||
// Remplacer aussi dans le titre et le pied de page
|
||||
template = {
|
||||
...template,
|
||||
headerTitle: replaceEmailVariables(template.headerTitle, variables),
|
||||
footerText: template.footerText ? replaceEmailVariables(template.footerText, variables) : template.footerText,
|
||||
};
|
||||
}
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
}
|
||||
.header {
|
||||
background-color: ${template.headerBgColor};
|
||||
color: ${template.headerTextColor};
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
${template.logoUrl ? `
|
||||
.header img {
|
||||
max-width: 150px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}` : ''}
|
||||
.content {
|
||||
background-color: #f9fafb;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.content h2 {
|
||||
color: ${template.primaryColor};
|
||||
margin-top: 0;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${template.primaryColor};
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #dbeafe;
|
||||
border-left: 4px solid ${template.primaryColor};
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.date-item {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${template.logoUrl ? `<img src="${template.logoUrl}" alt="Logo" />` : ''}
|
||||
<h1>${template.headerTitle}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
${emailBody}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>${template.footerText || 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template par défaut si aucun template personnalisé n'est trouvé
|
||||
*/
|
||||
function getDefaultEmailTemplate(content: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background-color: #2563eb; color: white; padding: 30px 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { background-color: #f9fafb; padding: 30px 20px; }
|
||||
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
|
||||
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Formation Manager Itinova</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
${content}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
|
||||
<p>Pour toute question, veuillez contacter le service RH.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
66
deployment-package/source_server/emailTemplateUtils.ts
Normal file
66
deployment-package/source_server/emailTemplateUtils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Utilitaires pour le remplacement des variables dans les templates d'emails
|
||||
*/
|
||||
|
||||
export interface EmailVariables {
|
||||
nomApprenant?: string;
|
||||
prenomApprenant?: string;
|
||||
nomFormation?: string;
|
||||
nomSequence?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
datesHTML?: string; // HTML formaté de toutes les dates de la séquence
|
||||
lieu?: string;
|
||||
formateur?: string;
|
||||
lienInscription?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace les variables {{variable}} dans un texte par leurs valeurs
|
||||
* @param template - Le texte contenant les variables à remplacer
|
||||
* @param variables - Un objet contenant les valeurs des variables
|
||||
* @returns Le texte avec les variables remplacées
|
||||
*/
|
||||
export function replaceEmailVariables(
|
||||
template: string,
|
||||
variables: EmailVariables
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
// Remplacer chaque variable trouvée dans le template
|
||||
Object.keys(variables).forEach((key) => {
|
||||
const value = variables[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
// Remplacer toutes les occurrences de {{key}} par la valeur
|
||||
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer les variables non remplacées (optionnel)
|
||||
// result = result.replace(/\{\{[^}]+\}\}/g, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un aperçu d'email avec des données d'exemple
|
||||
* @param template - Le template d'email
|
||||
* @returns Le template avec des données d'exemple
|
||||
*/
|
||||
export function generateEmailPreview(template: string): string {
|
||||
const exampleVariables: EmailVariables = {
|
||||
nomApprenant: 'Dupont',
|
||||
prenomApprenant: 'Marie',
|
||||
nomFormation: 'Formation Management',
|
||||
nomSequence: 'Séquence 1 - Introduction',
|
||||
dateDebut: '15/01/2025',
|
||||
dateFin: '17/01/2025',
|
||||
lieu: 'Salle de formation A - Bâtiment principal',
|
||||
formateur: 'Jean Martin',
|
||||
lienInscription: 'https://exemple.com/inscription/abc123',
|
||||
};
|
||||
|
||||
return replaceEmailVariables(template, exampleVariables);
|
||||
}
|
||||
110
deployment-package/source_server/etablissementsDb.ts
Normal file
110
deployment-package/source_server/etablissementsDb.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { apprenants, inscriptions } from "../drizzle/schema";
|
||||
|
||||
export async function getEtablissementsStats() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
// Récupérer tous les apprenants groupés par établissement
|
||||
const apprenantsData = await db.select().from(apprenants);
|
||||
|
||||
// Grouper par code établissement
|
||||
const etablissementsMap = new Map<string, {
|
||||
codeEtablissement: string;
|
||||
nombreApprenants: number;
|
||||
apprenantsActifs: number;
|
||||
apprenantIds: number[];
|
||||
}>();
|
||||
|
||||
for (const apprenant of apprenantsData) {
|
||||
const code = apprenant.codeEtablissement;
|
||||
|
||||
if (!etablissementsMap.has(code)) {
|
||||
etablissementsMap.set(code, {
|
||||
codeEtablissement: code,
|
||||
nombreApprenants: 0,
|
||||
apprenantsActifs: 0,
|
||||
apprenantIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
const etab = etablissementsMap.get(code)!;
|
||||
etab.nombreApprenants++;
|
||||
etab.apprenantIds.push(apprenant.id);
|
||||
}
|
||||
|
||||
// Pour chaque établissement, compter les apprenants actifs
|
||||
const etablissementsStats = await Promise.all(
|
||||
Array.from(etablissementsMap.values()).map(async (etab) => {
|
||||
// Compter le nombre d'apprenants ayant au moins une inscription
|
||||
const apprenantsActifsCount = await Promise.all(
|
||||
etab.apprenantIds.map(async (apprenantId) => {
|
||||
const inscriptionsData = await db
|
||||
.select()
|
||||
.from(inscriptions)
|
||||
.where(eq(inscriptions.apprenantId, apprenantId))
|
||||
.limit(1);
|
||||
return inscriptionsData.length > 0 ? 1 : 0;
|
||||
})
|
||||
);
|
||||
|
||||
const nombreActifs = apprenantsActifsCount.reduce((sum: number, count) => sum + count, 0);
|
||||
const tauxParticipation = etab.nombreApprenants > 0
|
||||
? Math.round((nombreActifs / etab.nombreApprenants) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
codeEtablissement: etab.codeEtablissement,
|
||||
nombreApprenants: etab.nombreApprenants,
|
||||
apprenantsActifs: nombreActifs,
|
||||
tauxParticipation,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Trier par nombre d'apprenants décroissant
|
||||
return etablissementsStats.sort((a, b) => b.nombreApprenants - a.nombreApprenants);
|
||||
}
|
||||
|
||||
export async function getEtablissementDetail(codeEtablissement: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// Récupérer tous les apprenants de cet établissement
|
||||
const apprenantsData = await db
|
||||
.select()
|
||||
.from(apprenants)
|
||||
.where(eq(apprenants.codeEtablissement, codeEtablissement));
|
||||
|
||||
// Pour chaque apprenant, récupérer ses inscriptions
|
||||
const apprenantsWithInscriptions = await Promise.all(
|
||||
apprenantsData.map(async (apprenant) => {
|
||||
const inscriptionsData = await db
|
||||
.select()
|
||||
.from(inscriptions)
|
||||
.where(eq(inscriptions.apprenantId, apprenant.id));
|
||||
|
||||
return {
|
||||
...apprenant,
|
||||
inscriptions: inscriptionsData,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const nombreApprenants = apprenantsData.length;
|
||||
const apprenantsActifs = apprenantsWithInscriptions.filter(
|
||||
(a) => a.inscriptions.length > 0
|
||||
).length;
|
||||
const tauxParticipation = nombreApprenants > 0
|
||||
? Math.round((apprenantsActifs / nombreApprenants) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
codeEtablissement,
|
||||
nombreApprenants,
|
||||
apprenantsActifs,
|
||||
tauxParticipation,
|
||||
apprenants: apprenantsWithInscriptions,
|
||||
};
|
||||
}
|
||||
277
deployment-package/source_server/exportService.ts
Normal file
277
deployment-package/source_server/exportService.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Service d'export des données en Excel et PDF
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface InscriptionExport {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
statut: string;
|
||||
dateInscription: Date;
|
||||
}
|
||||
|
||||
interface SequenceInfo {
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
publicCible: string;
|
||||
inscriptions: InscriptionExport[];
|
||||
}
|
||||
|
||||
interface ApprenantPresence {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
}
|
||||
|
||||
interface FeuillePresenceInfo {
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
publicCible: string;
|
||||
formateur?: string;
|
||||
apprenants: ApprenantPresence[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier Excel avec la liste des inscrits
|
||||
*/
|
||||
export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
// Préparer les données
|
||||
const data = sequenceInfo.inscriptions.map(i => ({
|
||||
'Nom': i.nom,
|
||||
'Prénom': i.prenom,
|
||||
'Email': i.email,
|
||||
'Code établissement': i.codeEtablissement,
|
||||
'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'),
|
||||
}));
|
||||
|
||||
// Créer le workbook
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
|
||||
|
||||
// Ajouter une feuille d'informations
|
||||
const infoData = [
|
||||
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
|
||||
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
|
||||
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
|
||||
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
|
||||
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
|
||||
];
|
||||
|
||||
// Ajouter les dates
|
||||
sequenceInfo.dates.forEach(date => {
|
||||
infoData.push({
|
||||
'Information': `Date ${date.ordre}`,
|
||||
'Valeur': date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const wsInfo = XLSX.utils.json_to_sheet(infoData);
|
||||
XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations');
|
||||
|
||||
// Générer le buffer
|
||||
return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un PDF avec la liste des inscrits
|
||||
*/
|
||||
export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Liste des inscrits', 14, 20);
|
||||
|
||||
// Informations de séquence
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
|
||||
yPos += 8;
|
||||
|
||||
// Dates de formation
|
||||
doc.setFontSize(9);
|
||||
sequenceInfo.dates.forEach(date => {
|
||||
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
yPos += 5;
|
||||
});
|
||||
|
||||
// Tableau des inscrits
|
||||
const tableData = sequenceInfo.inscriptions.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.email,
|
||||
i.codeEtablissement,
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Fonction', 'Statut']],
|
||||
body: tableData,
|
||||
styles: { fontSize: 9 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une feuille de présence PDF avec signatures matin/après-midi pour chaque journée
|
||||
*/
|
||||
export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Charger le logo
|
||||
let logoData: string | null = null;
|
||||
|
||||
try {
|
||||
// Utiliser un chemin absolu depuis la racine du projet
|
||||
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
|
||||
const logoBuffer = fs.readFileSync(logoPath);
|
||||
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
|
||||
} catch (error) {
|
||||
console.warn('Logo Itinova non trouvé, génération sans logo:', error);
|
||||
}
|
||||
|
||||
// Fonction pour générer une page de feuille de présence pour une journée
|
||||
const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
|
||||
if (!isFirstPage) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
// Ajouter le logo en haut à droite si disponible
|
||||
if (logoData) {
|
||||
doc.addImage(logoData, 'PNG', 160, 10, 40, 15);
|
||||
}
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Feuille de présence', 14, 20);
|
||||
|
||||
// Informations de séquence
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos);
|
||||
yPos += 6;
|
||||
|
||||
// Ajouter le formateur si disponible
|
||||
if (feuilleInfo.formateur) {
|
||||
doc.text(`Formateur : ${feuilleInfo.formateur}`, 14, yPos);
|
||||
yPos += 6;
|
||||
}
|
||||
|
||||
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
|
||||
yPos += 8;
|
||||
|
||||
// Date de la journée
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPos += 10;
|
||||
|
||||
// Tableau de présence avec signatures matin et après-midi
|
||||
const tableData = feuilleInfo.apprenants.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.codeEtablissement,
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'', // Signature matin
|
||||
'', // Signature après-midi
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos,
|
||||
head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']],
|
||||
body: tableData,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
minCellHeight: 10,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [37, 99, 235],
|
||||
halign: 'center',
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 30 }, // Nom
|
||||
1: { cellWidth: 30 }, // Prénom
|
||||
2: { cellWidth: 25 }, // Code établissement
|
||||
3: { cellWidth: 30 }, // Fonction
|
||||
4: { cellWidth: 35 }, // Signature matin
|
||||
5: { cellWidth: 35 }, // Signature après-midi
|
||||
},
|
||||
});
|
||||
|
||||
// Ajouter un champ de signature pour le formateur en bas de page
|
||||
const finalY = (doc as any).lastAutoTable.finalY || yPos + 50;
|
||||
const signatureY = finalY + 20;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Signature du formateur :', 14, signatureY);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// Dessiner une ligne pour la signature
|
||||
doc.line(60, signatureY, 120, signatureY);
|
||||
|
||||
// Ajouter la date
|
||||
doc.setFontSize(9);
|
||||
doc.text('Date : _______________', 140, signatureY);
|
||||
};
|
||||
|
||||
// Générer une page pour chaque date
|
||||
feuilleInfo.dates.forEach((date, index) => {
|
||||
generatePageForDate(date, index === 0);
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
402
deployment-package/source_server/formateurDb.ts
Normal file
402
deployment-package/source_server/formateurDb.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { eq, sql, and, gte, lte, desc } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import {
|
||||
sequences,
|
||||
formations,
|
||||
formateurs,
|
||||
datesFormation,
|
||||
inscriptions,
|
||||
apprenants,
|
||||
supportsFormation
|
||||
} from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Récupère le calendrier des interventions d'un formateur
|
||||
* @param formateurId - ID du formateur
|
||||
* @param dateDebut - Date de début optionnelle
|
||||
* @param dateFin - Date de fin optionnelle
|
||||
*/
|
||||
export async function getCalendrierFormateur(formateurId: number, dateDebut?: Date, dateFin?: Date) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const conditions = [eq(sequences.formateurId, formateurId)];
|
||||
|
||||
if (dateDebut) {
|
||||
conditions.push(gte(datesFormation.dateDebut, dateDebut));
|
||||
}
|
||||
if (dateFin) {
|
||||
conditions.push(lte(datesFormation.dateFin, dateFin));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
sequenceId: sequences.id,
|
||||
sequenceNom: sequences.nom,
|
||||
formationId: formations.id,
|
||||
formationNom: formations.nom,
|
||||
dateId: datesFormation.id,
|
||||
dateDebut: datesFormation.dateDebut,
|
||||
dateFin: datesFormation.dateFin,
|
||||
ordre: datesFormation.ordre,
|
||||
lieu: sequences.lieu,
|
||||
nbInscrits: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM inscriptions
|
||||
WHERE inscriptions.sequenceId = ${sequences.id}
|
||||
AND inscriptions.statut IN ('confirmee', 'liste_attente')
|
||||
)`,
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(datesFormation.dateDebut);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste des apprenants inscrits à une séquence
|
||||
* @param sequenceId - ID de la séquence
|
||||
*/
|
||||
export async function getApprenantsSequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
inscriptionId: inscriptions.id,
|
||||
apprenantId: apprenants.id,
|
||||
nom: apprenants.nom,
|
||||
prenom: apprenants.prenom,
|
||||
email: apprenants.email,
|
||||
fonction: apprenants.fonction,
|
||||
codeEtablissement: apprenants.codeEtablissement,
|
||||
statut: inscriptions.statut,
|
||||
dateInscription: inscriptions.dateInscription,
|
||||
// presenceValidee n'existe pas dans le schéma actuel
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(eq(inscriptions.sequenceId, sequenceId))
|
||||
.orderBy(apprenants.nom, apprenants.prenom);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les supports de formation d'une séquence
|
||||
* @param sequenceId - ID de la séquence
|
||||
*/
|
||||
export async function getSupportsSequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: supportsFormation.id,
|
||||
nomFichier: supportsFormation.nomFichier,
|
||||
typeFichier: supportsFormation.typeFichier,
|
||||
tailleFichier: supportsFormation.tailleFichier,
|
||||
urlFichier: supportsFormation.urlFichier,
|
||||
description: supportsFormation.description,
|
||||
formateurNom: formateurs.nom,
|
||||
createdAt: supportsFormation.createdAt,
|
||||
})
|
||||
.from(supportsFormation)
|
||||
.innerJoin(formateurs, eq(supportsFormation.formateurId, formateurs.id))
|
||||
.where(eq(supportsFormation.sequenceId, sequenceId))
|
||||
.orderBy(desc(supportsFormation.createdAt));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un support de formation
|
||||
* @param support - Données du support à ajouter
|
||||
*/
|
||||
export async function ajouterSupport(support: {
|
||||
sequenceId: number;
|
||||
formateurId: number;
|
||||
nomFichier: string;
|
||||
typeFichier: string;
|
||||
tailleFichier: number;
|
||||
urlFichier: string;
|
||||
s3Key: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const [result] = await db.insert(supportsFormation).values(support);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un support de formation
|
||||
* @param supportId - ID du support à supprimer
|
||||
* @param formateurId - ID du formateur (pour vérification)
|
||||
*/
|
||||
export async function supprimerSupport(supportId: number, formateurId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Vérifier que le support appartient bien au formateur
|
||||
const [support] = await db
|
||||
.select()
|
||||
.from(supportsFormation)
|
||||
.where(
|
||||
and(
|
||||
eq(supportsFormation.id, supportId),
|
||||
eq(supportsFormation.formateurId, formateurId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!support) {
|
||||
throw new Error("Support non trouvé ou non autorisé");
|
||||
}
|
||||
|
||||
await db.delete(supportsFormation).where(eq(supportsFormation.id, supportId));
|
||||
|
||||
return support; // Retourner le support pour récupérer la clé S3
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide la présence d'un apprenant
|
||||
* @param inscriptionId - ID de l'inscription
|
||||
* @param present - true si présent, false sinon
|
||||
*/
|
||||
export async function validerPresence(inscriptionId: number, present: boolean) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db
|
||||
.update(inscriptions)
|
||||
.set({ statut: present ? 'confirmee' : 'annulee' })
|
||||
.where(eq(inscriptions.id, inscriptionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'historique des formations d'un formateur
|
||||
* @param formateurId - ID du formateur
|
||||
* @param limit - Nombre de résultats à retourner (par défaut 50)
|
||||
*/
|
||||
export async function getHistoriqueFormateur(formateurId: number, limit: number = 50) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
sequenceId: sequences.id,
|
||||
sequenceNom: sequences.nom,
|
||||
formationNom: formations.nom,
|
||||
dateDebut: sql<Date>`MIN(${datesFormation.dateDebut})`,
|
||||
dateFin: sql<Date>`MAX(${datesFormation.dateFin})`,
|
||||
lieu: sequences.lieu,
|
||||
nbInscrits: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM inscriptions
|
||||
WHERE inscriptions.sequenceId = ${sequences.id}
|
||||
AND inscriptions.statut IN ('confirmee', 'liste_attente')
|
||||
)`,
|
||||
nbPresents: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM inscriptions
|
||||
WHERE inscriptions.sequenceId = ${sequences.id}
|
||||
AND inscriptions.statut = 'confirmee'
|
||||
)`,
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
|
||||
.where(eq(sequences.formateurId, formateurId))
|
||||
.groupBy(sequences.id, sequences.nom, formations.nom, sequences.lieu)
|
||||
.orderBy(desc(sql`MIN(${datesFormation.dateDebut})`))
|
||||
.limit(limit);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les détails d'une séquence pour un formateur
|
||||
* @param sequenceId - ID de la séquence
|
||||
* @param formateurId - ID du formateur (pour vérification)
|
||||
*/
|
||||
export async function getDetailSequence(sequenceId: number, formateurId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const [result] = await db
|
||||
.select({
|
||||
id: sequences.id,
|
||||
nom: sequences.nom,
|
||||
formationNom: formations.nom,
|
||||
lieu: sequences.lieu,
|
||||
capaciteMax: sequences.capaciteMax,
|
||||
formateurNom: formateurs.nom,
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.innerJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sequences.id, sequenceId),
|
||||
eq(sequences.formateurId, formateurId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
// Récupérer les dates de formation
|
||||
const dates = await db
|
||||
.select({
|
||||
id: datesFormation.id,
|
||||
dateDebut: datesFormation.dateDebut,
|
||||
dateFin: datesFormation.dateFin,
|
||||
ordre: datesFormation.ordre,
|
||||
})
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, sequenceId))
|
||||
.orderBy(datesFormation.dateDebut);
|
||||
|
||||
return {
|
||||
...result,
|
||||
dates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les statistiques du tableau de bord pour un formateur
|
||||
*/
|
||||
export async function getFormateurDashboardStats(formateurId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Nombre total de séquences du formateur
|
||||
const totalSequences = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(sequences)
|
||||
.where(eq(sequences.formateurId, formateurId));
|
||||
|
||||
// Nombre total d'inscrits confirmés à toutes les séquences du formateur
|
||||
const totalInscrits = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(inscriptions)
|
||||
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sequences.formateurId, formateurId),
|
||||
eq(inscriptions.statut, 'confirmee')
|
||||
)
|
||||
);
|
||||
|
||||
// Nombre de séquences à venir (ayant au moins une date future)
|
||||
const today = new Date();
|
||||
const sequencesAvenir = await db
|
||||
.select({ sequenceId: datesFormation.sequenceId })
|
||||
.from(datesFormation)
|
||||
.innerJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sequences.formateurId, formateurId),
|
||||
gte(datesFormation.dateDebut, today)
|
||||
)
|
||||
)
|
||||
.groupBy(datesFormation.sequenceId);
|
||||
|
||||
return {
|
||||
totalSequences: totalSequences[0]?.count || 0,
|
||||
totalInscrits: totalInscrits[0]?.count || 0,
|
||||
sequencesAvenir: sequencesAvenir.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les prochaines séquences du formateur avec leurs détails
|
||||
*/
|
||||
export async function getFormateurProchainesSequences(formateurId: number, limit: number = 10) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const today = new Date();
|
||||
|
||||
// Récupérer les séquences du formateur ayant au moins une date future
|
||||
const seqs = await db
|
||||
.select()
|
||||
.from(sequences)
|
||||
.where(eq(sequences.formateurId, formateurId));
|
||||
|
||||
// Pour chaque séquence, récupérer les détails
|
||||
const sequencesAvecDetails = await Promise.all(
|
||||
seqs.map(async (seq) => {
|
||||
// Récupérer toutes les dates de la séquence
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, seq.id))
|
||||
.orderBy(datesFormation.dateDebut);
|
||||
|
||||
// Vérifier si au moins une date est future
|
||||
const hasFutureDate = dates.some(d => new Date(d.dateDebut) >= today);
|
||||
|
||||
if (!hasFutureDate) return null;
|
||||
|
||||
// Récupérer la formation
|
||||
const formation = await db
|
||||
.select()
|
||||
.from(formations)
|
||||
.where(eq(formations.id, seq.formationId))
|
||||
.limit(1);
|
||||
|
||||
// Compter les inscrits confirmés
|
||||
const inscritsCount = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(inscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, seq.id),
|
||||
eq(inscriptions.statut, 'confirmee')
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...seq,
|
||||
formation: formation[0] || null,
|
||||
dates,
|
||||
nbInscrits: inscritsCount[0]?.count || 0,
|
||||
prochaineDateDebut: dates.find(d => new Date(d.dateDebut) >= today)?.dateDebut || dates[0]?.dateDebut,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrer les séquences nulles et trier par prochaine date
|
||||
const sequencesFiltrees = sequencesAvecDetails
|
||||
.filter(s => s !== null)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a!.prochaineDateDebut);
|
||||
const dateB = new Date(b!.prochaineDateDebut);
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return sequencesFiltrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer l'email du formateur à partir de son ID
|
||||
*/
|
||||
export async function getFormateurEmailById(formateurId: number): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const result = await db
|
||||
.select({ email: formateurs.email })
|
||||
.from(formateurs)
|
||||
.where(eq(formateurs.id, formateurId))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.email || null;
|
||||
}
|
||||
202
deployment-package/source_server/gestionAttestationsDb.ts
Normal file
202
deployment-package/source_server/gestionAttestationsDb.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { formations, attestations, inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Récupérer la configuration d'une formation
|
||||
*/
|
||||
export async function getFormationConfig(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: formations.id,
|
||||
nom: formations.nom,
|
||||
modeAttestation: formations.modeAttestation,
|
||||
modeEnvoi: formations.modeEnvoi,
|
||||
})
|
||||
.from(formations)
|
||||
.where(eq(formations.id, formationId))
|
||||
.limit(1);
|
||||
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour la configuration d'une formation
|
||||
*/
|
||||
export async function updateFormationConfig(
|
||||
formationId: number,
|
||||
modeAttestation: "auto" | "manuel",
|
||||
modeEnvoi: "auto" | "manuel"
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db
|
||||
.update(formations)
|
||||
.set({
|
||||
modeAttestation,
|
||||
modeEnvoi,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(formations.id, formationId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer toutes les attestations d'une formation avec les informations des apprenants
|
||||
*/
|
||||
export async function getAttestationsByFormation(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
attestation: attestations,
|
||||
apprenant: apprenants,
|
||||
sequence: sequences,
|
||||
})
|
||||
.from(attestations)
|
||||
.innerJoin(inscriptions, eq(attestations.inscriptionId, inscriptions.id))
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.where(eq(sequences.formationId, formationId))
|
||||
.orderBy(desc(attestations.createdAt));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploader un document d'attestation pour un apprenant
|
||||
*/
|
||||
export async function uploadAttestationDocument(
|
||||
inscriptionId: number,
|
||||
documentUrl: string,
|
||||
documentS3Key: string,
|
||||
uploadedBy: number
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Vérifier si une attestation existe déjà pour cette inscription
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(attestations)
|
||||
.where(eq(attestations.inscriptionId, inscriptionId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Mettre à jour l'attestation existante
|
||||
await db
|
||||
.update(attestations)
|
||||
.set({
|
||||
documentUrl,
|
||||
documentS3Key,
|
||||
uploadedBy,
|
||||
uploadedAt: new Date(),
|
||||
})
|
||||
.where(eq(attestations.inscriptionId, inscriptionId));
|
||||
|
||||
return existing[0].id;
|
||||
} else {
|
||||
// Créer une nouvelle attestation
|
||||
const result = await db
|
||||
.insert(attestations)
|
||||
.values({
|
||||
inscriptionId,
|
||||
documentUrl,
|
||||
documentS3Key,
|
||||
uploadedBy,
|
||||
uploadedAt: new Date(),
|
||||
})
|
||||
.$returningId();
|
||||
|
||||
return result[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoyer une attestation par email
|
||||
*/
|
||||
export async function markAttestationAsSent(attestationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db
|
||||
.update(attestations)
|
||||
.set({
|
||||
emailEnvoye: true,
|
||||
dateEnvoiEmail: new Date(),
|
||||
})
|
||||
.where(eq(attestations.id, attestationId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les apprenants d'une formation avec leur statut d'attestation
|
||||
*/
|
||||
export async function getApprenantsWithAttestationStatus(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
apprenant: {
|
||||
id: apprenants.id,
|
||||
nom: apprenants.nom,
|
||||
prenom: apprenants.prenom,
|
||||
email: apprenants.email,
|
||||
},
|
||||
inscription: {
|
||||
id: inscriptions.id,
|
||||
sequenceId: inscriptions.sequenceId,
|
||||
},
|
||||
sequence: {
|
||||
id: sequences.id,
|
||||
nom: sequences.nom,
|
||||
},
|
||||
attestation: {
|
||||
id: attestations.id,
|
||||
documentUrl: attestations.documentUrl,
|
||||
emailEnvoye: attestations.emailEnvoye,
|
||||
dateEnvoiEmail: attestations.dateEnvoiEmail,
|
||||
},
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.leftJoin(attestations, eq(attestations.inscriptionId, inscriptions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sequences.formationId, formationId),
|
||||
eq(inscriptions.statut, "confirmee")
|
||||
)
|
||||
)
|
||||
.orderBy(apprenants.nom, apprenants.prenom);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer un document d'attestation
|
||||
*/
|
||||
export async function deleteAttestationDocument(attestationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db
|
||||
.update(attestations)
|
||||
.set({
|
||||
documentUrl: null,
|
||||
documentS3Key: null,
|
||||
uploadedBy: null,
|
||||
uploadedAt: null,
|
||||
})
|
||||
.where(eq(attestations.id, attestationId));
|
||||
|
||||
return true;
|
||||
}
|
||||
109
deployment-package/source_server/icsGenerator.ts
Normal file
109
deployment-package/source_server/icsGenerator.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Module de génération de fichiers ICS (iCalendar) pour les invitations Outlook
|
||||
*/
|
||||
|
||||
interface ICSEvent {
|
||||
summary: string;
|
||||
description?: string;
|
||||
location: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
attendeeEmail: string;
|
||||
attendeeName: string;
|
||||
organizerEmail?: string;
|
||||
organizerName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date au format iCalendar (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UID unique pour l'événement
|
||||
*/
|
||||
function generateUID(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@itinova.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux pour le format ICS
|
||||
*/
|
||||
function escapeICS(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une invitation Outlook
|
||||
*/
|
||||
export function generateICS(event: ICSEvent): string {
|
||||
const now = new Date();
|
||||
const uid = generateUID();
|
||||
|
||||
const icsContent = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Itinova//Formation Manager//FR',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${formatICSDate(now)}`,
|
||||
`DTSTART:${formatICSDate(event.startDate)}`,
|
||||
`DTEND:${formatICSDate(event.endDate)}`,
|
||||
`SUMMARY:${escapeICS(event.summary)}`,
|
||||
event.description ? `DESCRIPTION:${escapeICS(event.description)}` : '',
|
||||
`LOCATION:${escapeICS(event.location)}`,
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:OPAQUE', // Marque comme "occupé" dans le calendrier
|
||||
'SEQUENCE:0',
|
||||
`ORGANIZER;CN=${escapeICS(event.organizerName || 'Formation Itinova')}:mailto:${event.organizerEmail || 'formation@itinova.com'}`,
|
||||
`ATTENDEE;CN=${escapeICS(event.attendeeName)};RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:${event.attendeeEmail}`,
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-P1D', // Rappel 1 jour avant
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:Rappel: ${escapeICS(event.summary)}`,
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].filter(line => line !== '').join('\r\n');
|
||||
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une session de formation
|
||||
*/
|
||||
export function generateFormationICS(params: {
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantEmail: string;
|
||||
}): string {
|
||||
return generateICS({
|
||||
summary: `Formation: ${params.formationNom} - ${params.sessionNom}`,
|
||||
description: `Vous êtes inscrit à la formation "${params.formationNom}".\n\nSession: ${params.sessionNom}\n\nMerci de vous présenter à l'heure indiquée.`,
|
||||
location: params.lieu,
|
||||
startDate: params.dateDebut,
|
||||
endDate: params.dateFin,
|
||||
attendeeEmail: params.apprenantEmail,
|
||||
attendeeName: `${params.apprenantPrenom} ${params.apprenantNom}`,
|
||||
});
|
||||
}
|
||||
331
deployment-package/source_server/importExcel.ts
Normal file
331
deployment-package/source_server/importExcel.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getDb } from './db';
|
||||
import { formations as formationsTable, sequences as sequencesTable, datesFormation } from '../drizzle/schema';
|
||||
|
||||
export interface FormationImport {
|
||||
nom: string;
|
||||
description: string;
|
||||
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
||||
}
|
||||
|
||||
export interface SequenceImport {
|
||||
formationNom: string;
|
||||
nom: string;
|
||||
lieu: string;
|
||||
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
||||
capaciteMax: number;
|
||||
dateBlocage: string;
|
||||
statut: 'ouverte' | 'bloquee' | 'terminee';
|
||||
dates: Array<{ debut: string; fin: string }>;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
formationsCreated: number;
|
||||
sequencesCreated: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse un fichier Excel et retourne les données structurées
|
||||
*/
|
||||
export function parseExcelFile(buffer: Buffer): { formations: FormationImport[]; sequences: SequenceImport[] } {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
|
||||
const formations: FormationImport[] = [];
|
||||
const sequences: SequenceImport[] = [];
|
||||
|
||||
// Parser la feuille "Formations"
|
||||
if (workbook.SheetNames.includes('Formations')) {
|
||||
const sheet = workbook.Sheets['Formations'];
|
||||
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
||||
|
||||
// Ignorer la première ligne (en-têtes) et les lignes vides
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
if (!row || row.length === 0 || !row[0]) continue;
|
||||
|
||||
// Arrêter si on atteint les instructions
|
||||
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
||||
|
||||
formations.push({
|
||||
nom: String(row[0] || '').trim(),
|
||||
description: String(row[1] || '').trim(),
|
||||
publicCible: normalizePublicCible(String(row[2] || '').trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la feuille "Séquences"
|
||||
if (workbook.SheetNames.includes('Séquences')) {
|
||||
const sheet = workbook.Sheets['Séquences'];
|
||||
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
if (!row || row.length === 0 || !row[0]) continue;
|
||||
|
||||
// Arrêter si on atteint les instructions
|
||||
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
||||
|
||||
const dates: Array<{ debut: string; fin: string }> = [];
|
||||
|
||||
// Parser les 4 dates possibles (colonnes 7-14)
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const debutIdx = 7 + (d * 2);
|
||||
const finIdx = 8 + (d * 2);
|
||||
|
||||
if (row[debutIdx] && row[finIdx]) {
|
||||
dates.push({
|
||||
debut: parseExcelDate(row[debutIdx]),
|
||||
fin: parseExcelDate(row[finIdx]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sequences.push({
|
||||
formationNom: String(row[0] || '').trim(),
|
||||
nom: String(row[1] || '').trim(),
|
||||
lieu: String(row[2] || '').trim(),
|
||||
publicCible: normalizePublicCible(String(row[3] || '').trim()),
|
||||
capaciteMax: parseInt(String(row[4] || '12')),
|
||||
dateBlocage: parseExcelDate(row[5]),
|
||||
statut: normalizeStatut(String(row[6] || '').trim()),
|
||||
dates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { formations, sequences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les données importées
|
||||
*/
|
||||
export function validateImportData(
|
||||
formations: FormationImport[],
|
||||
sequences: SequenceImport[]
|
||||
): { valid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Valider les formations
|
||||
formations.forEach((formation, index) => {
|
||||
if (!formation.nom) {
|
||||
errors.push(`Formation ligne ${index + 2}: Le nom est obligatoire`);
|
||||
}
|
||||
if (!formation.description) {
|
||||
errors.push(`Formation ligne ${index + 2}: La description est obligatoire`);
|
||||
}
|
||||
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(formation.publicCible)) {
|
||||
errors.push(`Formation ligne ${index + 2}: Public cible invalide (${formation.publicCible})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Créer un set des noms de formations pour validation des séquences
|
||||
const formationNames = new Set(formations.map(f => f.nom.toLowerCase()));
|
||||
|
||||
// Valider les séquences
|
||||
sequences.forEach((sequence, index) => {
|
||||
if (!sequence.formationNom) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le nom de la formation est obligatoire`);
|
||||
} else if (!formationNames.has(sequence.formationNom.toLowerCase())) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Formation "${sequence.formationNom}" non trouvée dans la feuille Formations`);
|
||||
}
|
||||
|
||||
if (!sequence.nom) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le nom est obligatoire`);
|
||||
}
|
||||
if (!sequence.lieu) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Le lieu est obligatoire`);
|
||||
}
|
||||
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(sequence.publicCible)) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Public cible invalide`);
|
||||
}
|
||||
if (!sequence.dateBlocage) {
|
||||
errors.push(`Séquence ligne ${index + 2}: La date de blocage est obligatoire`);
|
||||
}
|
||||
if (!['ouverte', 'bloquee', 'terminee'].includes(sequence.statut)) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Statut invalide`);
|
||||
}
|
||||
if (sequence.dates.length === 0) {
|
||||
errors.push(`Séquence ligne ${index + 2}: Au moins une date est obligatoire`);
|
||||
}
|
||||
if (sequence.dates.length > 4) {
|
||||
warnings.push(`Séquence ligne ${index + 2}: Maximum 4 dates autorisées, les dates supplémentaires seront ignorées`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe les données dans la base de données
|
||||
*/
|
||||
export async function importToDatabase(
|
||||
formations: FormationImport[],
|
||||
sequences: SequenceImport[]
|
||||
): Promise<ImportResult> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return {
|
||||
success: false,
|
||||
formationsCreated: 0,
|
||||
sequencesCreated: 0,
|
||||
errors: ['Connexion à la base de données impossible'],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let formationsCreated = 0;
|
||||
let sequencesCreated = 0;
|
||||
|
||||
try {
|
||||
// Map pour stocker les IDs des formations créées
|
||||
const formationIdMap = new Map<string, number>();
|
||||
|
||||
// Importer les formations
|
||||
console.log(`[Import] Début import de ${formations.length} formations`);
|
||||
for (const formation of formations) {
|
||||
try {
|
||||
console.log(`[Import] Création formation: ${formation.nom}`);
|
||||
const [result] = await db.insert(formationsTable).values({
|
||||
nom: formation.nom,
|
||||
description: formation.description,
|
||||
lienUnique: generateUniqueLien(),
|
||||
}).$returningId();
|
||||
|
||||
const formationId = result.id;
|
||||
formationIdMap.set(formation.nom.toLowerCase(), formationId);
|
||||
console.log(`[Import] Formation créée avec ID ${formationId}, ajoutée à la map avec clé: "${formation.nom.toLowerCase()}"`);
|
||||
formationsCreated++;
|
||||
} catch (error: any) {
|
||||
console.error(`[Import] Erreur formation "${formation.nom}":`, error);
|
||||
errors.push(`Erreur lors de la création de la formation "${formation.nom}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`[Import] Formations créées: ${formationsCreated}, Map size: ${formationIdMap.size}`);
|
||||
console.log(`[Import] Clés dans la map:`, Array.from(formationIdMap.keys()));
|
||||
|
||||
// Si des erreurs sont survenues lors de la création des formations, arrêter l'import
|
||||
if (errors.length > 0) {
|
||||
console.error(`[Import] Arrêt de l'import car ${errors.length} erreur(s) lors de la création des formations`);
|
||||
return {
|
||||
success: false,
|
||||
formationsCreated,
|
||||
sequencesCreated: 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// Importer les séquences
|
||||
console.log(`[Import] Début import de ${sequences.length} séquences`);
|
||||
for (const sequence of sequences) {
|
||||
try {
|
||||
const searchKey = sequence.formationNom.toLowerCase();
|
||||
console.log(`[Import] Recherche formation pour séquence "${sequence.nom}" avec clé: "${searchKey}"`);
|
||||
const formationId = formationIdMap.get(searchKey);
|
||||
if (!formationId) {
|
||||
errors.push(`Formation "${sequence.formationNom}" non trouvée pour la séquence "${sequence.nom}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [result] = await db.insert(sequencesTable).values({
|
||||
formationId,
|
||||
nom: sequence.nom,
|
||||
lieu: sequence.lieu,
|
||||
publicCible: sequence.publicCible,
|
||||
capaciteMax: sequence.capaciteMax,
|
||||
dateBlocage: new Date(sequence.dateBlocage),
|
||||
statut: sequence.statut,
|
||||
}).$returningId();
|
||||
|
||||
const sequenceId = result.id;
|
||||
|
||||
// Insérer les dates de formation
|
||||
for (let i = 0; i < Math.min(sequence.dates.length, 4); i++) {
|
||||
const date = sequence.dates[i];
|
||||
await db.insert(datesFormation).values({
|
||||
sequenceId,
|
||||
dateDebut: new Date(date.debut),
|
||||
dateFin: new Date(date.fin),
|
||||
ordre: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
sequencesCreated++;
|
||||
} catch (error: any) {
|
||||
errors.push(`Erreur lors de la création de la séquence "${sequence.nom}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
formationsCreated,
|
||||
sequencesCreated,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
formationsCreated,
|
||||
sequencesCreated,
|
||||
errors: [`Erreur générale: ${error.message}`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise le public cible
|
||||
*/
|
||||
function normalizePublicCible(value: string): 'directeur' | 'chef_service' | 'tous' | 'autre' {
|
||||
const normalized = value.toLowerCase().trim();
|
||||
if (normalized === 'directeur') return 'directeur';
|
||||
if (normalized === 'chef_service' || normalized === 'chef de service') return 'chef_service';
|
||||
if (normalized === 'tous') return 'tous';
|
||||
return 'autre';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise le statut
|
||||
*/
|
||||
function normalizeStatut(value: string): 'ouverte' | 'bloquee' | 'terminee' {
|
||||
const normalized = value.toLowerCase().trim();
|
||||
if (normalized === 'ouverte') return 'ouverte';
|
||||
if (normalized === 'bloquee' || normalized === 'bloquée') return 'bloquee';
|
||||
if (normalized === 'terminee' || normalized === 'terminée') return 'terminee';
|
||||
return 'ouverte';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une date Excel (peut être un nombre de série Excel ou une chaîne)
|
||||
*/
|
||||
function parseExcelDate(value: any): string {
|
||||
if (!value) return '';
|
||||
|
||||
// Si c'est un nombre (date Excel)
|
||||
if (typeof value === 'number') {
|
||||
const date = XLSX.SSF.parse_date_code(value);
|
||||
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H || 0).padStart(2, '0')}:${String(date.M || 0).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Si c'est déjà une chaîne
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un lien unique pour une formation
|
||||
*/
|
||||
function generateUniqueLien(): string {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
85
deployment-package/source_server/inscriptionWithDates.ts
Normal file
85
deployment-package/source_server/inscriptionWithDates.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { eq, ne, and } from "drizzle-orm";
|
||||
import { inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema";
|
||||
import { getDb } from "./db";
|
||||
|
||||
/**
|
||||
* Récupérer les inscriptions d'une séquence avec les dates de formation
|
||||
*/
|
||||
export async function getInscriptionsWithDates(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
// Récupérer les inscriptions
|
||||
const results = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, sequenceId),
|
||||
ne(inscriptions.statut, "annulee")
|
||||
)
|
||||
);
|
||||
|
||||
// Récupérer les dates de formation pour cette séquence
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, sequenceId))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
// Combiner les données
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
dates,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les inscriptions d'un apprenant avec les détails des séquences et dates
|
||||
*/
|
||||
export async function getInscriptionsByApprenantWithDates(apprenantId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
// Récupérer les inscriptions
|
||||
const results = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
sequence: sequences,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.apprenantId, apprenantId),
|
||||
ne(inscriptions.statut, "annulee")
|
||||
)
|
||||
);
|
||||
|
||||
// Pour chaque inscription, récupérer les dates de formation
|
||||
const resultsWithDates = await Promise.all(
|
||||
results.map(async (r) => {
|
||||
if (!r.sequence) return { ...r, dates: [] };
|
||||
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, r.sequence.id))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
return {
|
||||
...r,
|
||||
sequence: {
|
||||
...r.sequence,
|
||||
dates,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return resultsWithDates;
|
||||
}
|
||||
210
deployment-package/source_server/notificationLogsDb.ts
Normal file
210
deployment-package/source_server/notificationLogsDb.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { eq, desc, and, gte, lte, sql, like } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { logsNotifications, sequences, apprenants, formateurs, formations } from "../drizzle/schema";
|
||||
|
||||
export type NotificationType =
|
||||
| "remerciement"
|
||||
| "notification_formateur_inscription"
|
||||
| "notification_formateur_annulation"
|
||||
| "alerte_capacite"
|
||||
| "notification_liste_attente";
|
||||
|
||||
export interface LogNotificationInput {
|
||||
type: NotificationType;
|
||||
sequenceId?: number;
|
||||
apprenantId?: number;
|
||||
formateurId?: number;
|
||||
emailDestinataire: string;
|
||||
sujet: string;
|
||||
statut: "success" | "failed";
|
||||
messageErreur?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un log de notification
|
||||
*/
|
||||
export async function logNotification(input: LogNotificationInput) {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[NotificationLogs] Database not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(logsNotifications).values({
|
||||
type: input.type,
|
||||
sequenceId: input.sequenceId || null,
|
||||
apprenantId: input.apprenantId || null,
|
||||
formateurId: input.formateurId || null,
|
||||
emailDestinataire: input.emailDestinataire,
|
||||
sujet: input.sujet,
|
||||
statut: input.statut,
|
||||
messageErreur: input.messageErreur || null,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
});
|
||||
console.log(`[NotificationLogs] Log créé: ${input.type} -> ${input.emailDestinataire} (${input.statut})`);
|
||||
} catch (error) {
|
||||
console.error("[NotificationLogs] Erreur lors de la création du log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'historique des notifications avec filtres
|
||||
*/
|
||||
export async function getNotificationLogs(filters?: {
|
||||
type?: NotificationType;
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
statut?: "success" | "failed";
|
||||
email?: string;
|
||||
sequenceId?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return { logs: [], total: 0 };
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(eq(logsNotifications.type, filters.type));
|
||||
}
|
||||
if (filters?.dateDebut) {
|
||||
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
|
||||
}
|
||||
if (filters?.dateFin) {
|
||||
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
|
||||
}
|
||||
if (filters?.statut) {
|
||||
conditions.push(eq(logsNotifications.statut, filters.statut));
|
||||
}
|
||||
if (filters?.email) {
|
||||
conditions.push(like(logsNotifications.emailDestinataire, `%${filters.email}%`));
|
||||
}
|
||||
if (filters?.sequenceId) {
|
||||
conditions.push(eq(logsNotifications.sequenceId, filters.sequenceId));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Récupérer les logs avec les informations associées
|
||||
const logs = await db
|
||||
.select({
|
||||
id: logsNotifications.id,
|
||||
type: logsNotifications.type,
|
||||
sequenceId: logsNotifications.sequenceId,
|
||||
apprenantId: logsNotifications.apprenantId,
|
||||
formateurId: logsNotifications.formateurId,
|
||||
emailDestinataire: logsNotifications.emailDestinataire,
|
||||
sujet: logsNotifications.sujet,
|
||||
dateEnvoi: logsNotifications.dateEnvoi,
|
||||
statut: logsNotifications.statut,
|
||||
messageErreur: logsNotifications.messageErreur,
|
||||
metadata: logsNotifications.metadata,
|
||||
sequenceNom: sequences.nom,
|
||||
formationNom: formations.nom,
|
||||
apprenantNom: apprenants.nom,
|
||||
apprenantPrenom: apprenants.prenom,
|
||||
formateurNom: formateurs.nom,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.leftJoin(sequences, eq(logsNotifications.sequenceId, sequences.id))
|
||||
.leftJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(apprenants, eq(logsNotifications.apprenantId, apprenants.id))
|
||||
.leftJoin(formateurs, eq(logsNotifications.formateurId, formateurs.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(logsNotifications.dateEnvoi))
|
||||
.limit(filters?.limit || 50)
|
||||
.offset(filters?.offset || 0);
|
||||
|
||||
// Compter le total
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(logsNotifications)
|
||||
.where(whereClause);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: countResult?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques des notifications
|
||||
*/
|
||||
export async function getNotificationStats(filters?: {
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const conditions = [];
|
||||
if (filters?.dateDebut) {
|
||||
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
|
||||
}
|
||||
if (filters?.dateFin) {
|
||||
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Statistiques globales
|
||||
const [globalStats] = await db
|
||||
.select({
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause);
|
||||
|
||||
// Statistiques par type
|
||||
const statsByType = await db
|
||||
.select({
|
||||
type: logsNotifications.type,
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause)
|
||||
.groupBy(logsNotifications.type);
|
||||
|
||||
// Évolution par jour (7 derniers jours)
|
||||
const evolutionParJour = await db
|
||||
.select({
|
||||
date: sql<string>`DATE(dateEnvoi)`,
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`DATE(dateEnvoi)`)
|
||||
.orderBy(sql`DATE(dateEnvoi)`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
global: {
|
||||
total: globalStats?.total || 0,
|
||||
success: globalStats?.success || 0,
|
||||
failed: globalStats?.failed || 0,
|
||||
tauxSucces: globalStats?.total ? Math.round((globalStats.success / globalStats.total) * 100) : 0,
|
||||
},
|
||||
parType: statsByType,
|
||||
evolutionParJour,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels pour les types de notifications
|
||||
*/
|
||||
export const notificationTypeLabels: Record<NotificationType, string> = {
|
||||
remerciement: "Remerciement post-formation",
|
||||
notification_formateur_inscription: "Notification formateur (inscription)",
|
||||
notification_formateur_annulation: "Notification formateur (annulation)",
|
||||
alerte_capacite: "Alerte capacité atteinte",
|
||||
notification_liste_attente: "Notification liste d'attente",
|
||||
};
|
||||
134
deployment-package/source_server/parametresDb.ts
Normal file
134
deployment-package/source_server/parametresDb.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { parametres, historiqueParametres } from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Récupérer les paramètres de l'application
|
||||
* S'il n'y a pas de paramètres, en créer un avec les valeurs par défaut
|
||||
*/
|
||||
export async function getParametres() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.select().from(parametres).limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
// Créer les paramètres par défaut
|
||||
await db.insert(parametres).values({
|
||||
urlPublique: "https://formations.itinova.org",
|
||||
});
|
||||
|
||||
const newResult = await db.select().from(parametres).limit(1);
|
||||
return newResult[0];
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour les paramètres de l'application
|
||||
* Enregistre automatiquement l'historique des modifications
|
||||
*/
|
||||
export async function updateParametres(
|
||||
updates: Partial<{
|
||||
urlPublique: string;
|
||||
delaiExpirationQR: number;
|
||||
dureeValiditeToken: number;
|
||||
notificationsActives: boolean;
|
||||
envoiAutomatiqueAttestations: boolean;
|
||||
}>,
|
||||
userId: number,
|
||||
userName: string
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const params = await getParametres();
|
||||
|
||||
// Enregistrer l'historique pour chaque champ modifié
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const oldValue = params[key as keyof typeof params];
|
||||
if (oldValue !== value) {
|
||||
await db.insert(historiqueParametres).values({
|
||||
userId,
|
||||
userName,
|
||||
champModifie: key,
|
||||
ancienneValeur: oldValue !== null && oldValue !== undefined ? String(oldValue) : null,
|
||||
nouvelleValeur: value !== null && value !== undefined ? String(value) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour les paramètres
|
||||
await db.update(parametres)
|
||||
.set(updates)
|
||||
.where(eq(parametres.id, params.id));
|
||||
|
||||
return getParametres();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'URL publique (fonction de compatibilité)
|
||||
*/
|
||||
export async function updateUrlPublique(urlPublique: string, userId?: number, userName?: string) {
|
||||
return updateParametres(
|
||||
{ urlPublique },
|
||||
userId || 1,
|
||||
userName || "Système"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer l'historique des modifications des paramètres
|
||||
*/
|
||||
export async function getHistoriqueParametres(limit: number = 50) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
return db.select()
|
||||
.from(historiqueParametres)
|
||||
.orderBy(sql`${historiqueParametres.dateModification} DESC`)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tester l'accessibilité d'une URL
|
||||
*/
|
||||
export async function testerUrl(url: string): Promise<{ accessible: boolean; message: string; statusCode?: number }> {
|
||||
try {
|
||||
// Vérifier le format de l'URL
|
||||
const urlObj = new URL(url);
|
||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
||||
return {
|
||||
accessible: false,
|
||||
message: "L'URL doit commencer par http:// ou https://"
|
||||
};
|
||||
}
|
||||
|
||||
// Tenter une requête HEAD pour vérifier l'accessibilité
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000), // Timeout de 5 secondes
|
||||
});
|
||||
|
||||
return {
|
||||
accessible: response.ok,
|
||||
message: response.ok
|
||||
? "L'URL est accessible"
|
||||
: `L'URL a retourné une erreur HTTP ${response.status}`,
|
||||
statusCode: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
||||
return {
|
||||
accessible: false,
|
||||
message: "Format d'URL invalide"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessible: false,
|
||||
message: `Erreur lors du test: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
149
deployment-package/source_server/presenceDb.ts
Normal file
149
deployment-package/source_server/presenceDb.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema";
|
||||
import { getDb } from "./db";
|
||||
|
||||
/**
|
||||
* Valider la présence d'un apprenant (par QR code ou manuellement)
|
||||
*/
|
||||
export async function validerPresence(params: {
|
||||
inscriptionId: number;
|
||||
dateFormationId: number;
|
||||
modeValidation: "qrcode" | "manuel";
|
||||
validateurId?: number;
|
||||
commentaire?: string;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Vérifier si la présence existe déjà
|
||||
const presenceExistante = await db
|
||||
.select()
|
||||
.from(presences)
|
||||
.where(
|
||||
and(
|
||||
eq(presences.inscriptionId, params.inscriptionId),
|
||||
eq(presences.dateFormationId, params.dateFormationId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (presenceExistante.length > 0) {
|
||||
throw new Error("Présence déjà validée pour cette date");
|
||||
}
|
||||
|
||||
// Créer la présence
|
||||
await db.insert(presences).values({
|
||||
inscriptionId: params.inscriptionId,
|
||||
dateFormationId: params.dateFormationId,
|
||||
heurePresence: new Date(),
|
||||
modeValidation: params.modeValidation,
|
||||
validateurId: params.validateurId,
|
||||
commentaire: params.commentaire,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer toutes les présences pour une séquence
|
||||
*/
|
||||
export async function getPresencesBySequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
presenceId: presences.id,
|
||||
inscriptionId: presences.inscriptionId,
|
||||
dateFormationId: presences.dateFormationId,
|
||||
heurePresence: presences.heurePresence,
|
||||
modeValidation: presences.modeValidation,
|
||||
validateurId: presences.validateurId,
|
||||
commentaire: presences.commentaire,
|
||||
apprenantId: apprenants.id,
|
||||
apprenantNom: apprenants.nom,
|
||||
apprenantPrenom: apprenants.prenom,
|
||||
apprenantEmail: apprenants.email,
|
||||
dateDebut: datesFormation.dateDebut,
|
||||
dateFin: datesFormation.dateFin,
|
||||
})
|
||||
.from(presences)
|
||||
.innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id))
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
|
||||
.where(eq(datesFormation.sequenceId, sequenceId));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les présences pour une inscription spécifique
|
||||
*/
|
||||
export async function getPresencesByInscription(inscriptionId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
presenceId: presences.id,
|
||||
dateFormationId: presences.dateFormationId,
|
||||
heurePresence: presences.heurePresence,
|
||||
modeValidation: presences.modeValidation,
|
||||
validateurId: presences.validateurId,
|
||||
commentaire: presences.commentaire,
|
||||
dateDebut: datesFormation.dateDebut,
|
||||
dateFin: datesFormation.dateFin,
|
||||
})
|
||||
.from(presences)
|
||||
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
|
||||
.where(eq(presences.inscriptionId, inscriptionId));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si toutes les présences sont validées pour une inscription
|
||||
*/
|
||||
export async function checkAllPresencesValidated(inscriptionId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Récupérer l'inscription avec la séquence
|
||||
const inscription = await db
|
||||
.select({
|
||||
sequenceId: inscriptions.sequenceId,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.where(eq(inscriptions.id, inscriptionId))
|
||||
.limit(1);
|
||||
|
||||
if (inscription.length === 0) {
|
||||
throw new Error("Inscription not found");
|
||||
}
|
||||
|
||||
// Compter le nombre de dates de formation pour cette séquence
|
||||
const datesCount = await db
|
||||
.select({ count: datesFormation.id })
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, inscription[0].sequenceId));
|
||||
|
||||
// Compter le nombre de présences validées pour cette inscription
|
||||
const presencesCount = await db
|
||||
.select({ count: presences.id })
|
||||
.from(presences)
|
||||
.where(eq(presences.inscriptionId, inscriptionId));
|
||||
|
||||
return presencesCount.length === datesCount.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprimer une présence
|
||||
*/
|
||||
export async function supprimerPresence(presenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(presences).where(eq(presences.id, presenceId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
63
deployment-package/source_server/qrCodeGenerator.ts
Normal file
63
deployment-package/source_server/qrCodeGenerator.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import QRCode from "qrcode";
|
||||
import { randomBytes } from "crypto";
|
||||
import { getParametres } from "./parametresDb";
|
||||
|
||||
/**
|
||||
* Générer un token unique pour le QR code
|
||||
*/
|
||||
export function generateQRToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Générer un QR code en base64 à partir d'un token
|
||||
* @param token Le token unique de la séquence
|
||||
* @returns Une image QR code en base64 (data URL)
|
||||
*/
|
||||
export async function generateQRCodeDataURL(token: string): Promise<string> {
|
||||
// URL complète pour scanner le QR code
|
||||
const parametres = await getParametres();
|
||||
const url = `${parametres.urlPublique}/emargement/${token}`;
|
||||
|
||||
try {
|
||||
const qrCodeDataURL = await QRCode.toDataURL(url, {
|
||||
errorCorrectionLevel: "H",
|
||||
type: "image/png",
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
light: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
|
||||
return qrCodeDataURL;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la génération du QR code:", error);
|
||||
throw new Error("Impossible de générer le QR code");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Générer un QR code en buffer PNG
|
||||
* @param token Le token unique de la séquence
|
||||
* @returns Un buffer PNG du QR code
|
||||
*/
|
||||
export async function generateQRCodeBuffer(token: string): Promise<Buffer> {
|
||||
const parametres = await getParametres();
|
||||
const url = `${parametres.urlPublique}/emargement/${token}`;
|
||||
|
||||
try {
|
||||
const buffer = await QRCode.toBuffer(url, {
|
||||
errorCorrectionLevel: "H",
|
||||
type: "png",
|
||||
width: 400,
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la génération du QR code:", error);
|
||||
throw new Error("Impossible de générer le QR code");
|
||||
}
|
||||
}
|
||||
250
deployment-package/source_server/questionnaireDb.ts
Normal file
250
deployment-package/source_server/questionnaireDb.ts
Normal file
@@ -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<InsertQuestionnaire>) {
|
||||
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<InsertQuestion>) {
|
||||
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<InsertReponseQuestionnaire>) {
|
||||
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<InsertEnvoiQuestionnaire>) {
|
||||
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<number>`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<number>`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;
|
||||
}
|
||||
174
deployment-package/source_server/questionnaireExport.ts
Normal file
174
deployment-package/source_server/questionnaireExport.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { getQuestionnaireById, getQuestionsByQuestionnaireId, getQuestionnaireStats, getReponsesDetailleesQuestionnaire } from './questionnaireDb';
|
||||
import { getDb } from './db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Génère un fichier Excel avec les statistiques du questionnaire
|
||||
*/
|
||||
export async function exportQuestionnaireToExcel(questionnaireId: number): Promise<Buffer> {
|
||||
const questionnaire = await getQuestionnaireById(questionnaireId);
|
||||
const stats = await getQuestionnaireStats(questionnaireId);
|
||||
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
|
||||
const reponsesData = await getReponsesDetailleesQuestionnaire(questionnaireId);
|
||||
|
||||
if (!questionnaire || !stats) {
|
||||
throw new Error('Questionnaire introuvable');
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
// Feuille 1: Statistiques globales
|
||||
const statsSheet = workbook.addWorksheet('Statistiques');
|
||||
|
||||
statsSheet.addRow(['Questionnaire', questionnaire.titre]);
|
||||
statsSheet.addRow(['Type', questionnaire.type]);
|
||||
statsSheet.addRow(['']);
|
||||
statsSheet.addRow(['Nombre d\'envois', stats.totalEnvois]);
|
||||
statsSheet.addRow(['Nombre de réponses', stats.totalReponses]);
|
||||
statsSheet.addRow(['Taux de réponse', `${stats.tauxReponse.toFixed(1)}%`]);
|
||||
statsSheet.addRow(['']);
|
||||
|
||||
// Style pour l'en-tête
|
||||
statsSheet.getColumn(1).width = 30;
|
||||
statsSheet.getColumn(2).width = 40;
|
||||
statsSheet.getRow(1).font = { bold: true, size: 14 };
|
||||
|
||||
// Feuille 2: Statistiques par question
|
||||
const questionsSheet = workbook.addWorksheet('Par question');
|
||||
|
||||
questionsSheet.addRow(['Question', 'Type', 'Ordre']);
|
||||
questionsSheet.getRow(1).font = { bold: true };
|
||||
questionsSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' }
|
||||
};
|
||||
|
||||
questionsData.forEach((q: any) => {
|
||||
questionsSheet.addRow([
|
||||
q.texte,
|
||||
q.typeQuestion,
|
||||
q.ordre
|
||||
]);
|
||||
});
|
||||
|
||||
questionsSheet.getColumn(1).width = 60;
|
||||
questionsSheet.getColumn(2).width = 20;
|
||||
questionsSheet.getColumn(3).width = 10;
|
||||
|
||||
// Feuille 3: Réponses brutes
|
||||
const reponsesSheet = workbook.addWorksheet('Réponses brutes');
|
||||
|
||||
reponsesSheet.addRow(['Date réponse', 'Apprenant ID', 'Séquence ID', 'Question ID', 'Réponse numérique', 'Réponse texte']);
|
||||
reponsesSheet.getRow(1).font = { bold: true };
|
||||
reponsesSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' }
|
||||
};
|
||||
|
||||
reponsesData.forEach((r: any) => {
|
||||
reponsesSheet.addRow([
|
||||
r.dateReponse ? new Date(r.dateReponse).toLocaleDateString('fr-FR') : '',
|
||||
r.apprenantId,
|
||||
r.sequenceId,
|
||||
r.questionId,
|
||||
r.reponseNumerique || '',
|
||||
r.reponseTexte || ''
|
||||
]);
|
||||
});
|
||||
|
||||
reponsesSheet.getColumn(1).width = 15;
|
||||
reponsesSheet.getColumn(2).width = 15;
|
||||
reponsesSheet.getColumn(3).width = 15;
|
||||
reponsesSheet.getColumn(4).width = 15;
|
||||
reponsesSheet.getColumn(5).width = 20;
|
||||
reponsesSheet.getColumn(6).width = 50;
|
||||
|
||||
// Générer le buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier PDF avec les statistiques du questionnaire
|
||||
*/
|
||||
export async function exportQuestionnaireToPDF(questionnaireId: number): Promise<Buffer> {
|
||||
const questionnaire = await getQuestionnaireById(questionnaireId);
|
||||
const stats = await getQuestionnaireStats(questionnaireId);
|
||||
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
|
||||
|
||||
if (!questionnaire || !stats) {
|
||||
throw new Error('Questionnaire introuvable');
|
||||
}
|
||||
|
||||
const doc = new jsPDF();
|
||||
let yPos = 20;
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(questionnaire.titre, 20, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Type
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Type: ${questionnaire.type}`, 20, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Statistiques globales
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Statistiques globales', 20, yPos);
|
||||
yPos += 8;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Nombre d'envois: ${stats.totalEnvois}`, 20, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Nombre de réponses: ${stats.totalReponses}`, 20, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Taux de réponse: ${stats.tauxReponse.toFixed(1)}%`, 20, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Liste des questions
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Questions du questionnaire', 20, yPos);
|
||||
yPos += 10;
|
||||
|
||||
questionsData.forEach((q: any, index: number) => {
|
||||
// Vérifier si on doit ajouter une nouvelle page
|
||||
if (yPos > 250) {
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
}
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
const questionText = `${index + 1}. ${q.texte}`;
|
||||
const lines = doc.splitTextToSize(questionText, 170);
|
||||
doc.text(lines, 20, yPos);
|
||||
yPos += lines.length * 6;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Type: ${q.typeQuestion}`, 25, yPos);
|
||||
yPos += 8;
|
||||
});
|
||||
|
||||
// Pied de page
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(
|
||||
`Page ${i} sur ${pageCount} - Généré le ${new Date().toLocaleDateString('fr-FR')}`,
|
||||
20,
|
||||
290
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
248
deployment-package/source_server/questionnaireScheduler.ts
Normal file
248
deployment-package/source_server/questionnaireScheduler.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { getDb } from "./db";
|
||||
import { eq, and, lte, sql } from "drizzle-orm";
|
||||
import {
|
||||
questionnaires,
|
||||
sequences,
|
||||
inscriptions,
|
||||
apprenants,
|
||||
datesFormation,
|
||||
envoisQuestionnaires,
|
||||
} from "../drizzle/schema";
|
||||
import {
|
||||
getAllQuestionnaires,
|
||||
checkEnvoiExists,
|
||||
createEnvoiQuestionnaire,
|
||||
} from "./questionnaireDb";
|
||||
import { sendEmail } from "./_core/emailSender";
|
||||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Génère un token unique pour accéder au questionnaire
|
||||
*/
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un questionnaire à un apprenant pour une séquence donnée
|
||||
*/
|
||||
async function envoyerQuestionnaire(
|
||||
questionnaireId: number,
|
||||
apprenantId: number,
|
||||
sequenceId: number,
|
||||
apprenantEmail: string,
|
||||
apprenantNom: string,
|
||||
apprenantPrenom: string,
|
||||
questionnairenom: string,
|
||||
sequenceNom: string
|
||||
) {
|
||||
// Vérifier si le questionnaire n'a pas déjà été envoyé
|
||||
const dejaEnvoye = await checkEnvoiExists(questionnaireId, apprenantId, sequenceId);
|
||||
if (dejaEnvoye) {
|
||||
console.log(
|
||||
`[Questionnaires] Questionnaire ${questionnaireId} déjà envoyé à l'apprenant ${apprenantId} pour la séquence ${sequenceId}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Générer un token unique
|
||||
const token = generateToken();
|
||||
|
||||
// Créer l'envoi
|
||||
await createEnvoiQuestionnaire({
|
||||
questionnaireId,
|
||||
apprenantId,
|
||||
sequenceId,
|
||||
token,
|
||||
dateEnvoi: new Date(),
|
||||
dateReponse: null,
|
||||
});
|
||||
|
||||
// Envoyer l'email
|
||||
const lienQuestionnaire = `${process.env.VITE_FRONTEND_URL || "http://localhost:3000"}/questionnaire/${token}`;
|
||||
|
||||
const emailSent = await sendEmail({
|
||||
to: apprenantEmail,
|
||||
subject: `Questionnaire : ${questionnairenom}`,
|
||||
html: `
|
||||
<h2>Bonjour ${apprenantPrenom} ${apprenantNom},</h2>
|
||||
|
||||
<p>Nous vous remercions d'avoir participé à la formation <strong>${sequenceNom}</strong>.</p>
|
||||
|
||||
<p>Afin d'améliorer continuellement la qualité de nos formations, nous vous invitons à répondre à ce questionnaire :</p>
|
||||
|
||||
<p style="margin: 30px 0;">
|
||||
<a href="${lienQuestionnaire}"
|
||||
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Répondre au questionnaire
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>Ce questionnaire ne vous prendra que quelques minutes.</p>
|
||||
|
||||
<p>Merci pour votre participation !</p>
|
||||
|
||||
<p style="margin-top: 30px; font-size: 12px; color: #666;">
|
||||
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||
<a href="${lienQuestionnaire}">${lienQuestionnaire}</a>
|
||||
</p>
|
||||
`,
|
||||
});
|
||||
|
||||
if (emailSent) {
|
||||
console.log(
|
||||
`[Questionnaires] Questionnaire ${questionnaireId} envoyé à ${apprenantEmail} pour la séquence ${sequenceId}`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
console.error(
|
||||
`[Questionnaires] Échec de l'envoi du questionnaire ${questionnaireId} à ${apprenantEmail}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite les envois automatiques de questionnaires post-formation
|
||||
*/
|
||||
export async function processEnvoisAutomatiques() {
|
||||
console.log("[Questionnaires] Démarrage du traitement des envois automatiques...");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.log("[Questionnaires] Base de données non disponible");
|
||||
return { success: false, message: "Base de données non disponible" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer tous les questionnaires actifs avec envoi automatique
|
||||
const questionnairesActifs = await getAllQuestionnaires();
|
||||
const questionnairesAEnvoyer = questionnairesActifs.filter(
|
||||
(q) => q.actif && q.envoiAutomatique
|
||||
);
|
||||
|
||||
if (questionnairesAEnvoyer.length === 0) {
|
||||
console.log("[Questionnaires] Aucun questionnaire avec envoi automatique configuré");
|
||||
return { success: true, message: "Aucun questionnaire à envoyer", count: 0 };
|
||||
}
|
||||
|
||||
let totalEnvoyes = 0;
|
||||
|
||||
for (const questionnaire of questionnairesAEnvoyer) {
|
||||
console.log(
|
||||
`[Questionnaires] Traitement du questionnaire "${questionnaire.titre}" (délai: J+${questionnaire.delaiEnvoiJours})`
|
||||
);
|
||||
|
||||
// Calculer la date cible (aujourd'hui - délai)
|
||||
const dateCible = new Date();
|
||||
dateCible.setDate(dateCible.getDate() - questionnaire.delaiEnvoiJours);
|
||||
dateCible.setHours(0, 0, 0, 0);
|
||||
|
||||
// Récupérer les séquences terminées à la date cible
|
||||
const sequencesTerminees = await db
|
||||
.select({
|
||||
sequenceId: sequences.id,
|
||||
sequenceNom: sequences.nom,
|
||||
formationId: sequences.formationId,
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
|
||||
.where(lte(datesFormation.dateFin, dateCible))
|
||||
.groupBy(sequences.id);
|
||||
|
||||
console.log(
|
||||
`[Questionnaires] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s) pour le ${dateCible.toLocaleDateString("fr-FR")}`
|
||||
);
|
||||
|
||||
// Pour chaque séquence terminée
|
||||
for (const seq of sequencesTerminees) {
|
||||
// Vérifier si le questionnaire est lié à une formation spécifique
|
||||
if (questionnaire.formationId && questionnaire.formationId !== seq.formationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer les apprenants inscrits et validés
|
||||
const apprenantsInscrits = await db
|
||||
.select({
|
||||
apprenantId: apprenants.id,
|
||||
apprenantEmail: apprenants.email,
|
||||
apprenantNom: apprenants.nom,
|
||||
apprenantPrenom: apprenants.prenom,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(apprenants.id, inscriptions.apprenantId))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, seq.sequenceId),
|
||||
eq(inscriptions.statut, "confirmee")
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Questionnaires] ${apprenantsInscrits.length} apprenant(s) inscrit(s) à la séquence ${seq.sequenceId}`
|
||||
);
|
||||
|
||||
// Envoyer le questionnaire à chaque apprenant
|
||||
for (const apprenant of apprenantsInscrits) {
|
||||
if (!apprenant.apprenantEmail) {
|
||||
console.log(
|
||||
`[Questionnaires] Apprenant ${apprenant.apprenantId} sans email, envoi ignoré`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const envoye = await envoyerQuestionnaire(
|
||||
questionnaire.id,
|
||||
apprenant.apprenantId,
|
||||
seq.sequenceId,
|
||||
apprenant.apprenantEmail,
|
||||
apprenant.apprenantNom,
|
||||
apprenant.apprenantPrenom,
|
||||
questionnaire.titre,
|
||||
seq.sequenceNom
|
||||
);
|
||||
|
||||
if (envoye) {
|
||||
totalEnvoyes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Questionnaires] Traitement terminé - ${totalEnvoyes} questionnaire(s) envoyé(s)`);
|
||||
return {
|
||||
success: true,
|
||||
message: `${totalEnvoyes} questionnaire(s) envoyé(s)`,
|
||||
count: totalEnvoyes,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("[Questionnaires] Erreur lors du traitement :", error);
|
||||
return { success: false, message: error.message, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le scheduler pour les envois automatiques de questionnaires
|
||||
* Exécute le traitement tous les jours à 9h00
|
||||
*/
|
||||
export function initQuestionnaireScheduler() {
|
||||
console.log("[Questionnaires] Initialisation du scheduler d'envoi automatique");
|
||||
|
||||
// Exécuter immédiatement au démarrage
|
||||
processEnvoisAutomatiques();
|
||||
|
||||
// Puis exécuter tous les jours à 9h00
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
const now = new Date();
|
||||
if (now.getHours() === 9 && now.getMinutes() === 0) {
|
||||
processEnvoisAutomatiques();
|
||||
}
|
||||
},
|
||||
60 * 1000 // Vérifier toutes les minutes
|
||||
);
|
||||
|
||||
console.log("[Questionnaires] Scheduler initialisé - vérification quotidienne à 9h00");
|
||||
|
||||
return interval;
|
||||
}
|
||||
164
deployment-package/source_server/questionnaireSuiviDb.ts
Normal file
164
deployment-package/source_server/questionnaireSuiviDb.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { eq, sql, and, gte, lte } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import {
|
||||
questionnaires,
|
||||
envoisQuestionnaires,
|
||||
reponsesQuestionnaires,
|
||||
reponsesQuestions,
|
||||
questions,
|
||||
sequences,
|
||||
formations,
|
||||
formateurs
|
||||
} from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Statistiques de taux de réponse par formation
|
||||
*/
|
||||
export async function getStatsByFormation() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
formationId: formations.id,
|
||||
formationNom: formations.nom,
|
||||
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
|
||||
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(envoisQuestionnaires)
|
||||
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.groupBy(formations.id, formations.nom);
|
||||
|
||||
return results.map(r => ({
|
||||
formationId: r.formationId,
|
||||
formationNom: r.formationNom,
|
||||
totalEnvois: Number(r.totalEnvois),
|
||||
totalReponses: Number(r.totalReponses),
|
||||
tauxReponse: Number(r.totalEnvois) > 0
|
||||
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
|
||||
: 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Évolution temporelle des réponses (par mois)
|
||||
*/
|
||||
export async function getEvolutionTemporelle(startDate?: Date, endDate?: Date) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const conditions = [];
|
||||
if (startDate) {
|
||||
conditions.push(gte(envoisQuestionnaires.dateEnvoi, startDate));
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(lte(envoisQuestionnaires.dateEnvoi, endDate));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
mois: sql<string>`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`,
|
||||
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
|
||||
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(envoisQuestionnaires)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.groupBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`)
|
||||
.orderBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`);
|
||||
|
||||
return results.map(r => ({
|
||||
mois: r.mois,
|
||||
totalEnvois: Number(r.totalEnvois),
|
||||
totalReponses: Number(r.totalReponses),
|
||||
tauxReponse: Number(r.totalEnvois) > 0
|
||||
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
|
||||
: 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparaison par formateur
|
||||
*/
|
||||
export async function getStatsByFormateur() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Première requête : statistiques d'envois et réponses par formateur
|
||||
const statsEnvois = await db
|
||||
.select({
|
||||
formateurId: formateurs.id,
|
||||
formateurNom: formateurs.nom,
|
||||
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
|
||||
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(envoisQuestionnaires)
|
||||
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
|
||||
.innerJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.groupBy(formateurs.id, formateurs.nom);
|
||||
|
||||
// Pour chaque formateur, calculer la moyenne de satisfaction
|
||||
const results = await Promise.all(statsEnvois.map(async (stat) => {
|
||||
// Calculer la moyenne des réponses de type échelle pour ce formateur
|
||||
const satisfactionResult = await db
|
||||
.select({
|
||||
moyenne: sql<number>`AVG(CAST(${reponsesQuestions.reponseNumerique} AS DECIMAL(10,2)))`,
|
||||
})
|
||||
.from(reponsesQuestionnaires)
|
||||
.innerJoin(sequences, eq(reponsesQuestionnaires.sequenceId, sequences.id))
|
||||
.innerJoin(reponsesQuestions, eq(reponsesQuestionnaires.id, reponsesQuestions.reponseQuestionnaireId))
|
||||
.innerJoin(questions, eq(reponsesQuestions.questionId, questions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sequences.formateurId, stat.formateurId),
|
||||
eq(reponsesQuestionnaires.complete, true),
|
||||
eq(questions.typeQuestion, 'echelle')
|
||||
)
|
||||
);
|
||||
|
||||
const moyenneSatisfaction = satisfactionResult[0]?.moyenne
|
||||
? Number(satisfactionResult[0].moyenne).toFixed(1)
|
||||
: null;
|
||||
|
||||
return {
|
||||
formateurId: stat.formateurId,
|
||||
formateurNom: stat.formateurNom || "Non spécifié",
|
||||
totalEnvois: Number(stat.totalEnvois),
|
||||
totalReponses: Number(stat.totalReponses),
|
||||
tauxReponse: Number(stat.totalEnvois) > 0
|
||||
? Math.round((Number(stat.totalReponses) / Number(stat.totalEnvois)) * 100)
|
||||
: 0,
|
||||
moyenneSatisfaction
|
||||
};
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques globales pour le tableau de bord
|
||||
*/
|
||||
export async function getStatsGlobales() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
totalQuestionnaires: sql<number>`COUNT(DISTINCT ${questionnaires.id})`,
|
||||
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
|
||||
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(questionnaires)
|
||||
.leftJoin(envoisQuestionnaires, eq(questionnaires.id, envoisQuestionnaires.questionnaireId));
|
||||
|
||||
const stats = results[0];
|
||||
const totalEnvois = Number(stats.totalEnvois) || 0;
|
||||
const totalReponses = Number(stats.totalReponses) || 0;
|
||||
|
||||
return {
|
||||
totalQuestionnaires: Number(stats.totalQuestionnaires) || 0,
|
||||
totalEnvois,
|
||||
totalReponses,
|
||||
tauxReponseGlobal: totalEnvois > 0 ? Number(((totalReponses / totalEnvois) * 100).toFixed(1)) : 0
|
||||
};
|
||||
}
|
||||
328
deployment-package/source_server/rappelDb.ts
Normal file
328
deployment-package/source_server/rappelDb.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { getDb } from "./db";
|
||||
import { logsRappels, InsertLogRappel, LogRappel } from "../drizzle/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Enregistre l'envoi d'un rappel dans les logs
|
||||
*/
|
||||
export async function logRappelEnvoye(data: {
|
||||
rappelId: number;
|
||||
sequenceId: number;
|
||||
apprenantId: number;
|
||||
emailDestinataire: string;
|
||||
typeRappel: string;
|
||||
statut: "success" | "failed";
|
||||
messageErreur?: string;
|
||||
dateSequence: Date;
|
||||
}): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[LogsRappels] Base de données non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(logsRappels).values({
|
||||
rappelId: data.rappelId,
|
||||
sequenceId: data.sequenceId,
|
||||
apprenantId: data.apprenantId,
|
||||
emailDestinataire: data.emailDestinataire,
|
||||
typeRappel: data.typeRappel,
|
||||
dateEnvoi: new Date(),
|
||||
statut: data.statut,
|
||||
messageErreur: data.messageErreur || null,
|
||||
dateSequence: data.dateSequence,
|
||||
} as InsertLogRappel);
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de l'enregistrement du log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un rappel a déjà été envoyé à un apprenant pour une séquence donnée
|
||||
*/
|
||||
export async function rappelDejaEnvoye(
|
||||
rappelId: number,
|
||||
sequenceId: number,
|
||||
apprenantId: number
|
||||
): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[LogsRappels] Base de données non disponible");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(
|
||||
and(
|
||||
eq(logsRappels.rappelId, rappelId),
|
||||
eq(logsRappels.sequenceId, sequenceId),
|
||||
eq(logsRappels.apprenantId, apprenantId),
|
||||
eq(logsRappels.statut, "success")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return logs.length > 0;
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la vérification des doublons:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les logs de rappels pour une séquence donnée
|
||||
*/
|
||||
export async function getLogsRappelsBySequence(sequenceId: number): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(eq(logsRappels.sequenceId, sequenceId))
|
||||
.orderBy(logsRappels.dateEnvoi);
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération des logs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les logs de rappels échoués récents (dernières 24h)
|
||||
*/
|
||||
export async function getLogsRappelsEchoues(): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
const hier = new Date();
|
||||
hier.setDate(hier.getDate() - 1);
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(
|
||||
and(
|
||||
eq(logsRappels.statut, "failed"),
|
||||
// Note: Drizzle ne supporte pas directement les comparaisons de dates
|
||||
// On récupère tous les échecs et on filtre en JS
|
||||
)
|
||||
)
|
||||
.orderBy(logsRappels.dateEnvoi);
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération des échecs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre de rappels envoyés avec succès pour une séquence
|
||||
*/
|
||||
export async function countRappelsEnvoyesSequence(sequenceId: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
|
||||
try {
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(
|
||||
and(
|
||||
eq(logsRappels.sequenceId, sequenceId),
|
||||
eq(logsRappels.statut, "success")
|
||||
)
|
||||
);
|
||||
|
||||
return logs.length;
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors du comptage:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs de rappels avec filtres
|
||||
*/
|
||||
export async function getLogsRappelsWithFilters(filters: {
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
sequenceId?: number;
|
||||
statut?: "success" | "failed";
|
||||
email?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
let query = db.select().from(logsRappels);
|
||||
|
||||
const conditions = [];
|
||||
if (filters.sequenceId) {
|
||||
conditions.push(eq(logsRappels.sequenceId, filters.sequenceId));
|
||||
}
|
||||
if (filters.statut) {
|
||||
conditions.push(eq(logsRappels.statut, filters.statut));
|
||||
}
|
||||
if (filters.email) {
|
||||
// Note: Drizzle ne supporte pas LIKE directement, on filtre en JS
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions)) as any;
|
||||
}
|
||||
|
||||
let logs = await query.orderBy(logsRappels.dateEnvoi).limit(filters.limit || 100).offset(filters.offset || 0);
|
||||
|
||||
// Filtrer par email si nécessaire
|
||||
if (filters.email) {
|
||||
logs = logs.filter(log => log.emailDestinataire.toLowerCase().includes(filters.email!.toLowerCase()));
|
||||
}
|
||||
|
||||
// Filtrer par date si nécessaire
|
||||
if (filters.dateDebut) {
|
||||
logs = logs.filter(log => new Date(log.dateEnvoi) >= filters.dateDebut!);
|
||||
}
|
||||
if (filters.dateFin) {
|
||||
logs = logs.filter(log => new Date(log.dateEnvoi) <= filters.dateFin!);
|
||||
}
|
||||
|
||||
return logs;
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération avec filtres:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques des rappels
|
||||
*/
|
||||
export async function getStatsRappels(): Promise<{
|
||||
totalEnvoyes: number;
|
||||
totalSucces: number;
|
||||
totalEchecs: number;
|
||||
tauxSucces: number;
|
||||
emailsProblematiques: Array<{ email: string; nbEchecs: number }>;
|
||||
}> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return {
|
||||
totalEnvoyes: 0,
|
||||
totalSucces: 0,
|
||||
totalEchecs: 0,
|
||||
tauxSucces: 0,
|
||||
emailsProblematiques: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await db.select().from(logsRappels);
|
||||
|
||||
const totalEnvoyes = logs.length;
|
||||
const totalSucces = logs.filter(l => l.statut === "success").length;
|
||||
const totalEchecs = logs.filter(l => l.statut === "failed").length;
|
||||
const tauxSucces = totalEnvoyes > 0 ? (totalSucces / totalEnvoyes) * 100 : 0;
|
||||
|
||||
// Compter les échecs par email
|
||||
const echecsParEmail = new Map<string, number>();
|
||||
logs.filter(l => l.statut === "failed").forEach(log => {
|
||||
const count = echecsParEmail.get(log.emailDestinataire) || 0;
|
||||
echecsParEmail.set(log.emailDestinataire, count + 1);
|
||||
});
|
||||
|
||||
const emailsProblematiques = Array.from(echecsParEmail.entries())
|
||||
.map(([email, nbEchecs]) => ({ email, nbEchecs }))
|
||||
.sort((a, b) => b.nbEchecs - a.nbEchecs)
|
||||
.slice(0, 10); // Top 10
|
||||
|
||||
return {
|
||||
totalEnvoyes,
|
||||
totalSucces,
|
||||
totalEchecs,
|
||||
tauxSucces,
|
||||
emailsProblematiques,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors du calcul des statistiques:", error);
|
||||
return {
|
||||
totalEnvoyes: 0,
|
||||
totalSucces: 0,
|
||||
totalEchecs: 0,
|
||||
tauxSucces: 0,
|
||||
emailsProblematiques: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'évolution des envois par jour
|
||||
*/
|
||||
export async function getEvolutionEnvois(): Promise<Array<{
|
||||
date: string;
|
||||
nbEnvoyes: number;
|
||||
nbSucces: number;
|
||||
nbEchecs: number;
|
||||
}>> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
const logs = await db.select().from(logsRappels).orderBy(logsRappels.dateEnvoi);
|
||||
|
||||
// Grouper par jour
|
||||
const parJour = new Map<string, { nbEnvoyes: number; nbSucces: number; nbEchecs: number }>();
|
||||
|
||||
logs.forEach(log => {
|
||||
const dateStr = new Date(log.dateEnvoi).toISOString().split('T')[0];
|
||||
const stats = parJour.get(dateStr) || { nbEnvoyes: 0, nbSucces: 0, nbEchecs: 0 };
|
||||
stats.nbEnvoyes++;
|
||||
if (log.statut === "success") stats.nbSucces++;
|
||||
else stats.nbEchecs++;
|
||||
parJour.set(dateStr, stats);
|
||||
});
|
||||
|
||||
return Array.from(parJour.entries())
|
||||
.map(([date, stats]) => ({ date, ...stats }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération de l'évolution:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs échoués à réessayer
|
||||
*/
|
||||
export async function getLogsAReessayer(): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
const maintenant = new Date();
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(
|
||||
and(
|
||||
eq(logsRappels.statut, "failed"),
|
||||
// Limiter à 3 tentatives maximum
|
||||
)
|
||||
);
|
||||
|
||||
// Filtrer en JS pour les conditions complexes
|
||||
return logs.filter(log => {
|
||||
if (log.nbTentatives >= 3) return false;
|
||||
if (!log.prochainEssai) return true; // Premier essai
|
||||
return new Date(log.prochainEssai) <= maintenant;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération des logs à réessayer:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
226
deployment-package/source_server/rappelRetry.ts
Normal file
226
deployment-package/source_server/rappelRetry.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { getDb } from "./db";
|
||||
import { logsRappels, sequences, inscriptions, apprenants, formations, datesFormation, rappels, formateurs } from "../drizzle/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
|
||||
import { getLogsAReessayer } from "./rappelDb";
|
||||
import { notifyOwner } from "./_core/notification";
|
||||
|
||||
/**
|
||||
* Calcule le délai avant le prochain essai selon le nombre de tentatives
|
||||
* Délai exponentiel : 1h, 4h, 24h
|
||||
*/
|
||||
function calculerProchainEssai(nbTentatives: number): Date {
|
||||
const maintenant = new Date();
|
||||
let delaiHeures = 1; // 1 heure par défaut
|
||||
|
||||
if (nbTentatives === 1) {
|
||||
delaiHeures = 1; // 1ère tentative échouée → réessayer dans 1h
|
||||
} else if (nbTentatives === 2) {
|
||||
delaiHeures = 4; // 2ème tentative échouée → réessayer dans 4h
|
||||
} else {
|
||||
delaiHeures = 24; // 3ème tentative échouée → réessayer dans 24h (mais on arrête à 3)
|
||||
}
|
||||
|
||||
maintenant.setHours(maintenant.getHours() + delaiHeures);
|
||||
return maintenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réessaye d'envoyer les rappels échoués
|
||||
*/
|
||||
export async function processRappelRetry() {
|
||||
console.log("[Rappels Retry] Démarrage du processus de réessai");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.error("[Rappels Retry] Base de données non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer les logs à réessayer
|
||||
const logsAReessayer = await getLogsAReessayer();
|
||||
|
||||
if (logsAReessayer.length === 0) {
|
||||
console.log("[Rappels Retry] Aucun rappel à réessayer");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Rappels Retry] ${logsAReessayer.length} rappel(s) à réessayer`);
|
||||
|
||||
let nbSucces = 0;
|
||||
let nbEchecs = 0;
|
||||
|
||||
for (const log of logsAReessayer) {
|
||||
try {
|
||||
// Récupérer les informations de la séquence
|
||||
const sequence = await db
|
||||
.select()
|
||||
.from(sequences)
|
||||
.where(eq(sequences.id, log.sequenceId))
|
||||
.limit(1);
|
||||
|
||||
if (sequence.length === 0) {
|
||||
console.warn(`[Rappels Retry] Séquence ${log.sequenceId} introuvable`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seq = sequence[0];
|
||||
|
||||
// Récupérer la formation
|
||||
const formation = await db
|
||||
.select()
|
||||
.from(formations)
|
||||
.where(eq(formations.id, seq.formationId))
|
||||
.limit(1);
|
||||
|
||||
if (formation.length === 0) {
|
||||
console.warn(`[Rappels Retry] Formation ${seq.formationId} introuvable`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer l'apprenant
|
||||
const apprenant = await db
|
||||
.select()
|
||||
.from(apprenants)
|
||||
.where(eq(apprenants.id, log.apprenantId))
|
||||
.limit(1);
|
||||
|
||||
if (apprenant.length === 0 || !apprenant[0].email) {
|
||||
console.warn(`[Rappels Retry] Apprenant ${log.apprenantId} introuvable ou sans email`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer les dates de la séquence
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, seq.id))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
// Récupérer le formateur si disponible
|
||||
let formateurNom = "";
|
||||
if (seq.formateurId) {
|
||||
const formateur = await db
|
||||
.select()
|
||||
.from(formateurs)
|
||||
.where(eq(formateurs.id, seq.formateurId))
|
||||
.limit(1);
|
||||
if (formateur.length > 0) {
|
||||
formateurNom = formateur[0].nom;
|
||||
}
|
||||
}
|
||||
|
||||
// Réessayer l'envoi
|
||||
try {
|
||||
if (log.typeRappel === "rappel") {
|
||||
await sendRappelJ7Email({
|
||||
apprenantEmail: apprenant[0].email,
|
||||
apprenantPrenom: apprenant[0].prenom,
|
||||
apprenantNom: apprenant[0].nom,
|
||||
apprenantFonction: apprenant[0].fonction,
|
||||
formationNom: formation[0].nom,
|
||||
sequenceNom: seq.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: seq.lieu || "",
|
||||
});
|
||||
} else if (log.typeRappel === "rappelJ1") {
|
||||
await sendRappelJ1Email({
|
||||
apprenantEmail: apprenant[0].email,
|
||||
apprenantPrenom: apprenant[0].prenom,
|
||||
apprenantNom: apprenant[0].nom,
|
||||
apprenantFonction: apprenant[0].fonction,
|
||||
formationNom: formation[0].nom,
|
||||
sequenceNom: seq.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: seq.lieu || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Succès : mettre à jour le log
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
statut: "success",
|
||||
nbTentatives: log.nbTentatives + 1,
|
||||
prochainEssai: null,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.log(`[Rappels Retry] Succès pour ${apprenant[0].email} après ${log.nbTentatives + 1} tentative(s)`);
|
||||
nbSucces++;
|
||||
} catch (emailError: any) {
|
||||
const messageErreur = emailError?.message || String(emailError);
|
||||
const nouvelleTentative = log.nbTentatives + 1;
|
||||
|
||||
if (nouvelleTentative >= 3) {
|
||||
// Abandon après 3 tentatives
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
statut: "failed",
|
||||
nbTentatives: nouvelleTentative,
|
||||
messageErreur: `Abandon après 3 tentatives: ${messageErreur}`,
|
||||
prochainEssai: null,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.error(`[Rappels Retry] Abandon pour ${apprenant[0].email} après 3 tentatives`);
|
||||
|
||||
// Notifier l'admin
|
||||
await notifyOwner({
|
||||
title: `❌ Abandon d'envoi de rappel`,
|
||||
content: `Le rappel pour ${apprenant[0].email} (${seq.nom}) a échoué 3 fois et a été abandonné.\\n\\nDernière erreur: ${messageErreur}`,
|
||||
});
|
||||
} else {
|
||||
// Planifier un nouvel essai
|
||||
const prochainEssai = calculerProchainEssai(nouvelleTentative);
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
nbTentatives: nouvelleTentative,
|
||||
messageErreur: messageErreur,
|
||||
prochainEssai: prochainEssai,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.log(`[Rappels Retry] Échec pour ${apprenant[0].email}, tentative ${nouvelleTentative}/3. Prochain essai: ${prochainEssai.toLocaleString()}`);
|
||||
}
|
||||
nbEchecs++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Rappels Retry] Erreur lors du traitement du log ${log.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Rappels Retry] Traitement terminé: ${nbSucces} succès, ${nbEchecs} échecs`);
|
||||
} catch (error) {
|
||||
console.error("[Rappels Retry] Erreur lors du processus de retry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le scheduler de retry
|
||||
* Vérifie toutes les heures s'il y a des rappels à réessayer
|
||||
*/
|
||||
export function initRappelRetryScheduler() {
|
||||
console.log("[Rappels Retry] Initialisation du scheduler de réessai automatique");
|
||||
|
||||
// Exécuter immédiatement au démarrage
|
||||
processRappelRetry();
|
||||
|
||||
// Puis exécuter toutes les heures
|
||||
setInterval(() => {
|
||||
processRappelRetry();
|
||||
}, 60 * 60 * 1000); // 1 heure en millisecondes
|
||||
|
||||
console.log("[Rappels Retry] Scheduler de réessai initialisé - vérification toutes les heures");
|
||||
}
|
||||
294
deployment-package/source_server/rappelScheduler.ts
Normal file
294
deployment-package/source_server/rappelScheduler.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { getDb } from "./db";
|
||||
import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema";
|
||||
import { eq, and, gte, lte, sql, inArray } from "drizzle-orm";
|
||||
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
|
||||
import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb";
|
||||
import { notifyOwner } from "./_core/notification";
|
||||
import { processRemerciementsAutomatiques } from "./remerciementScheduler";
|
||||
|
||||
/**
|
||||
* Job automatique qui vérifie quotidiennement les séquences à venir
|
||||
* et envoie les rappels J-7 et J-1 aux apprenants inscrits
|
||||
*/
|
||||
export async function processRappelsAutomatiques() {
|
||||
console.log("[Rappels] Démarrage du traitement des rappels automatiques...");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.error("[Rappels] Base de données non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer tous les rappels actifs
|
||||
const rappelsActifs = await db
|
||||
.select()
|
||||
.from(rappels)
|
||||
.where(eq(rappels.actif, true));
|
||||
|
||||
console.log(`[Rappels] ${rappelsActifs.length} rappel(s) actif(s) trouvé(s)`);
|
||||
|
||||
for (const rappel of rappelsActifs) {
|
||||
await processRappel(rappel);
|
||||
}
|
||||
|
||||
console.log("[Rappels] Traitement terminé");
|
||||
} catch (error) {
|
||||
console.error("[Rappels] Erreur lors du traitement des rappels:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function processRappel(rappel: any) {
|
||||
console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`);
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
// Calculer la date cible selon le timing (pré ou post-formation)
|
||||
const dateCible = new Date();
|
||||
if (rappel.timing === 'pre_formation') {
|
||||
dateCible.setDate(dateCible.getDate() + rappel.joursAvant);
|
||||
} else {
|
||||
dateCible.setDate(dateCible.getDate() - rappel.joursAvant);
|
||||
}
|
||||
dateCible.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateCibleFin = new Date(dateCible);
|
||||
dateCibleFin.setHours(23, 59, 59, 999);
|
||||
|
||||
console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`);
|
||||
|
||||
// Vérifier si ce rappel est associé à des dates spécifiques
|
||||
const datesAssociees = rappel.dateFormationIds || [];
|
||||
const filtreDates = datesAssociees.length > 0;
|
||||
|
||||
console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`);
|
||||
|
||||
// Récupérer les dates de formation concernées
|
||||
let datesConcernees;
|
||||
if (filtreDates) {
|
||||
// Seulement les dates associées à ce rappel
|
||||
datesConcernees = await db
|
||||
.select({
|
||||
dateFormation: datesFormation,
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
formateur: formateurs,
|
||||
})
|
||||
.from(datesFormation)
|
||||
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
|
||||
.leftJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(datesFormation.id, datesAssociees),
|
||||
gte(datesFormation.dateDebut, dateCible),
|
||||
lte(datesFormation.dateDebut, dateCibleFin)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Toutes les dates correspondant à la date cible
|
||||
datesConcernees = await db
|
||||
.select({
|
||||
dateFormation: datesFormation,
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
formateur: formateurs,
|
||||
})
|
||||
.from(datesFormation)
|
||||
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
|
||||
.leftJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.where(
|
||||
and(
|
||||
gte(datesFormation.dateDebut, dateCible),
|
||||
lte(datesFormation.dateDebut, dateCibleFin)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`);
|
||||
|
||||
// Grouper les dates par séquence pour envoyer un seul email par apprenant
|
||||
const sequencesMap = new Map<number, { sequence: any; formation: any; formateur: any; dates: any[] }>();
|
||||
|
||||
for (const { dateFormation, sequence, formation, formateur } of datesConcernees) {
|
||||
if (!dateFormation || !sequence || !formation) continue;
|
||||
|
||||
if (!sequencesMap.has(sequence.id)) {
|
||||
sequencesMap.set(sequence.id, {
|
||||
sequence,
|
||||
formation,
|
||||
formateur,
|
||||
dates: [],
|
||||
});
|
||||
}
|
||||
sequencesMap.get(sequence.id)!.dates.push(dateFormation);
|
||||
}
|
||||
|
||||
console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`);
|
||||
|
||||
for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) {
|
||||
if (!sequence || !formation) continue;
|
||||
|
||||
// Récupérer toutes les dates de cette séquence pour l'email
|
||||
const toutesLesDates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, sequence.id))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
const formateurNom = formateur?.nom || "";
|
||||
|
||||
// Récupérer tous les apprenants inscrits à cette séquence
|
||||
const inscrits = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, sequence.id),
|
||||
eq(inscriptions.statut, "confirmee")
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`[Rappels] ${inscrits.length} apprenant(s) inscrit(s) à la séquence ${sequence.nom}`);
|
||||
|
||||
// Envoyer le rappel à chaque apprenant
|
||||
let nbEnvoyes = 0;
|
||||
let nbEchecs = 0;
|
||||
const echecs: Array<{ email: string; erreur: string }> = [];
|
||||
|
||||
for (const { apprenant } of inscrits) {
|
||||
if (!apprenant || !apprenant.email) continue;
|
||||
|
||||
// Vérifier si le rappel a déjà été envoyé
|
||||
const dejaEnvoye = await rappelDejaEnvoye(rappel.id, sequence.id, apprenant.id);
|
||||
if (dejaEnvoye) {
|
||||
console.log(`[Rappels] Rappel déjà envoyé à ${apprenant.email} pour la séquence ${sequence.nom} - ignoré`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Choisir la fonction d'envoi selon le type de rappel
|
||||
if (rappel.templateType === "rappel") {
|
||||
// Rappel J-7
|
||||
await sendRappelJ7Email({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantPrenom: apprenant.prenom,
|
||||
apprenantNom: apprenant.nom,
|
||||
apprenantFonction: apprenant.fonction,
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: toutesLesDates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: sequence.lieu || "",
|
||||
formateur: formateurNom,
|
||||
attachmentUrl: rappel.urlFichier || undefined,
|
||||
attachmentFilename: rappel.nomFichier || undefined,
|
||||
attachmentMimeType: rappel.typeFichier || undefined,
|
||||
});
|
||||
console.log(`[Rappels] Email J-7 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
|
||||
} else if (rappel.templateType === "rappelJ1") {
|
||||
// Rappel J-1
|
||||
await sendRappelJ1Email({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantPrenom: apprenant.prenom,
|
||||
apprenantNom: apprenant.nom,
|
||||
apprenantFonction: apprenant.fonction,
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: toutesLesDates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: sequence.lieu || "",
|
||||
formateur: formateurNom,
|
||||
attachmentUrl: rappel.urlFichier || undefined,
|
||||
attachmentFilename: rappel.nomFichier || undefined,
|
||||
attachmentMimeType: rappel.typeFichier || undefined,
|
||||
});
|
||||
console.log(`[Rappels] Email J-1 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
|
||||
}
|
||||
|
||||
// Logger le succès
|
||||
await logRappelEnvoye({
|
||||
rappelId: rappel.id,
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
typeRappel: rappel.templateType,
|
||||
statut: "success",
|
||||
dateSequence: toutesLesDates[0].dateDebut,
|
||||
});
|
||||
nbEnvoyes++;
|
||||
} catch (emailError: any) {
|
||||
const messageErreur = emailError?.message || String(emailError);
|
||||
console.error(`[Rappels] Erreur lors de l'envoi à ${apprenant.email}:`, emailError);
|
||||
|
||||
// Logger l'échec
|
||||
await logRappelEnvoye({
|
||||
rappelId: rappel.id,
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
typeRappel: rappel.templateType,
|
||||
statut: "failed",
|
||||
messageErreur: messageErreur,
|
||||
dateSequence: toutesLesDates[0].dateDebut,
|
||||
});
|
||||
nbEchecs++;
|
||||
echecs.push({ email: apprenant.email, erreur: messageErreur });
|
||||
}
|
||||
}
|
||||
|
||||
// Notifier l'administrateur en cas d'échecs
|
||||
if (nbEchecs > 0) {
|
||||
const listeEchecs = echecs.map(e => `- ${e.email}: ${e.erreur}`).join("\n");
|
||||
await notifyOwner({
|
||||
title: `⚠️ Échecs d'envoi de rappels`,
|
||||
content: `${nbEchecs} rappel(s) n'ont pas pu être envoyés pour la séquence "${sequence.nom}" (${formation.nom}):\n\n${listeEchecs}\n\nRappel: ${rappel.nom} (J-${rappel.joursAvant})\nEnvois réussis: ${nbEnvoyes}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Rappels] Séquence ${sequence.nom}: ${nbEnvoyes} envoi(s) réussi(s), ${nbEchecs} échec(s)`);
|
||||
}
|
||||
|
||||
// Mettre à jour la date de dernière exécution
|
||||
await db
|
||||
.update(rappels)
|
||||
.set({ derniereExecution: new Date() })
|
||||
.where(eq(rappels.id, rappel.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Rappels] Erreur lors du traitement du rappel ${rappel.nom}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le scheduler pour exécuter les rappels automatiques
|
||||
* Vérifie toutes les heures s'il y a des rappels à envoyer
|
||||
*/
|
||||
export function initRappelScheduler() {
|
||||
console.log("[Rappels] Initialisation du scheduler de rappels automatiques");
|
||||
|
||||
// Exécuter immédiatement au démarrage
|
||||
processRappelsAutomatiques();
|
||||
processRemerciementsAutomatiques();
|
||||
|
||||
// Puis exécuter toutes les heures
|
||||
setInterval(() => {
|
||||
processRappelsAutomatiques();
|
||||
processRemerciementsAutomatiques();
|
||||
}, 60 * 60 * 1000); // 1 heure en millisecondes
|
||||
|
||||
console.log("[Rappels] Scheduler initialisé - vérification toutes les heures (rappels + remerciements)");
|
||||
}
|
||||
351
deployment-package/source_server/remerciementScheduler.ts
Normal file
351
deployment-package/source_server/remerciementScheduler.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { getDb } from "./db";
|
||||
import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs, questionnaires, envoisQuestionnaires, logsNotifications } from "../drizzle/schema";
|
||||
import { eq, and, lte, sql } from "drizzle-orm";
|
||||
import { sendRemerciementPostFormation } from "./emailService";
|
||||
import { logNotification } from "./notificationLogsDb";
|
||||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Génère un token unique pour accéder au questionnaire
|
||||
*/
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère ou crée un lien questionnaire pour un apprenant
|
||||
*/
|
||||
async function getOrCreateQuestionnaireLink(
|
||||
apprenantId: number,
|
||||
sequenceId: number
|
||||
): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
try {
|
||||
// Chercher un questionnaire de satisfaction actif
|
||||
const [questionnaire] = await db
|
||||
.select()
|
||||
.from(questionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(questionnaires.type, "satisfaction"),
|
||||
eq(questionnaires.actif, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!questionnaire) {
|
||||
console.log("[Remerciements] Aucun questionnaire de satisfaction actif trouvé");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si un envoi existe déjà
|
||||
const [envoiExistant] = await db
|
||||
.select()
|
||||
.from(envoisQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(envoisQuestionnaires.questionnaireId, questionnaire.id),
|
||||
eq(envoisQuestionnaires.apprenantId, apprenantId),
|
||||
eq(envoisQuestionnaires.sequenceId, sequenceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (envoiExistant) {
|
||||
// Retourner le lien existant
|
||||
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
|
||||
return `${baseUrl}/questionnaire/${envoiExistant.token}`;
|
||||
}
|
||||
|
||||
// Créer un nouvel envoi
|
||||
const token = generateToken();
|
||||
await db.insert(envoisQuestionnaires).values({
|
||||
questionnaireId: questionnaire.id,
|
||||
apprenantId,
|
||||
sequenceId,
|
||||
token,
|
||||
dateEnvoi: new Date(),
|
||||
dateReponse: null,
|
||||
});
|
||||
|
||||
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
|
||||
return `${baseUrl}/questionnaire/${token}`;
|
||||
} catch (error) {
|
||||
console.error("[Remerciements] Erreur lors de la création du lien questionnaire:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un remerciement a déjà été envoyé pour une inscription en vérifiant dans la base de données
|
||||
*/
|
||||
async function remerciementDejaEnvoye(apprenantId: number, sequenceId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: logsNotifications.id })
|
||||
.from(logsNotifications)
|
||||
.where(
|
||||
and(
|
||||
eq(logsNotifications.type, "remerciement"),
|
||||
eq(logsNotifications.apprenantId, apprenantId),
|
||||
eq(logsNotifications.sequenceId, sequenceId),
|
||||
eq(logsNotifications.statut, "success")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return !!existing;
|
||||
} catch (error) {
|
||||
console.error("[Remerciements] Erreur lors de la vérification du log:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job automatique qui vérifie quotidiennement les séquences terminées
|
||||
* et envoie les emails de remerciement aux apprenants
|
||||
*/
|
||||
export async function processRemerciementsAutomatiques() {
|
||||
console.log("[Remerciements] Démarrage du traitement des remerciements post-formation...");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.error("[Remerciements] Base de données non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const hier = new Date();
|
||||
hier.setDate(hier.getDate() - 1);
|
||||
hier.setHours(23, 59, 59, 999);
|
||||
|
||||
// Récupérer les séquences dont la dernière date est passée (terminées hier ou avant)
|
||||
const sequencesTerminees = await db
|
||||
.select({
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
formateur: formateurs,
|
||||
derniereDate: sql<Date>`MAX(${datesFormation.dateFin})`.as('derniereDate'),
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.innerJoin(datesFormation, eq(sequences.id, datesFormation.sequenceId))
|
||||
.where(eq(sequences.statut, 'ouverte')) // Séquences encore ouvertes (pas encore marquées terminées)
|
||||
.groupBy(sequences.id, formations.id, formateurs.id)
|
||||
.having(lte(sql`MAX(${datesFormation.dateFin})`, hier));
|
||||
|
||||
console.log(`[Remerciements] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s)`);
|
||||
|
||||
for (const { sequence, formation, formateur, derniereDate } of sequencesTerminees) {
|
||||
await processRemerciementSequence(sequence, formation, formateur, derniereDate);
|
||||
}
|
||||
|
||||
console.log("[Remerciements] Traitement terminé");
|
||||
} catch (error) {
|
||||
console.error("[Remerciements] Erreur lors du traitement des remerciements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function processRemerciementSequence(
|
||||
sequence: any,
|
||||
formation: any,
|
||||
formateur: any | null,
|
||||
derniereDate: Date
|
||||
) {
|
||||
console.log(`[Remerciements] Traitement de la séquence: ${sequence.nom} (terminée le ${derniereDate})`);
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
// Récupérer les inscriptions confirmées pour cette séquence
|
||||
const inscriptionsConfirmees = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, sequence.id),
|
||||
eq(inscriptions.statut, 'confirmee')
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`[Remerciements] ${inscriptionsConfirmees.length} inscription(s) confirmée(s) pour ${sequence.nom}`);
|
||||
|
||||
let envoyesCount = 0;
|
||||
let errorsCount = 0;
|
||||
|
||||
for (const { inscription, apprenant } of inscriptionsConfirmees) {
|
||||
// Vérifier si le remerciement a déjà été envoyé (vérification en base de données)
|
||||
const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequence.id);
|
||||
if (dejaEnvoye) {
|
||||
console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Générer le lien questionnaire
|
||||
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequence.id);
|
||||
|
||||
await sendRemerciementPostFormation({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantNom: apprenant.nom,
|
||||
apprenantPrenom: apprenant.prenom,
|
||||
apprenantFonction: apprenant.fonction,
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
formateurNom: formateur?.nom,
|
||||
lienQuestionnaire: lienQuestionnaire || undefined,
|
||||
});
|
||||
|
||||
// Logger la notification (sert aussi de marqueur pour éviter les doublons)
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "success",
|
||||
metadata: { lienQuestionnaire },
|
||||
});
|
||||
|
||||
envoyesCount++;
|
||||
console.log(`[Remerciements] Email envoyé à ${apprenant.email}`);
|
||||
} catch (error: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: error.message,
|
||||
});
|
||||
|
||||
errorsCount++;
|
||||
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Remerciements] Séquence ${sequence.nom}: ${envoyesCount} envoyé(s), ${errorsCount} erreur(s)`);
|
||||
|
||||
// Optionnel: Marquer la séquence comme terminée
|
||||
// await db.update(sequences).set({ statut: 'terminee' }).where(eq(sequences.id, sequence.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Remerciements] Erreur pour la séquence ${sequence.nom}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie manuellement un email de remerciement pour une séquence spécifique
|
||||
* Utilisé par l'interface admin pour envoyer les remerciements à la demande
|
||||
*/
|
||||
export async function envoyerRemerciementsSequence(sequenceId: number): Promise<{ sent: number; failed: number }> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Base de données non disponible");
|
||||
}
|
||||
|
||||
// Récupérer la séquence avec formation et formateur
|
||||
const sequenceData = await db
|
||||
.select({
|
||||
sequence: sequences,
|
||||
formation: formations,
|
||||
formateur: formateurs,
|
||||
})
|
||||
.from(sequences)
|
||||
.innerJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
|
||||
.where(eq(sequences.id, sequenceId))
|
||||
.limit(1);
|
||||
|
||||
if (sequenceData.length === 0) {
|
||||
throw new Error("Séquence introuvable");
|
||||
}
|
||||
|
||||
const { sequence, formation, formateur } = sequenceData[0];
|
||||
|
||||
// Récupérer les inscriptions confirmées
|
||||
const inscriptionsConfirmees = await db
|
||||
.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(
|
||||
and(
|
||||
eq(inscriptions.sequenceId, sequenceId),
|
||||
eq(inscriptions.statut, 'confirmee')
|
||||
)
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const { inscription, apprenant } of inscriptionsConfirmees) {
|
||||
// Vérifier si le remerciement a déjà été envoyé
|
||||
const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequenceId);
|
||||
if (dejaEnvoye) {
|
||||
console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}, ignoré`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Générer le lien questionnaire
|
||||
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequenceId);
|
||||
|
||||
await sendRemerciementPostFormation({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantNom: apprenant.nom,
|
||||
apprenantPrenom: apprenant.prenom,
|
||||
apprenantFonction: apprenant.fonction,
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
formateurNom: formateur?.nom,
|
||||
lienQuestionnaire: lienQuestionnaire || undefined,
|
||||
});
|
||||
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "success",
|
||||
metadata: { lienQuestionnaire },
|
||||
});
|
||||
|
||||
sent++;
|
||||
} catch (error: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: error.message,
|
||||
});
|
||||
|
||||
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed };
|
||||
}
|
||||
2380
deployment-package/source_server/routers.ts
Normal file
2380
deployment-package/source_server/routers.ts
Normal file
File diff suppressed because it is too large
Load Diff
102
deployment-package/source_server/storage.ts
Normal file
102
deployment-package/source_server/storage.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Preconfigured storage helpers for Manus WebDev templates
|
||||
// Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
|
||||
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
type StorageConfig = { baseUrl: string; apiKey: string };
|
||||
|
||||
function getStorageConfig(): StorageConfig {
|
||||
const baseUrl = ENV.forgeApiUrl;
|
||||
const apiKey = ENV.forgeApiKey;
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error(
|
||||
"Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
||||
}
|
||||
|
||||
function buildUploadUrl(baseUrl: string, relKey: string): URL {
|
||||
const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
|
||||
url.searchParams.set("path", normalizeKey(relKey));
|
||||
return url;
|
||||
}
|
||||
|
||||
async function buildDownloadUrl(
|
||||
baseUrl: string,
|
||||
relKey: string,
|
||||
apiKey: string
|
||||
): Promise<string> {
|
||||
const downloadApiUrl = new URL(
|
||||
"v1/storage/downloadUrl",
|
||||
ensureTrailingSlash(baseUrl)
|
||||
);
|
||||
downloadApiUrl.searchParams.set("path", normalizeKey(relKey));
|
||||
const response = await fetch(downloadApiUrl, {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(apiKey),
|
||||
});
|
||||
return (await response.json()).url;
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function normalizeKey(relKey: string): string {
|
||||
return relKey.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function toFormData(
|
||||
data: Buffer | Uint8Array | string,
|
||||
contentType: string,
|
||||
fileName: string
|
||||
): FormData {
|
||||
const blob =
|
||||
typeof data === "string"
|
||||
? new Blob([data], { type: contentType })
|
||||
: new Blob([data as any], { type: contentType });
|
||||
const form = new FormData();
|
||||
form.append("file", blob, fileName || "file");
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildAuthHeaders(apiKey: string): HeadersInit {
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
|
||||
export async function storagePut(
|
||||
relKey: string,
|
||||
data: Buffer | Uint8Array | string,
|
||||
contentType = "application/octet-stream"
|
||||
): Promise<{ key: string; url: string }> {
|
||||
const { baseUrl, apiKey } = getStorageConfig();
|
||||
const key = normalizeKey(relKey);
|
||||
const uploadUrl = buildUploadUrl(baseUrl, key);
|
||||
const formData = toFormData(data, contentType, key.split("/").pop() ?? key);
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(apiKey),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => response.statusText);
|
||||
throw new Error(
|
||||
`Storage upload failed (${response.status} ${response.statusText}): ${message}`
|
||||
);
|
||||
}
|
||||
const url = (await response.json()).url;
|
||||
return { key, url };
|
||||
}
|
||||
|
||||
export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> {
|
||||
const { baseUrl, apiKey } = getStorageConfig();
|
||||
const key = normalizeKey(relKey);
|
||||
return {
|
||||
key,
|
||||
url: await buildDownloadUrl(baseUrl, key, apiKey),
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Script de mise à jour rapide - Formation Manager Itinova
|
||||
# Version: 7a737c05
|
||||
# Auteur: Manus AI
|
||||
# Description: Script simplifié pour mettre à jour l'application en production
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# Couleurs
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
APP_DIR="/var/www/formation-manager-itinova"
|
||||
SERVICE_NAME="formation-manager"
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[OK]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERREUR]${NC} $1"
|
||||
}
|
||||
|
||||
# Vérifier les privilèges sudo
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
print_error "Ce script doit être exécuté avec sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " Mise à jour Formation Manager Itinova"
|
||||
echo " Version: 7a737c05"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 1. Arrêter le service
|
||||
print_info "Arrêt du service..."
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
print_success "Service arrêté"
|
||||
|
||||
# 2. Sauvegarder .env
|
||||
print_info "Sauvegarde de la configuration..."
|
||||
cp .env /tmp/.env.backup
|
||||
print_success "Configuration sauvegardée"
|
||||
|
||||
# 3. Mettre à jour le code
|
||||
print_info "Mise à jour du code..."
|
||||
git pull origin main
|
||||
print_success "Code mis à jour"
|
||||
|
||||
# 4. Installer les dépendances
|
||||
print_info "Installation des dépendances..."
|
||||
pnpm install
|
||||
print_success "Dépendances installées"
|
||||
|
||||
# 5. Appliquer les migrations
|
||||
print_info "Application des migrations..."
|
||||
pnpm db:push
|
||||
print_success "Migrations appliquées"
|
||||
|
||||
# 6. Build
|
||||
print_info "Compilation de l'application..."
|
||||
pnpm run build
|
||||
print_success "Application compilée"
|
||||
|
||||
# 7. Restaurer .env
|
||||
print_info "Restauration de la configuration..."
|
||||
cp /tmp/.env.backup .env
|
||||
print_success "Configuration restaurée"
|
||||
|
||||
# 8. Redémarrer le service
|
||||
print_info "Redémarrage du service..."
|
||||
systemctl start "$SERVICE_NAME"
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
print_success "Service redémarré"
|
||||
else
|
||||
print_error "Échec du redémarrage"
|
||||
systemctl status "$SERVICE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
print_success "Mise à jour terminée avec succès !"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
print_info "Vérifiez les logs: sudo journalctl -u $SERVICE_NAME -f"
|
||||
281
fix-all-database-schema-FINAL.sh
Executable file
281
fix-all-database-schema-FINAL.sh
Executable file
@@ -0,0 +1,281 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de correction COMPLÈTE et DÉFINITIVE du schéma de base de données
|
||||
# Formation Manager Itinova - Janvier 2026
|
||||
# Ce script corrige TOUTES les colonnes manquantes identifiées
|
||||
|
||||
DB_USER="app_user"
|
||||
DB_PASS="Itinova69!"
|
||||
DB_NAME="formation_manager"
|
||||
|
||||
echo "=========================================="
|
||||
echo "CORRECTION DÉFINITIVE DU SCHÉMA"
|
||||
echo "Formation Manager Itinova"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "⚠️ Ce script va MODIFIER la base de données"
|
||||
echo "Appuyez sur Entrée pour continuer ou Ctrl+C pour annuler..."
|
||||
read
|
||||
|
||||
echo ""
|
||||
echo "=== DÉBUT DES CORRECTIONS ==="
|
||||
echo ""
|
||||
|
||||
# Fonction pour exécuter une requête SQL avec gestion d'erreur
|
||||
execute_sql() {
|
||||
local description="$1"
|
||||
local query="$2"
|
||||
echo "→ $description"
|
||||
result=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "$query" 2>&1 | grep -v "Warning: Using a password")
|
||||
if [ -z "$result" ]; then
|
||||
echo " ✓ OK"
|
||||
return 0
|
||||
else
|
||||
if echo "$result" | grep -q "Duplicate column name"; then
|
||||
echo " ⚠ Colonne existe déjà"
|
||||
return 0
|
||||
elif echo "$result" | grep -q "Can't DROP"; then
|
||||
echo " ⚠ Colonne n'existe pas"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Erreur: $result"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=========================================="
|
||||
echo "1. CORRECTION DE LA TABLE formateurs"
|
||||
echo "=========================================="
|
||||
|
||||
# CRITIQUE: La colonne email manque dans formateurs
|
||||
execute_sql "Ajout de la colonne email" \
|
||||
"ALTER TABLE formateurs ADD COLUMN email VARCHAR(320) AFTER nom;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "2. CORRECTION DE LA TABLE rappels"
|
||||
echo "=========================================="
|
||||
|
||||
# CRITIQUE: La colonne timing manque dans rappels
|
||||
execute_sql "Ajout de la colonne timing" \
|
||||
"ALTER TABLE rappels ADD COLUMN timing ENUM('pre_formation', 'post_formation') NOT NULL DEFAULT 'pre_formation' AFTER heureEnvoi;"
|
||||
|
||||
execute_sql "Ajout de la colonne nomFichier" \
|
||||
"ALTER TABLE rappels ADD COLUMN nomFichier VARCHAR(255) AFTER timing;"
|
||||
|
||||
execute_sql "Ajout de la colonne urlFichier" \
|
||||
"ALTER TABLE rappels ADD COLUMN urlFichier VARCHAR(500) AFTER nomFichier;"
|
||||
|
||||
execute_sql "Ajout de la colonne s3Key" \
|
||||
"ALTER TABLE rappels ADD COLUMN s3Key VARCHAR(500) AFTER urlFichier;"
|
||||
|
||||
execute_sql "Ajout de la colonne typeFichier" \
|
||||
"ALTER TABLE rappels ADD COLUMN typeFichier VARCHAR(100) AFTER s3Key;"
|
||||
|
||||
execute_sql "Ajout de la colonne tailleFichier" \
|
||||
"ALTER TABLE rappels ADD COLUMN tailleFichier INT AFTER typeFichier;"
|
||||
|
||||
execute_sql "Ajout de la colonne derniereExecution" \
|
||||
"ALTER TABLE rappels ADD COLUMN derniereExecution DATETIME AFTER actif;"
|
||||
|
||||
execute_sql "Modification de l'ENUM templateType" \
|
||||
"ALTER TABLE rappels MODIFY COLUMN templateType ENUM('rappel', 'rappelJ1', 'rappel3', 'rappel4', 'rappel5', 'rappel6') NOT NULL;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "3. CORRECTION DE LA TABLE sequences"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Modification de l'ENUM statut" \
|
||||
"ALTER TABLE sequences MODIFY COLUMN statut ENUM('ouverte','bloquee','terminee','fermee','annulee') NOT NULL DEFAULT 'ouverte';"
|
||||
|
||||
execute_sql "Modification de l'ENUM publicCible" \
|
||||
"ALTER TABLE sequences MODIFY COLUMN publicCible ENUM('directeur','chef_service','autre','tous') NOT NULL DEFAULT 'tous';"
|
||||
|
||||
execute_sql "Modification de la taille de lieu" \
|
||||
"ALTER TABLE sequences MODIFY COLUMN lieu VARCHAR(255) NOT NULL;"
|
||||
|
||||
execute_sql "Modification de la taille de qrCodeToken" \
|
||||
"ALTER TABLE sequences MODIFY COLUMN qrCodeToken VARCHAR(100) UNIQUE;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "4. CORRECTION DE LA TABLE formations"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Ajout de la colonne modeAttestation" \
|
||||
"ALTER TABLE formations ADD COLUMN modeAttestation ENUM('auto', 'manuel') NOT NULL DEFAULT 'auto' AFTER actif;"
|
||||
|
||||
execute_sql "Ajout de la colonne modeEnvoi" \
|
||||
"ALTER TABLE formations ADD COLUMN modeEnvoi ENUM('auto', 'manuel') NOT NULL DEFAULT 'manuel' AFTER modeAttestation;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "5. CORRECTION DE LA TABLE users"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Ajout de la colonne username" \
|
||||
"ALTER TABLE users ADD COLUMN username VARCHAR(100) UNIQUE AFTER email;"
|
||||
|
||||
execute_sql "Ajout de la colonne password" \
|
||||
"ALTER TABLE users ADD COLUMN password VARCHAR(255) AFTER username;"
|
||||
|
||||
execute_sql "Ajout de la colonne isActive" \
|
||||
"ALTER TABLE users ADD COLUMN isActive BOOLEAN NOT NULL DEFAULT TRUE AFTER role;"
|
||||
|
||||
execute_sql "Ajout de la colonne formateurId" \
|
||||
"ALTER TABLE users ADD COLUMN formateurId INT AFTER isActive;"
|
||||
|
||||
execute_sql "Modification de l'ENUM role" \
|
||||
"ALTER TABLE users MODIFY COLUMN role ENUM('user', 'admin', 'formateur') NOT NULL DEFAULT 'user';"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "6. CORRECTION DE LA TABLE apprenants"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Modification de codeEtablissement" \
|
||||
"ALTER TABLE apprenants MODIFY COLUMN codeEtablissement VARCHAR(50) NULL;"
|
||||
|
||||
execute_sql "Modification de fonction" \
|
||||
"ALTER TABLE apprenants MODIFY COLUMN fonction VARCHAR(100) NULL;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "7. CORRECTION DE LA TABLE emailConfig"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Modification de l'ENUM provider" \
|
||||
"ALTER TABLE emailConfig MODIFY COLUMN provider ENUM('resend', 'smtp', 'simulation') NOT NULL DEFAULT 'simulation';"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "8. CORRECTION DE LA TABLE emailTemplates"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Modification de l'ENUM type" \
|
||||
"ALTER TABLE emailTemplates MODIFY COLUMN type ENUM('inscription', 'teaser', 'rappel', 'rappelJ1', 'rappel3', 'rappel4', 'rappel5', 'rappel6') NOT NULL UNIQUE;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "9. CORRECTION DE LA TABLE logsRappels"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Ajout de la colonne email" \
|
||||
"ALTER TABLE logsRappels ADD COLUMN email VARCHAR(320) NOT NULL AFTER apprenantId;"
|
||||
|
||||
execute_sql "Modification de l'ENUM type" \
|
||||
"ALTER TABLE logsRappels MODIFY COLUMN type ENUM('rappel', 'rappelJ1', 'rappel3', 'rappel4', 'rappel5', 'rappel6') NOT NULL;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "10. CORRECTION DE LA TABLE questionnaires"
|
||||
echo "=========================================="
|
||||
|
||||
# Vérifier si la colonne nom existe et la renommer en titre
|
||||
result=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='questionnaires' AND COLUMN_NAME='nom';" 2>/dev/null)
|
||||
|
||||
if [ "$result" = "1" ]; then
|
||||
execute_sql "Renommage de nom en titre" \
|
||||
"ALTER TABLE questionnaires CHANGE COLUMN nom titre VARCHAR(255) NOT NULL;"
|
||||
else
|
||||
echo "→ La colonne titre existe déjà"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "11. CORRECTION DE LA TABLE reponsesQuestionnaires"
|
||||
echo "=========================================="
|
||||
|
||||
# Vérifier si la colonne complete existe et la supprimer
|
||||
result=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='reponsesQuestionnaires' AND COLUMN_NAME='complete';" 2>/dev/null)
|
||||
|
||||
if [ "$result" = "1" ]; then
|
||||
execute_sql "Suppression de la colonne complete" \
|
||||
"ALTER TABLE reponsesQuestionnaires DROP COLUMN complete;"
|
||||
else
|
||||
echo "→ La colonne complete n'existe pas (OK)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "12. CORRECTION DE LA TABLE attestations"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Ajout de la colonne documentUrl" \
|
||||
"ALTER TABLE attestations ADD COLUMN documentUrl VARCHAR(500) AFTER s3Key;"
|
||||
|
||||
execute_sql "Ajout de la colonne documentS3Key" \
|
||||
"ALTER TABLE attestations ADD COLUMN documentS3Key VARCHAR(500) AFTER documentUrl;"
|
||||
|
||||
execute_sql "Ajout de la colonne uploadedBy" \
|
||||
"ALTER TABLE attestations ADD COLUMN uploadedBy INT AFTER documentS3Key;"
|
||||
|
||||
execute_sql "Ajout de la colonne uploadedAt" \
|
||||
"ALTER TABLE attestations ADD COLUMN uploadedAt TIMESTAMP AFTER uploadedBy;"
|
||||
|
||||
execute_sql "Ajout de la colonne emailEnvoye" \
|
||||
"ALTER TABLE attestations ADD COLUMN emailEnvoye BOOLEAN NOT NULL DEFAULT FALSE AFTER uploadedAt;"
|
||||
|
||||
execute_sql "Ajout de la colonne dateEnvoiEmail" \
|
||||
"ALTER TABLE attestations ADD COLUMN dateEnvoiEmail TIMESTAMP AFTER emailEnvoye;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "13. CORRECTION DE LA TABLE parametres"
|
||||
echo "=========================================="
|
||||
|
||||
# Supprimer envoiAutomatiqueAttestations si elle existe
|
||||
result=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='parametres' AND COLUMN_NAME='envoiAutomatiqueAttestations';" 2>/dev/null)
|
||||
|
||||
if [ "$result" = "1" ]; then
|
||||
execute_sql "Suppression de la colonne envoiAutomatiqueAttestations" \
|
||||
"ALTER TABLE parametres DROP COLUMN envoiAutomatiqueAttestations;"
|
||||
else
|
||||
echo "→ La colonne envoiAutomatiqueAttestations n'existe pas (OK)"
|
||||
fi
|
||||
|
||||
execute_sql "Ajout de la colonne urlPublique" \
|
||||
"ALTER TABLE parametres ADD COLUMN urlPublique VARCHAR(500) NOT NULL DEFAULT 'https://formations.itinova.org' AFTER id;"
|
||||
|
||||
execute_sql "Ajout de la colonne delaiExpirationQR" \
|
||||
"ALTER TABLE parametres ADD COLUMN delaiExpirationQR INT NOT NULL DEFAULT 30 AFTER urlPublique;"
|
||||
|
||||
execute_sql "Ajout de la colonne dureeValiditeToken" \
|
||||
"ALTER TABLE parametres ADD COLUMN dureeValiditeToken INT NOT NULL DEFAULT 24 AFTER delaiExpirationQR;"
|
||||
|
||||
execute_sql "Ajout de la colonne notificationsActives" \
|
||||
"ALTER TABLE parametres ADD COLUMN notificationsActives BOOLEAN NOT NULL DEFAULT TRUE AFTER dureeValiditeToken;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "14. CORRECTION DES AUTRES TABLES"
|
||||
echo "=========================================="
|
||||
|
||||
execute_sql "Ajout de userId dans passwordResetTokens" \
|
||||
"ALTER TABLE passwordResetTokens ADD COLUMN userId INT NOT NULL AFTER id;"
|
||||
|
||||
execute_sql "Ajout de ordre dans datesFormation" \
|
||||
"ALTER TABLE datesFormation ADD COLUMN ordre INT NOT NULL AFTER sequenceId;"
|
||||
|
||||
execute_sql "Ajout de validateurId dans presences" \
|
||||
"ALTER TABLE presences ADD COLUMN validateurId INT AFTER modeValidation;"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ TOUTES LES CORRECTIONS APPLIQUÉES"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Compter les tables
|
||||
total_tables=$(mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -se "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='$DB_NAME';" 2>/dev/null)
|
||||
|
||||
echo "✓ Nombre total de tables: $total_tables"
|
||||
echo ""
|
||||
echo "=== PROCHAINES ÉTAPES ==="
|
||||
echo "1. Redémarrer l'application: pm2 restart formation-manager"
|
||||
echo "2. Vérifier les logs: pm2 logs formation-manager --lines 30"
|
||||
echo "3. Tester l'application dans le navigateur"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
BIN
formation-manager-itinova-20260113_043521.tar.gz
Normal file
BIN
formation-manager-itinova-20260113_043521.tar.gz
Normal file
Binary file not shown.
1
formation-manager-itinova-20260113_043521.tar.gz.sha256
Normal file
1
formation-manager-itinova-20260113_043521.tar.gz.sha256
Normal file
@@ -0,0 +1 @@
|
||||
e406b674c739c15d5f7eb50042fb115a19c7ce4961c612d14ace016a9575fdee formation-manager-itinova-20260113_043521.tar.gz
|
||||
Binary file not shown.
BIN
formation-manager-itinova-update-20260113_082603.tar.gz
Normal file
BIN
formation-manager-itinova-update-20260113_082603.tar.gz
Normal file
Binary file not shown.
BIN
formation-manager-update.tar.gz
Normal file
BIN
formation-manager-update.tar.gz
Normal file
Binary file not shown.
79
migrations/README.md
Normal file
79
migrations/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Migrations SQL
|
||||
|
||||
Ce répertoire contient les migrations SQL versionnées pour la base de données Formation Manager Itinova.
|
||||
|
||||
## Format des fichiers
|
||||
|
||||
Les fichiers de migration doivent suivre ce format de nommage :
|
||||
```
|
||||
YYYYMMDD-description.sql
|
||||
```
|
||||
|
||||
Exemples :
|
||||
- `20260113-fix-schema.sql`
|
||||
- `20260120-add-notifications.sql`
|
||||
- `20260201-update-rappels.sql`
|
||||
|
||||
## Ordre d'application
|
||||
|
||||
Les migrations sont appliquées dans l'ordre alphabétique (donc chronologique grâce au format de date).
|
||||
|
||||
## Syntaxe
|
||||
|
||||
Utilisez la syntaxe MySQL compatible avec les versions 5.7+.
|
||||
|
||||
Pour les opérations ALTER TABLE, utilisez `IF NOT EXISTS` ou `IF EXISTS` quand c'est supporté, ou gérez les erreurs dans le script de mise à jour.
|
||||
|
||||
## Exemple de migration
|
||||
|
||||
```sql
|
||||
-- Migration YYYYMMDD: Description courte
|
||||
-- Description détaillée de ce que fait cette migration
|
||||
|
||||
-- Ajouter une nouvelle table
|
||||
CREATE TABLE IF NOT EXISTS nouvelle_table (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nom VARCHAR(255) NOT NULL,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Modifier une table existante
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS nouveau_champ VARCHAR(100);
|
||||
|
||||
-- Ajouter un index
|
||||
ALTER TABLE users
|
||||
ADD INDEX IF NOT EXISTS idx_users_nouveau_champ (nouveau_champ);
|
||||
|
||||
-- Insérer des données par défaut
|
||||
INSERT IGNORE INTO nouvelle_table (nom) VALUES ('Valeur par défaut');
|
||||
```
|
||||
|
||||
## Suivi des migrations
|
||||
|
||||
Le script de mise à jour crée automatiquement une table `schema_migrations` qui enregistre :
|
||||
- La version (nom du fichier sans .sql)
|
||||
- La date d'application
|
||||
- Une description
|
||||
|
||||
## Créer une nouvelle migration
|
||||
|
||||
1. Créez un fichier avec le format de nommage correct
|
||||
2. Écrivez votre migration SQL
|
||||
3. Testez-la sur une base de données de développement
|
||||
4. Incluez-la dans le package de déploiement
|
||||
|
||||
## Rollback
|
||||
|
||||
Pour annuler une migration, créez une nouvelle migration qui fait l'opération inverse.
|
||||
|
||||
Exemple : Si `20260113-add-column.sql` ajoute une colonne, créez `20260114-remove-column.sql` qui la supprime.
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
1. **Toujours tester** sur une base de développement avant de déployer
|
||||
2. **Sauvegarder** la base de données avant d'appliquer des migrations (fait automatiquement par le script)
|
||||
3. **Être idempotent** : la migration doit pouvoir être exécutée plusieurs fois sans erreur
|
||||
4. **Documenter** : ajouter des commentaires expliquant le but de chaque modification
|
||||
5. **Atomique** : chaque migration doit être une unité logique de changement
|
||||
6. **Rétrocompatible** : éviter de supprimer des colonnes utilisées par l'ancienne version
|
||||
141
scripts/create-package.sh
Executable file
141
scripts/create-package.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
|
||||
#############################################################################
|
||||
# Script de création de package - Formation Manager Itinova
|
||||
# Version: 2.0
|
||||
#
|
||||
# Ce script crée un package tar.gz prêt pour le déploiement incluant:
|
||||
# - Le code compilé
|
||||
# - Les dépendances
|
||||
# - Les migrations SQL
|
||||
# - Les scripts de déploiement
|
||||
# - La documentation
|
||||
#############################################################################
|
||||
|
||||
set -e # Arrêter en cas d'erreur
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/home/ubuntu/formation-manager-itinova"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
PACKAGE_NAME="formation-manager-itinova-${TIMESTAMP}.tar.gz"
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
|
||||
echo -e "${BLUE}=========================================${NC}"
|
||||
echo -e "${BLUE} Création du package de déploiement${NC}"
|
||||
echo -e "${BLUE}=========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. Vérifier que nous sommes dans le bon répertoire
|
||||
echo -e "${BLUE}[1/8]${NC} Vérification du répertoire..."
|
||||
cd "$PROJECT_DIR"
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo -e "${YELLOW}Erreur: package.json non trouvé${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Répertoire vérifié"
|
||||
|
||||
# 2. Compiler le projet
|
||||
echo -e "${BLUE}[2/8]${NC} Compilation du projet..."
|
||||
if command -v pnpm &> /dev/null; then
|
||||
pnpm build
|
||||
else
|
||||
npm run build
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Projet compilé"
|
||||
|
||||
# 3. Créer la structure dans le répertoire temporaire
|
||||
echo -e "${BLUE}[3/8]${NC} Préparation de la structure..."
|
||||
mkdir -p "$TEMP_DIR"/{dist,migrations,scripts,node_modules}
|
||||
echo -e "${GREEN}✓${NC} Structure créée"
|
||||
|
||||
# 4. Copier les fichiers nécessaires
|
||||
echo -e "${BLUE}[4/8]${NC} Copie des fichiers..."
|
||||
|
||||
# Code compilé
|
||||
cp -r dist/* "$TEMP_DIR/dist/"
|
||||
|
||||
# Migrations SQL
|
||||
if [ -d "migrations" ]; then
|
||||
cp -r migrations/* "$TEMP_DIR/migrations/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Scripts de déploiement
|
||||
cp scripts/update-production.sh "$TEMP_DIR/scripts/"
|
||||
chmod +x "$TEMP_DIR/scripts/update-production.sh"
|
||||
|
||||
# Fichiers de configuration
|
||||
cp package.json "$TEMP_DIR/"
|
||||
cp pnpm-lock.yaml "$TEMP_DIR/" 2>/dev/null || cp package-lock.json "$TEMP_DIR/" 2>/dev/null || true
|
||||
|
||||
# Fichier .env.example (pas le .env réel pour la sécurité)
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example "$TEMP_DIR/"
|
||||
elif [ -f ".env" ]; then
|
||||
# Créer un .env.example depuis .env en masquant les valeurs sensibles
|
||||
sed 's/=.*/=/' .env > "$TEMP_DIR/.env.example"
|
||||
fi
|
||||
|
||||
# Documentation
|
||||
if [ -f "README.md" ]; then
|
||||
cp README.md "$TEMP_DIR/"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓${NC} Fichiers copiés"
|
||||
|
||||
# 5. Installer les dépendances de production
|
||||
echo -e "${BLUE}[5/8]${NC} Installation des dépendances de production..."
|
||||
cd "$TEMP_DIR"
|
||||
if command -v pnpm &> /dev/null; then
|
||||
pnpm install --prod --frozen-lockfile 2>/dev/null || pnpm install --prod
|
||||
else
|
||||
npm install --production
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Dépendances installées"
|
||||
|
||||
# 6. Créer un fichier VERSION
|
||||
echo -e "${BLUE}[6/8]${NC} Création du fichier VERSION..."
|
||||
cat > "$TEMP_DIR/VERSION" << EOF
|
||||
Version: ${TIMESTAMP}
|
||||
Date: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
Git commit: $(cd "$PROJECT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "N/A")
|
||||
EOF
|
||||
echo -e "${GREEN}✓${NC} Fichier VERSION créé"
|
||||
|
||||
# 7. Créer le package tar.gz
|
||||
echo -e "${BLUE}[7/8]${NC} Création de l'archive..."
|
||||
cd "$TEMP_DIR"
|
||||
tar -czf "$PROJECT_DIR/$PACKAGE_NAME" .
|
||||
echo -e "${GREEN}✓${NC} Archive créée"
|
||||
|
||||
# 8. Nettoyer
|
||||
echo -e "${BLUE}[8/8]${NC} Nettoyage..."
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo -e "${GREEN}✓${NC} Nettoyage terminé"
|
||||
|
||||
# Afficher les informations du package
|
||||
echo ""
|
||||
echo -e "${GREEN}=========================================${NC}"
|
||||
echo -e "${GREEN} Package créé avec succès !${NC}"
|
||||
echo -e "${GREEN}=========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Nom du fichier: ${BLUE}$PACKAGE_NAME${NC}"
|
||||
echo -e "Emplacement: ${BLUE}$PROJECT_DIR/$PACKAGE_NAME${NC}"
|
||||
echo -e "Taille: ${BLUE}$(du -h "$PROJECT_DIR/$PACKAGE_NAME" | cut -f1)${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Contenu du package:${NC}"
|
||||
tar -tzf "$PROJECT_DIR/$PACKAGE_NAME" | head -20
|
||||
echo ""
|
||||
echo -e "${YELLOW}Instructions de déploiement:${NC}"
|
||||
echo -e "1. Transférer le package sur le serveur:"
|
||||
echo -e " ${BLUE}scp $PACKAGE_NAME user@serveur:/tmp/${NC}"
|
||||
echo ""
|
||||
echo -e "2. Se connecter au serveur et exécuter:"
|
||||
echo -e " ${BLUE}cd /var/www/formation-manager-itinova${NC}"
|
||||
echo -e " ${BLUE}./scripts/update-production.sh /tmp/$PACKAGE_NAME${NC}"
|
||||
echo ""
|
||||
242
scripts/update-from-package.sh
Executable file
242
scripts/update-from-package.sh
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/bin/bash
|
||||
# Script de mise à jour depuis package tar.gz
|
||||
# Formation Manager Itinova
|
||||
# Ce script met à jour l'application depuis un package tar.gz (sans Git)
|
||||
|
||||
set -e # Arrêter en cas d'erreur
|
||||
|
||||
# Couleurs pour les messages
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "========================================="
|
||||
echo "Mise à jour Formation Manager Itinova"
|
||||
echo "Depuis package tar.gz"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Vérifier que nous sommes dans le bon répertoire
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo -e "${RED}Erreur: package.json non trouvé.${NC}"
|
||||
echo -e "${RED}Ce script doit être exécuté depuis /var/www/formation-manager-itinova${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Vérifier que le fichier .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
echo -e "${RED}Erreur: fichier .env non trouvé.${NC}"
|
||||
echo -e "${RED}La configuration de l'application est manquante.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Demander le chemin du package
|
||||
echo -e "${BLUE}Veuillez spécifier le chemin complet du package tar.gz :${NC}"
|
||||
read -p "Chemin du package (ex: /tmp/formation-manager-itinova-*.tar.gz): " PACKAGE_PATH
|
||||
|
||||
if [ ! -f "$PACKAGE_PATH" ]; then
|
||||
echo -e "${RED}Erreur: Le fichier $PACKAGE_PATH n'existe pas.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Package trouvé: $PACKAGE_PATH${NC}"
|
||||
echo ""
|
||||
|
||||
# Confirmation
|
||||
read -p "Voulez-vous continuer avec la mise à jour ? (oui/non): " CONFIRM
|
||||
if [ "$CONFIRM" != "oui" ]; then
|
||||
echo "Mise à jour annulée."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}1. Sauvegarde de la base de données...${NC}"
|
||||
|
||||
# Créer un répertoire de backup s'il n'existe pas
|
||||
mkdir -p backups
|
||||
BACKUP_FILE="backups/db-backup-$(date +%Y%m%d-%H%M%S).sql"
|
||||
|
||||
# Charger les variables d'environnement
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
# Extraire les informations de connexion depuis DATABASE_URL
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo -e "${RED}Erreur: DATABASE_URL n'est pas définie dans .env${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parser DATABASE_URL (format: mysql://user:password@host:port/database)
|
||||
DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
|
||||
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')
|
||||
|
||||
echo "Sauvegarde de la base de données $DB_NAME..."
|
||||
mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" --single-transaction --routines --triggers > "$BACKUP_FILE" 2>/dev/null || {
|
||||
echo -e "${YELLOW}Avertissement: Sauvegarde avec privilèges limités${NC}"
|
||||
mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_FILE" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
|
||||
echo -e "${GREEN}✓ Sauvegarde créée: $BACKUP_FILE${NC}"
|
||||
else
|
||||
echo -e "${RED}Erreur: La sauvegarde a échoué${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}2. Sauvegarde des fichiers actuels...${NC}"
|
||||
|
||||
BACKUP_DIR="backups/files-backup-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Sauvegarder les fichiers importants
|
||||
cp -r client "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp -r server "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp -r drizzle "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp -r shared "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp package.json "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp tsconfig.json "$BACKUP_DIR/" 2>/dev/null || true
|
||||
cp vite.config.ts "$BACKUP_DIR/" 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}✓ Fichiers sauvegardés dans: $BACKUP_DIR${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}3. Arrêt du service...${NC}"
|
||||
|
||||
# Détecter le gestionnaire de processus (systemd ou PM2)
|
||||
if systemctl is-active --quiet formation-manager 2>/dev/null; then
|
||||
echo "Arrêt du service systemd..."
|
||||
sudo systemctl stop formation-manager
|
||||
SERVICE_MANAGER="systemd"
|
||||
echo -e "${GREEN}✓ Service systemd arrêté${NC}"
|
||||
elif pm2 list | grep -q "formation-manager" 2>/dev/null; then
|
||||
echo "Arrêt du processus PM2..."
|
||||
pm2 stop formation-manager
|
||||
SERVICE_MANAGER="pm2"
|
||||
echo -e "${GREEN}✓ Processus PM2 arrêté${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Aucun service en cours d'exécution détecté${NC}"
|
||||
SERVICE_MANAGER="none"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}4. Extraction du nouveau code...${NC}"
|
||||
|
||||
# Créer un répertoire temporaire
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
echo "Extraction dans $TEMP_DIR..."
|
||||
|
||||
# Extraire le package
|
||||
tar -xzf "$PACKAGE_PATH" -C "$TEMP_DIR"
|
||||
|
||||
if [ ! -d "$TEMP_DIR/source" ]; then
|
||||
echo -e "${RED}Erreur: Le répertoire 'source' n'a pas été trouvé dans le package${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Package extrait${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}5. Mise à jour des fichiers...${NC}"
|
||||
|
||||
# Sauvegarder le fichier .env actuel
|
||||
cp .env .env.backup
|
||||
|
||||
# Copier les nouveaux fichiers (en excluant certains répertoires)
|
||||
rsync -av --delete \
|
||||
--exclude='node_modules' \
|
||||
--exclude='dist' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.backup' \
|
||||
--exclude='backups' \
|
||||
--exclude='.manus' \
|
||||
"$TEMP_DIR/source/" ./
|
||||
|
||||
# Restaurer le .env
|
||||
mv .env.backup .env
|
||||
|
||||
# Nettoyer le répertoire temporaire
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo -e "${GREEN}✓ Fichiers mis à jour${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}6. Installation des dépendances...${NC}"
|
||||
|
||||
# Vérifier si pnpm est installé
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
echo -e "${RED}Erreur: pnpm n'est pas installé${NC}"
|
||||
echo "Installation de pnpm..."
|
||||
npm install -g pnpm
|
||||
fi
|
||||
|
||||
pnpm install
|
||||
|
||||
echo -e "${GREEN}✓ Dépendances installées${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}7. Application des migrations de base de données...${NC}"
|
||||
|
||||
# Utiliser drizzle-kit push pour les mises à jour (ne recrée pas les tables existantes)
|
||||
echo "Synchronisation du schéma avec la base de données..."
|
||||
pnpm exec drizzle-kit push || {
|
||||
echo -e "${YELLOW}Avertissement: La synchronisation a échoué, tentative avec generate...${NC}"
|
||||
pnpm exec drizzle-kit generate
|
||||
}
|
||||
|
||||
echo -e "${GREEN}✓ Schéma de base de données synchronisé${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}8. Compilation du projet...${NC}"
|
||||
|
||||
pnpm build
|
||||
|
||||
echo -e "${GREEN}✓ Projet compilé${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}9. Redémarrage du service...${NC}"
|
||||
|
||||
if [ "$SERVICE_MANAGER" = "systemd" ]; then
|
||||
sudo systemctl start formation-manager
|
||||
sleep 3
|
||||
if systemctl is-active --quiet formation-manager; then
|
||||
echo -e "${GREEN}✓ Service systemd redémarré avec succès${NC}"
|
||||
else
|
||||
echo -e "${RED}Erreur: Le service n'a pas pu démarrer${NC}"
|
||||
echo "Consultez les logs avec: sudo journalctl -u formation-manager -n 50"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$SERVICE_MANAGER" = "pm2" ]; then
|
||||
pm2 start formation-manager
|
||||
echo -e "${GREEN}✓ Processus PM2 redémarré${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Aucun gestionnaire de service détecté.${NC}"
|
||||
echo -e "${YELLOW}Démarrez manuellement l'application avec: pnpm start${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}=========================================${NC}"
|
||||
echo -e "${GREEN}Mise à jour terminée avec succès !${NC}"
|
||||
echo -e "${GREEN}=========================================${NC}"
|
||||
echo ""
|
||||
echo "Sauvegardes créées :"
|
||||
echo " - Base de données: $BACKUP_FILE"
|
||||
echo " - Fichiers: $BACKUP_DIR"
|
||||
echo ""
|
||||
echo "Pour vérifier le statut :"
|
||||
if [ "$SERVICE_MANAGER" = "systemd" ]; then
|
||||
echo " sudo systemctl status formation-manager"
|
||||
echo " sudo journalctl -u formation-manager -f"
|
||||
elif [ "$SERVICE_MANAGER" = "pm2" ]; then
|
||||
echo " pm2 status"
|
||||
echo " pm2 logs formation-manager"
|
||||
fi
|
||||
echo ""
|
||||
445
scripts/update-production.sh
Executable file
445
scripts/update-production.sh
Executable file
@@ -0,0 +1,445 @@
|
||||
#!/bin/bash
|
||||
|
||||
#############################################################################
|
||||
# Script de mise à jour production - Formation Manager Itinova
|
||||
# Version: 2.0
|
||||
#
|
||||
# Ce script gère:
|
||||
# - Sauvegarde automatique de la base de données
|
||||
# - Sauvegarde des fichiers
|
||||
# - Extraction du package
|
||||
# - Application des migrations SQL versionnées
|
||||
# - Redémarrage du service
|
||||
# - Vérification post-déploiement
|
||||
#############################################################################
|
||||
|
||||
set -e # Arrêter en cas d'erreur
|
||||
set -u # Arrêter si variable non définie
|
||||
|
||||
# Couleurs pour les messages
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
APP_DIR="/var/www/formation-manager-itinova"
|
||||
BACKUP_DIR="$APP_DIR/backups"
|
||||
MIGRATIONS_DIR="$APP_DIR/migrations"
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
LOG_FILE="$APP_DIR/logs/update-$TIMESTAMP.log"
|
||||
|
||||
# Fonction pour afficher les messages
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[✓]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[⚠]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[✗]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Fonction pour vérifier les prérequis
|
||||
check_prerequisites() {
|
||||
log_info "Vérification des prérequis..."
|
||||
|
||||
# Vérifier que nous sommes dans le bon répertoire
|
||||
if [ ! -f "$APP_DIR/package.json" ]; then
|
||||
log_error "Erreur: package.json non trouvé dans $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Vérifier que le fichier .env existe
|
||||
if [ ! -f "$APP_DIR/.env" ]; then
|
||||
log_error "Erreur: fichier .env non trouvé"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Créer les répertoires nécessaires
|
||||
mkdir -p "$BACKUP_DIR" "$MIGRATIONS_DIR" "$(dirname "$LOG_FILE")"
|
||||
|
||||
log_success "Prérequis vérifiés"
|
||||
}
|
||||
|
||||
# Fonction pour charger les variables d'environnement
|
||||
load_env() {
|
||||
log_info "Chargement des variables d'environnement..."
|
||||
|
||||
# Charger le .env
|
||||
set -a
|
||||
source "$APP_DIR/.env"
|
||||
set +a
|
||||
|
||||
# Extraire les informations de connexion MySQL depuis DATABASE_URL
|
||||
# Format: mysql://user:password@host:port/database
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
log_error "DATABASE_URL non définie dans .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parser DATABASE_URL
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p')
|
||||
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')
|
||||
|
||||
if [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then
|
||||
log_error "Impossible de parser DATABASE_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Variables d'environnement chargées"
|
||||
}
|
||||
|
||||
# Fonction pour sauvegarder la base de données
|
||||
backup_database() {
|
||||
log_info "Sauvegarde de la base de données..."
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/db-backup-$TIMESTAMP.sql"
|
||||
|
||||
# Créer un fichier de configuration MySQL temporaire pour éviter le warning
|
||||
MYSQL_CONFIG=$(mktemp)
|
||||
cat > "$MYSQL_CONFIG" << EOF
|
||||
[client]
|
||||
user=$DB_USER
|
||||
password=$DB_PASS
|
||||
host=$DB_HOST
|
||||
port=$DB_PORT
|
||||
EOF
|
||||
|
||||
# Sauvegarder
|
||||
if mysqldump --defaults-extra-file="$MYSQL_CONFIG" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
"$DB_NAME" > "$BACKUP_FILE" 2>&1; then
|
||||
log_success "Sauvegarde créée: $BACKUP_FILE"
|
||||
else
|
||||
log_warning "Sauvegarde partielle (certains privilèges manquants)"
|
||||
# Ne pas arrêter le script, la sauvegarde partielle est acceptable
|
||||
fi
|
||||
|
||||
# Nettoyer
|
||||
rm -f "$MYSQL_CONFIG"
|
||||
|
||||
# Compresser la sauvegarde
|
||||
gzip "$BACKUP_FILE"
|
||||
log_success "Sauvegarde compressée: $BACKUP_FILE.gz"
|
||||
|
||||
# Garder seulement les 10 dernières sauvegardes
|
||||
ls -t "$BACKUP_DIR"/db-backup-*.sql.gz | tail -n +11 | xargs -r rm
|
||||
}
|
||||
|
||||
# Fonction pour sauvegarder les fichiers
|
||||
backup_files() {
|
||||
log_info "Sauvegarde des fichiers..."
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/files-backup-$TIMESTAMP.tar.gz"
|
||||
|
||||
# Sauvegarder les fichiers importants (exclure node_modules et dist)
|
||||
tar -czf "$BACKUP_FILE" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='dist' \
|
||||
--exclude='backups' \
|
||||
--exclude='logs' \
|
||||
-C "$APP_DIR" \
|
||||
. 2>/dev/null || true
|
||||
|
||||
log_success "Fichiers sauvegardés: $BACKUP_FILE"
|
||||
|
||||
# Garder seulement les 5 dernières sauvegardes de fichiers
|
||||
ls -t "$BACKUP_DIR"/files-backup-*.tar.gz | tail -n +6 | xargs -r rm
|
||||
}
|
||||
|
||||
# Fonction pour détecter le gestionnaire de processus
|
||||
detect_process_manager() {
|
||||
if systemctl is-active --quiet formation-manager 2>/dev/null; then
|
||||
echo "systemd"
|
||||
elif pm2 list | grep -q "formation-manager"; then
|
||||
echo "pm2"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour arrêter le service
|
||||
stop_service() {
|
||||
log_info "Arrêt du service..."
|
||||
|
||||
PROCESS_MANAGER=$(detect_process_manager)
|
||||
|
||||
case "$PROCESS_MANAGER" in
|
||||
systemd)
|
||||
sudo systemctl stop formation-manager
|
||||
log_success "Service arrêté (systemd)"
|
||||
;;
|
||||
pm2)
|
||||
pm2 stop formation-manager 2>/dev/null || true
|
||||
log_success "Service arrêté (PM2)"
|
||||
;;
|
||||
*)
|
||||
log_warning "Gestionnaire de processus non détecté, le service n'a pas été arrêté"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fonction pour extraire le package
|
||||
extract_package() {
|
||||
local PACKAGE_PATH="$1"
|
||||
|
||||
log_info "Extraction du package..."
|
||||
|
||||
if [ ! -f "$PACKAGE_PATH" ]; then
|
||||
log_error "Package non trouvé: $PACKAGE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Créer un répertoire temporaire
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
|
||||
# Extraire dans le répertoire temporaire
|
||||
tar -xzf "$PACKAGE_PATH" -C "$TEMP_DIR"
|
||||
|
||||
# Copier les nouveaux fichiers (préserver .env)
|
||||
rsync -av --exclude='.env' --exclude='backups' --exclude='logs' "$TEMP_DIR/" "$APP_DIR/"
|
||||
|
||||
# Nettoyer
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
log_success "Package extrait"
|
||||
}
|
||||
|
||||
# Fonction pour obtenir la version actuelle de la base de données
|
||||
get_current_db_version() {
|
||||
MYSQL_CONFIG=$(mktemp)
|
||||
cat > "$MYSQL_CONFIG" << EOF
|
||||
[client]
|
||||
user=$DB_USER
|
||||
password=$DB_PASS
|
||||
host=$DB_HOST
|
||||
port=$DB_PORT
|
||||
EOF
|
||||
|
||||
# Créer la table de versions si elle n'existe pas
|
||||
mysql --defaults-extra-file="$MYSQL_CONFIG" "$DB_NAME" << 'EOF' 2>/dev/null
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(50) PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT
|
||||
);
|
||||
EOF
|
||||
|
||||
# Obtenir la dernière version
|
||||
VERSION=$(mysql --defaults-extra-file="$MYSQL_CONFIG" "$DB_NAME" -sN << 'EOF' 2>/dev/null
|
||||
SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;
|
||||
EOF
|
||||
)
|
||||
|
||||
rm -f "$MYSQL_CONFIG"
|
||||
|
||||
echo "${VERSION:-0}"
|
||||
}
|
||||
|
||||
# Fonction pour appliquer les migrations
|
||||
apply_migrations() {
|
||||
log_info "Application des migrations..."
|
||||
|
||||
CURRENT_VERSION=$(get_current_db_version)
|
||||
log_info "Version actuelle de la base de données: $CURRENT_VERSION"
|
||||
|
||||
# Créer un fichier de configuration MySQL
|
||||
MYSQL_CONFIG=$(mktemp)
|
||||
cat > "$MYSQL_CONFIG" << EOF
|
||||
[client]
|
||||
user=$DB_USER
|
||||
password=$DB_PASS
|
||||
host=$DB_HOST
|
||||
port=$DB_PORT
|
||||
EOF
|
||||
|
||||
# Appliquer chaque migration non appliquée
|
||||
MIGRATIONS_APPLIED=0
|
||||
|
||||
for MIGRATION_FILE in "$MIGRATIONS_DIR"/*.sql; do
|
||||
if [ ! -f "$MIGRATION_FILE" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
MIGRATION_VERSION=$(basename "$MIGRATION_FILE" .sql)
|
||||
|
||||
# Vérifier si la migration a déjà été appliquée
|
||||
ALREADY_APPLIED=$(mysql --defaults-extra-file="$MYSQL_CONFIG" "$DB_NAME" -sN << EOF
|
||||
SELECT COUNT(*) FROM schema_migrations WHERE version = '$MIGRATION_VERSION';
|
||||
EOF
|
||||
)
|
||||
|
||||
if [ "$ALREADY_APPLIED" -eq 0 ]; then
|
||||
log_info "Application de la migration: $MIGRATION_VERSION"
|
||||
|
||||
# Appliquer la migration
|
||||
if mysql --defaults-extra-file="$MYSQL_CONFIG" "$DB_NAME" < "$MIGRATION_FILE" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
# Enregistrer la migration
|
||||
mysql --defaults-extra-file="$MYSQL_CONFIG" "$DB_NAME" << EOF
|
||||
INSERT INTO schema_migrations (version, description)
|
||||
VALUES ('$MIGRATION_VERSION', 'Migration appliquée automatiquement');
|
||||
EOF
|
||||
log_success "Migration $MIGRATION_VERSION appliquée avec succès"
|
||||
MIGRATIONS_APPLIED=$((MIGRATIONS_APPLIED + 1))
|
||||
else
|
||||
log_error "Échec de la migration $MIGRATION_VERSION"
|
||||
rm -f "$MYSQL_CONFIG"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_info "Migration $MIGRATION_VERSION déjà appliquée (ignorée)"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$MYSQL_CONFIG"
|
||||
|
||||
if [ $MIGRATIONS_APPLIED -eq 0 ]; then
|
||||
log_info "Aucune nouvelle migration à appliquer"
|
||||
else
|
||||
log_success "$MIGRATIONS_APPLIED migration(s) appliquée(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour installer les dépendances
|
||||
install_dependencies() {
|
||||
log_info "Installation des dépendances..."
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
if command -v pnpm &> /dev/null; then
|
||||
pnpm install --prod 2>&1 | tee -a "$LOG_FILE"
|
||||
else
|
||||
npm install --production 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
log_success "Dépendances installées"
|
||||
}
|
||||
|
||||
# Fonction pour démarrer le service
|
||||
start_service() {
|
||||
log_info "Démarrage du service..."
|
||||
|
||||
PROCESS_MANAGER=$(detect_process_manager)
|
||||
|
||||
case "$PROCESS_MANAGER" in
|
||||
systemd)
|
||||
sudo systemctl start formation-manager
|
||||
sleep 3
|
||||
if systemctl is-active --quiet formation-manager; then
|
||||
log_success "Service démarré (systemd)"
|
||||
else
|
||||
log_error "Échec du démarrage du service"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
pm2)
|
||||
pm2 restart formation-manager 2>&1 | tee -a "$LOG_FILE"
|
||||
sleep 3
|
||||
if pm2 list | grep -q "formation-manager.*online"; then
|
||||
log_success "Service démarré (PM2)"
|
||||
else
|
||||
log_error "Échec du démarrage du service"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_warning "Gestionnaire de processus non détecté, démarrage manuel requis"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fonction pour vérifier le service
|
||||
verify_service() {
|
||||
log_info "Vérification du service..."
|
||||
|
||||
# Attendre que le service démarre
|
||||
sleep 5
|
||||
|
||||
# Vérifier que le service répond
|
||||
if curl -f http://localhost:3000/ &>/dev/null; then
|
||||
log_success "Service opérationnel"
|
||||
else
|
||||
log_warning "Le service ne répond pas sur le port 3000"
|
||||
fi
|
||||
|
||||
# Afficher les derniers logs
|
||||
PROCESS_MANAGER=$(detect_process_manager)
|
||||
|
||||
case "$PROCESS_MANAGER" in
|
||||
systemd)
|
||||
log_info "Derniers logs:"
|
||||
sudo journalctl -u formation-manager -n 20 --no-pager | tee -a "$LOG_FILE"
|
||||
;;
|
||||
pm2)
|
||||
log_info "Derniers logs:"
|
||||
pm2 logs formation-manager --lines 20 --nostream | tee -a "$LOG_FILE"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fonction principale
|
||||
main() {
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " Mise à jour Formation Manager Itinova"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Demander le chemin du package
|
||||
if [ $# -eq 0 ]; then
|
||||
read -p "Chemin du package tar.gz: " PACKAGE_PATH
|
||||
else
|
||||
PACKAGE_PATH="$1"
|
||||
fi
|
||||
|
||||
# Vérifier les prérequis
|
||||
check_prerequisites
|
||||
|
||||
# Charger les variables d'environnement
|
||||
load_env
|
||||
|
||||
# Sauvegardes
|
||||
backup_database
|
||||
backup_files
|
||||
|
||||
# Arrêter le service
|
||||
stop_service
|
||||
|
||||
# Extraire le package
|
||||
extract_package "$PACKAGE_PATH"
|
||||
|
||||
# Appliquer les migrations
|
||||
apply_migrations
|
||||
|
||||
# Installer les dépendances
|
||||
install_dependencies
|
||||
|
||||
# Démarrer le service
|
||||
start_service
|
||||
|
||||
# Vérifier le service
|
||||
verify_service
|
||||
|
||||
echo ""
|
||||
log_success "Mise à jour terminée avec succès!"
|
||||
echo ""
|
||||
log_info "Logs complets: $LOG_FILE"
|
||||
log_info "Sauvegarde DB: $BACKUP_DIR/db-backup-$TIMESTAMP.sql.gz"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Exécuter
|
||||
main "$@"
|
||||
@@ -199,12 +199,7 @@ export async function getQuestionnaireStats(questionnaireId: number) {
|
||||
const reponsesResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(reponsesQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(reponsesQuestionnaires.complete, true)
|
||||
)
|
||||
);
|
||||
.where(eq(reponsesQuestionnaires.questionnaireId, questionnaireId));
|
||||
const totalReponses = reponsesResult[0]?.count || 0;
|
||||
|
||||
// Taux de réponse
|
||||
@@ -239,12 +234,7 @@ export async function getReponsesDetailleesQuestionnaire(questionnaireId: number
|
||||
reponsesQuestions,
|
||||
eq(reponsesQuestions.reponseQuestionnaireId, reponsesQuestionnaires.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
|
||||
eq(reponsesQuestionnaires.complete, true)
|
||||
)
|
||||
);
|
||||
.where(eq(reponsesQuestionnaires.questionnaireId, questionnaireId));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function exportQuestionnaireToExcel(questionnaireId: number): Promi
|
||||
// Feuille 1: Statistiques globales
|
||||
const statsSheet = workbook.addWorksheet('Statistiques');
|
||||
|
||||
statsSheet.addRow(['Questionnaire', questionnaire.nom]);
|
||||
statsSheet.addRow(['Questionnaire', questionnaire.titre]);
|
||||
statsSheet.addRow(['Type', questionnaire.type]);
|
||||
statsSheet.addRow(['']);
|
||||
statsSheet.addRow(['Nombre d\'envois', stats.totalEnvois]);
|
||||
@@ -110,7 +110,7 @@ export async function exportQuestionnaireToPDF(questionnaireId: number): Promise
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(questionnaire.nom, 20, yPos);
|
||||
doc.text(questionnaire.titre, 20, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Type
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function processEnvoisAutomatiques() {
|
||||
|
||||
for (const questionnaire of questionnairesAEnvoyer) {
|
||||
console.log(
|
||||
`[Questionnaires] Traitement du questionnaire "${questionnaire.nom}" (délai: J+${questionnaire.delaiEnvoiJours})`
|
||||
`[Questionnaires] Traitement du questionnaire "${questionnaire.titre}" (délai: J+${questionnaire.delaiEnvoiJours})`
|
||||
);
|
||||
|
||||
// Calculer la date cible (aujourd'hui - délai)
|
||||
@@ -198,7 +198,7 @@ export async function processEnvoisAutomatiques() {
|
||||
apprenant.apprenantEmail,
|
||||
apprenant.apprenantNom,
|
||||
apprenant.apprenantPrenom,
|
||||
questionnaire.nom,
|
||||
questionnaire.titre,
|
||||
seq.sequenceNom
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user