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:
Manus
2026-01-14 03:08:54 -05:00
parent 8ecb856191
commit b53801c463
94 changed files with 29353 additions and 2042 deletions

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@@ -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">

View File

@@ -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
View 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 ""

View File

@@ -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.

View File

@@ -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**

View File

@@ -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
================================================================================

View File

@@ -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
================================================================================

View File

@@ -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

View File

@@ -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 "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1768296151204,
"tag": "0000_clean_justice",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
import {} from "./schema";

View 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;

View 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

File diff suppressed because it is too large Load Diff

View 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");
});
});

View 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}`;
}

View 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,
};
}

View 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),
};
}

View 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;
}

View 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;
}

View 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 ?? "",
};

View 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"
});
}
}

View 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,
};
}

View 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);

View 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;
}

View 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;

View 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
*/

View 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;
}
}

View 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" });
}
});
}

View 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();

View 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',
};
}
}

View 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;
}),
});

View 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,
},
});
}),
);

View File

@@ -0,0 +1,6 @@
declare module "cookie" {
export function parse(
str: string,
options?: Record<string, unknown>
): Record<string, string>;
}

View 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;
}

View 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;

View 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;

View 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"));
});
}

View 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;
* }),
* });
* ```
*/

View 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;
}

View 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");
}

View 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 };
}

View 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;
}

View 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}`;
}

File diff suppressed because it is too large Load Diff

View 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,
},
};
}

File diff suppressed because it is too large Load Diff

View 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();
}

View 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);
}

View 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,
};
}

View 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'));
}

View 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;
}

View 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;
}

View 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}`,
});
}

View 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);
}

View 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;
}

View 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",
};

View 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'}`
};
}
}

View 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 };
}

View 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");
}
}

View 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;
}

View 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'));
}

View 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;
}

View 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
};
}

View 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 [];
}
}

View 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");
}

View 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)");
}

View 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 };
}

File diff suppressed because it is too large Load Diff

View 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),
};
}

View File

@@ -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
View 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 "=========================================="

Binary file not shown.

View File

@@ -0,0 +1 @@
e406b674c739c15d5f7eb50042fb115a19c7ce4961c612d14ace016a9575fdee formation-manager-itinova-20260113_043521.tar.gz

Binary file not shown.

79
migrations/README.md Normal file
View 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
View 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
View 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
View 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 "$@"

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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
);