Files
GTFRH/GTFRRH/project/backend/config/serv.js
2025-09-23 14:54:33 +02:00

1768 lines
61 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cors = require('cors');
const sql = require('mssql');
const axios = require('axios');
require('dotenv').config();
const app = express();
const PORT = 3002;
// Configuration base de données
const dbConfig = {
server: process.env.DB_SERVER,
database: process.env.DB_DATABASE,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true
}
};
// Configuration Microsoft OAuth
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
const REDIRECT_URI = 'http://localhost:5174';
const CLIENT_SECRET = 'F5G8Q~qWNzuMdghyIwTX20cAVjqAK4sz~1uEUaLB';
const GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0';
// Middleware
app.use(cors({
origin: ['http://localhost:5174', 'http://localhost:5173', 'http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Log de toutes les requêtes
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
if (req.method === 'POST' && req.path !== '/api/exchange-token') {
console.log('POST Body:', req.body);
}
next();
});
// Variable pour stocker la connexion et l'état du système
let pool = null;
let systemStatus = {
hasFormateurEmailColumn: false,
hasFormateurView: false,
canAccessFormateurView: false,
hasFormateurLocal: false,
operatingMode: 'unknown'
};
// Fonction pour se connecter à la base
async function connectDatabase() {
try {
pool = await sql.connect(dbConfig);
console.log('Base de données connectée (serveur RH)');
// Diagnostic automatique de la structure et permissions
await checkSystemStatus();
return true;
} catch (error) {
console.error('Erreur de connexion :', error.message);
return false;
}
}
// Fonction pour vérifier l'état complet du système
async function checkSystemStatus() {
try {
// 1. Vérifier si la colonne formateur_email_fk existe
const columnCheck = await pool.request().query(`
SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'declarations'
AND COLUMN_NAME = 'formateur_email_fk'
`);
systemStatus.hasFormateurEmailColumn = columnCheck.recordset[0].count > 0;
// 2. Vérifier si la vue Formateurs existe
const viewCheck = await pool.request().query(`
SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.VIEWS
WHERE TABLE_NAME = 'Formateurs'
`);
systemStatus.hasFormateurView = viewCheck.recordset[0].count > 0;
// 3. Tester l'accès à la vue Formateurs si elle existe
if (systemStatus.hasFormateurView) {
try {
await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [dbo].[Formateurs]`);
systemStatus.canAccessFormateurView = true;
console.log('✅ Accès à la vue Formateurs: OK (RH)');
} catch (error) {
systemStatus.canAccessFormateurView = false;
console.log('❌ Accès à la vue Formateurs: ERREUR (RH) -', error.message);
}
}
// 4. Vérifier si la table formateurs_local existe et est accessible
try {
await pool.request().query(`SELECT TOP 1 * FROM formateurs_local`);
systemStatus.hasFormateurLocal = true;
console.log('✅ Table formateurs_local: OK (RH)');
} catch (error) {
systemStatus.hasFormateurLocal = false;
console.log('❌ Table formateurs_local: non accessible (RH)');
}
// 5. Déterminer le mode de fonctionnement optimal
if (systemStatus.hasFormateurEmailColumn && systemStatus.canAccessFormateurView) {
systemStatus.operatingMode = 'new_with_view';
} else if (systemStatus.hasFormateurEmailColumn && systemStatus.hasFormateurLocal) {
systemStatus.operatingMode = 'new_with_local';
} else if (systemStatus.hasFormateurEmailColumn) {
systemStatus.operatingMode = 'new_email_only';
} else {
systemStatus.operatingMode = 'legacy_hash';
}
console.log('📊 État du système RH:');
console.log(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`);
console.log(` - Vue Formateurs: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
console.log(` - Accès vue Formateurs: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`);
console.log(` - Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
} catch (error) {
console.error('Erreur lors du diagnostic RH:', error.message);
systemStatus.operatingMode = 'legacy_hash';
}
}
// À ajouter dans votre serveur RH (server.js)
app.get('/api/debug-campus', async (req, res) => {
try {
// Vérifier les campus distincts dans la vue
const campusResult = await pool.request().query(`
SELECT DISTINCT Campus, COUNT(*) as nb_formateurs
FROM [dbo].[Formateurs]
GROUP BY Campus
ORDER BY Campus
`);
// Échantillon des formateurs pour voir la structure
const sampleResult = await pool.request().query(`
SELECT TOP 10
userPrincipalName,
displayName,
Campus,
surname,
givenname
FROM [dbo].[Formateurs]
ORDER BY Campus, displayName
`);
res.json({
success: true,
campus_distincts: campusResult.recordset,
echantillon_formateurs: sampleResult.recordset,
message: 'Diagnostic des campus dans la vue Formateurs'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Route de diagnostic qui fonctionne même sans accès à la vue distante
app.get('/api/debug-campus-local', async (req, res) => {
try {
let results = {
campusFromDeclarations: [],
campusFromFormateurs: [],
sampleFormateurs: []
};
// Campus trouvés dans les déclarations
try {
const declCampus = await pool.request().query(`
SELECT DISTINCT
ISNULL(formateur_email_fk, 'hash_' + CAST(formateur_numero AS VARCHAR)) as formateur_ref,
COUNT(*) as nb_declarations
FROM declarations
GROUP BY formateur_email_fk, formateur_numero
ORDER BY COUNT(*) DESC
`);
results.campusFromDeclarations = declCampus.recordset;
} catch (error) {
results.campusFromDeclarations = `Erreur: ${error.message}`;
}
// Essayer formateurs_local si accessible
try {
const formatLocal = await pool.request().query(`
SELECT TOP 10
userPrincipalName,
displayName,
Campus,
surname,
givenname
FROM formateurs_local
ORDER BY Campus, displayName
`);
results.campusFromFormateurs = formatLocal.recordset.map(f => f.Campus).filter(Boolean);
results.sampleFormateurs = formatLocal.recordset;
} catch (error) {
results.campusFromFormateurs = `Erreur formateurs_local: ${error.message}`;
}
res.json({
success: true,
results,
message: 'Diagnostic local des campus'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Fonction pour générer un hash reproductible depuis un email (mode legacy)
function generateHashFromEmail(email) {
let hash = 0;
for (let i = 0; i < email.length; i++) {
const char = email.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 10000 + 1000;
}
// Fonction pour formatter l'heure SQL
function formatSqlTime(timeValue) {
if (!timeValue) return null;
if (typeof timeValue === 'string') {
if (timeValue.match(/^\d{2}:\d{2}:\d{2}\.\d+$/)) {
return timeValue.substring(0, 5);
}
if (timeValue.match(/^\d{2}:\d{2}:\d{2}$/)) {
return timeValue.substring(0, 5);
}
if (timeValue.match(/^\d{2}:\d{2}$/)) {
return timeValue;
}
}
if (timeValue instanceof Date) {
const hours = timeValue.getHours().toString().padStart(2, '0');
const minutes = timeValue.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
return null;
}
// Fonction utilitaire pour récupérer le token
function getAccessToken(req) {
const headers = req.headers;
let accessToken = null;
console.log('\n=== DEBUG TOKEN EXTRACTION (RH) ===');
console.log('Headers reçus:', JSON.stringify(headers, null, 2));
// 1. Vérifier Authorization header
if (headers['authorization']) {
accessToken = headers['authorization'].replace(/^Bearer\s+/i, '').trim();
console.log('✅ Token trouvé dans Authorization header');
}
// 2. Vérifier x-access-token
if (!accessToken && headers['x-access-token']) {
accessToken = headers['x-access-token'].trim();
console.log('✅ Token trouvé dans x-access-token header');
}
// 3. Vérifier query param
if (!accessToken && req.query && req.query.token) {
accessToken = req.query.token.trim();
console.log('✅ Token trouvé dans query param ?token=');
}
// 4. Vérifier body
if (!accessToken && req.body && req.body.accessToken) {
accessToken = req.body.accessToken.trim();
console.log('✅ Token trouvé dans body');
}
if (accessToken) {
console.log(`🎫 Token extrait: Présent (${accessToken.substring(0, 25)}...)`);
} else {
console.warn('⚠️ Aucun token trouvé dans la requête');
}
console.log('=== FIN DEBUG TOKEN (RH) ===\n');
return accessToken;
}
async function getApplicationToken() {
try {
const response = await axios.post(
`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'https://graph.microsoft.com/.default'
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.access_token;
} catch (error) {
console.error('Erreur obtention token application:', error.response?.data || error.message);
throw new Error('Impossible d\'obtenir un token Microsoft');
}
}
/**
* Fonction générique pour appeler Graph API
*/
async function callGraph(url, accessToken, method = 'GET', data = null) {
try {
const config = {
method,
url,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
};
if (data && method === 'POST') config.data = data;
const response = await axios(config);
return response.data;
} catch (error) {
console.error('Erreur Graph API:', error.response?.data || error.message);
return null;
}
}
/**
* Vérifier si utilisateur appartient à un groupe
*/
async function isUserInGroup(userId, groupId, accessToken) {
try {
const url = `https://graph.microsoft.com/v1.0/users/${userId}/checkMemberGroups`;
const data = { groupIds: [groupId] };
const result = await callGraph(url, accessToken, 'POST', data);
if (result && result.value && result.value.includes(groupId)) {
const userInfo = await callGraph(
`https://graph.microsoft.com/v1.0/users/${userId}?$select=mail,userPrincipalName,department`,
accessToken
);
const userEmail = userInfo?.mail || userInfo?.userPrincipalName;
const userDepartment = userInfo?.department || '';
if (userDepartment.toLowerCase().includes('administratif') ||
userDepartment.toLowerCase().includes('administration') ||
userDepartment.toLowerCase().includes('informatique') ||
userDepartment.toLowerCase().includes('ressources humaines')) {
console.log(`✅ Utilisateur RH autorisé: ${userEmail} (Service: ${userDepartment})`);
return true;
}
console.log(`❌ Utilisateur pas dans le service administratif: ${userEmail} (Service: ${userDepartment})`);
}
const userInfo = await callGraph(
`https://graph.microsoft.com/v1.0/users/${userId}?$select=mail,userPrincipalName`,
accessToken
);
const userEmail = userInfo?.mail || userInfo?.userPrincipalName;
const authorizedUsers = ['adminensup@ensup.eu', 'klambert@ensup.eu'];
if (authorizedUsers.includes(userEmail)) {
console.log(`✅ Utilisateur autorisé spécifiquement: ${userEmail}`);
return true;
}
console.log(`❌ Utilisateur non autorisé: ${userEmail}`);
return false;
} catch (error) {
console.error('Erreur vérification groupe:', error);
return false;
}
}
/**
* Obtenir les membres d'un groupe avec filtrage par service
*/
async function getGroupMembers(groupId, accessToken, service = null, limit = null) {
const url = `https://graph.microsoft.com/v1.0/groups/${groupId}/members?$select=id,displayName,givenName,surname,mail,department,jobTitle`;
const result = await callGraph(url, accessToken);
if (!result || !result.value) {
return [];
}
let members = result.value;
if (service) {
members = members.filter(member =>
member.department && member.department.toLowerCase().includes(service.toLowerCase())
);
}
if (limit) {
members = members.slice(0, limit);
}
return members;
}
// ==================== ROUTES DE DIAGNOSTIC ====================
// Route de diagnostic complet
app.get('/api/diagnostic', async (req, res) => {
try {
await checkSystemStatus();
let recommendations = [];
switch (systemStatus.operatingMode) {
case 'new_with_view':
recommendations.push('✅ Système optimal - toutes les fonctionnalités disponibles');
break;
case 'new_with_local':
recommendations.push('⚠️ Fonctionne avec la table locale - pas d\'accès à la vue distante');
recommendations.push('💡 Vérifier les permissions sur HP-TO-O365 pour utiliser la vue');
break;
case 'new_email_only':
recommendations.push('⚠️ Mode dégradé - sauvegarde par email mais pas de détails formateurs');
recommendations.push('💡 Restaurer l\'accès à la vue Formateurs ou table formateurs_local');
break;
case 'legacy_hash':
recommendations.push('🔄 Mode compatibilité - utilise l\'ancien système de hash');
recommendations.push('💡 Appliquer la migration avec POST /api/migrate');
break;
}
res.json({
systemStatus,
recommendations,
currentMode: systemStatus.operatingMode,
serverType: 'RH'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Route pour appliquer la migration
app.post('/api/migrate', async (req, res) => {
try {
const steps = [];
// Étape 1: Ajouter la colonne si nécessaire
if (!systemStatus.hasFormateurEmailColumn) {
try {
await pool.request().query(`
ALTER TABLE [dbo].[declarations]
ADD [formateur_email_fk] [nvarchar](255) NULL
`);
steps.push('✅ Colonne formateur_email_fk ajoutée');
} catch (error) {
if (!error.message.includes('already exists')) {
throw error;
}
steps.push(' Colonne formateur_email_fk déjà existante');
}
}
// Étape 2: Créer un index
try {
await pool.request().query(`
CREATE NONCLUSTERED INDEX [IX_declarations_formateur_email_fk]
ON [dbo].[declarations] ([formateur_email_fk])
`);
steps.push('✅ Index créé');
} catch (error) {
if (error.message.includes('already exists')) {
steps.push(' Index déjà existant');
} else {
steps.push(`⚠️ Erreur index: ${error.message}`);
}
}
// Vérifier à nouveau l'état
await checkSystemStatus();
res.json({
success: true,
steps,
newStatus: systemStatus,
message: `Migration appliquée - Mode RH: ${systemStatus.operatingMode}`
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
message: 'Erreur lors de la migration'
});
}
});
// ==================== ROUTES EXISTANTES ====================
// Route de test
app.get('/api/test', (req, res) => {
res.json({
message: 'Le serveur RH fonctionne !',
timestamp: new Date().toISOString(),
systemStatus
});
});
// Route pour tester la base de données
app.get('/api/db-test', async (req, res) => {
try {
if (!pool) {
return res.status(500).json({ error: 'Base non connectée' });
}
const declarationsResult = await pool.request().query('SELECT COUNT(*) as total FROM declarations');
const rhResult = await pool.request().query('SELECT COUNT(*) as total FROM rh');
let formateurCount = 0;
try {
if (systemStatus.canAccessFormateurView) {
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [dbo].[Formateurs]');
formateurCount = formateurResult.recordset[0].total;
} else if (systemStatus.hasFormateurLocal) {
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM formateurs_local');
formateurCount = formateurResult.recordset[0].total;
}
} catch (error) {
console.log('Impossible de compter les formateurs:', error.message);
}
res.json({
message: 'Base RH OK',
declarations: declarationsResult.recordset[0].total,
utilisateurs_rh: rhResult.recordset[0].total,
formateurs: formateurCount,
mode: systemStatus.operatingMode
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Route pour mettre à jour le statut d'une déclaration
app.put('/api/declarations/:id/status', async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
await pool.request()
.input('id', sql.Int, id)
.input('status', sql.VarChar, status)
.query('UPDATE declarations SET status = @status WHERE id = @id');
res.json({ success: true, message: 'Statut mis à jour' });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Route pour récupérer les déclarations (ADAPTÉE AU NOUVEAU SYSTÈME)
app.get('/api/get_declarations', async (req, res) => {
try {
console.log(`Récupération des déclarations (mode RH: ${systemStatus.operatingMode})...`);
let result;
switch (systemStatus.operatingMode) {
case 'new_with_view':
// Avec vue Formateurs
result = await pool.request().query(`
SELECT
d.id,
d.utilisateur_id,
d.formateur_email_fk as formateur_email,
f.displayName as formateur_nom_complet,
f.surname as nom,
f.givenname as prenom,
f.Campus,
f.departement,
td.id as type_demande_id,
td.libelle as activityType,
d.date,
d.duree,
d.heure_debut,
d.heure_fin,
d.description,
d.status
FROM declarations d
INNER JOIN types_demandes td ON d.type_demande_id = td.id
LEFT JOIN [dbo].[Formateurs] f ON d.formateur_email_fk = f.userPrincipalName
ORDER BY d.date DESC
`);
break;
case 'new_with_local':
// Avec table formateurs_local
result = await pool.request().query(`
SELECT
d.id,
d.utilisateur_id,
d.formateur_email_fk as formateur_email,
f.displayName as formateur_nom_complet,
f.surname as nom,
f.givenname as prenom,
f.Campus,
f.departement,
td.id as type_demande_id,
td.libelle as activityType,
d.date,
d.duree,
d.heure_debut,
d.heure_fin,
d.description,
d.status
FROM declarations d
INNER JOIN types_demandes td ON d.type_demande_id = td.id
LEFT JOIN formateurs_local f ON d.formateur_email_fk = f.userPrincipalName
ORDER BY d.date DESC
`);
break;
case 'new_email_only':
// Sans jointure formateur
result = await pool.request().query(`
SELECT
d.id,
d.utilisateur_id,
d.formateur_email_fk as formateur_email,
td.id as type_demande_id,
td.libelle as activityType,
d.date,
d.duree,
d.heure_debut,
d.heure_fin,
d.description,
d.status
FROM declarations d
INNER JOIN types_demandes td ON d.type_demande_id = td.id
ORDER BY d.date DESC
`);
break;
case 'legacy_hash':
default:
// Ancien système avec hash
result = await pool.request().query(`
SELECT
d.id,
d.formateur_numero as utilisateur_id,
td.id as type_demande_id,
d.date,
d.duree,
d.description,
d.formateur_numero,
d.heure_debut,
d.heure_fin,
'pending' as status,
td.libelle as activityType
FROM declarations d
INNER JOIN types_demandes td ON d.type_demande_id = td.id
ORDER BY d.date DESC
`);
break;
}
console.log(`${result.recordset.length} déclarations récupérées`);
// Traitement selon le mode
let processedResults = [];
if (systemStatus.operatingMode.startsWith('new_')) {
// Nouveau système - données déjà enrichies par les jointures
processedResults = result.recordset.map(row => ({
id: row.id,
utilisateur_id: row.utilisateur_id,
formateur_email: row.formateur_email,
type_demande_id: row.type_demande_id,
date: row.date,
duree: row.duree,
description: row.description,
heure_debut: formatSqlTime(row.heure_debut),
heure_fin: formatSqlTime(row.heure_fin),
status: row.status || 'pending',
activityType: row.activityType,
// Informations formateur (peuvent être null si pas de jointure)
nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
prenom: row.prenom || '',
campus: row.Campus || 'Non défini',
formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu'
}));
} else {
// Ancien système - mapping manuel
const knownMappings = {
122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' }
};
const emailMappings = {
'oimer@ensup.eu': { nom: 'Oimer', prenom: 'Utilisateur', campus: 'Cergy' },
'admin@ensup.eu': { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
'adminensup@ensup.eu': { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
'klambert@ensup.eu': { nom: 'Lambert', prenom: 'Kevin', campus: 'SQY' }
};
processedResults = result.recordset.map(row => {
const formateurNumero = row.formateur_numero;
let formateurInfo = {
nom: `Formateur ${formateurNumero}`,
prenom: '',
campus: 'Non défini'
};
// 1. Vérifier les mappings directs
if (knownMappings[formateurNumero]) {
formateurInfo = knownMappings[formateurNumero];
} else {
// 2. Vérifier si c'est un hash d'email connu
for (const [email, info] of Object.entries(emailMappings)) {
const hash = generateHashFromEmail(email);
if (hash === formateurNumero) {
formateurInfo = info;
break;
}
}
}
return {
id: row.id,
utilisateur_id: row.utilisateur_id,
type_demande_id: row.type_demande_id,
date: row.date,
duree: row.duree,
description: row.description,
formateur_numero: row.formateur_numero,
heure_debut: formatSqlTime(row.heure_debut),
heure_fin: formatSqlTime(row.heure_fin),
status: row.status,
activityType: row.activityType,
nom: formateurInfo.nom,
prenom: formateurInfo.prenom,
campus: formateurInfo.campus
};
});
}
console.log('Déclarations traitées avec succès (RH)');
if (processedResults.length > 0) {
console.log('Exemple:', processedResults[0]);
}
res.json(processedResults);
} catch (error) {
console.error('Erreur get_declarations (RH):', error);
res.status(500).json({
error: error.message,
details: 'Erreur lors de la récupération des déclarations (serveur RH)'
});
}
});
// Route de debug pour voir quel hash correspond à votre email
app.get('/api/debug-hash', (req, res) => {
const { email } = req.query;
if (!email) {
return res.json({
error: 'Email requis',
example: 'http://localhost:3002/api/debug-hash?email=oimer@ensup.eu'
});
}
const hash = generateHashFromEmail(email);
res.json({
email: email,
hash: hash,
message: `L'email ${email} génère le numéro ${hash}`,
serverType: 'RH'
});
});
// Nouvelle route pour les formateurs avec déclarations (ADAPTÉE)
app.get('/api/formateurs-avec-declarations', async (req, res) => {
try {
let result;
switch (systemStatus.operatingMode) {
case 'new_with_view':
result = await pool.request().query(`
SELECT DISTINCT
f.givenname,
f.surname,
f.displayName,
f.Campus,
f.userPrincipalName,
f.Jobtitle,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM [dbo].[Formateurs] f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
AND d.id IS NOT NULL
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
ORDER BY f.surname, f.givenname
`);
break;
case 'new_with_local':
result = await pool.request().query(`
SELECT DISTINCT
f.givenname,
f.surname,
f.displayName,
f.Campus,
f.userPrincipalName,
f.Jobtitle,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM formateurs_local f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
AND d.id IS NOT NULL
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
ORDER BY f.surname, f.givenname
`);
break;
}
const formateurs = result.recordset.map(f => ({
userPrincipalName: f.userPrincipalName,
displayName: f.displayName,
nom: f.surname || '',
prenom: f.givenname || '',
campus: f.Campus || 'Non défini',
poste: f.Jobtitle || '',
contrat: f.Contrat || '',
nbDeclarations: f.nb_declarations,
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim()
}));
res.json({
success: true,
count: formateurs.length,
formateurs: formateurs,
mode: systemStatus.operatingMode,
filtre: 'CDD_avec_declarations'
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Route de compatibilité pour l'ancienne méthode (ADAPTÉE)
app.get('/api/formateurs-vue', async (req, res) => {
try {
console.log(`🔍 Récupération formateurs CDD seulement (mode: ${systemStatus.operatingMode})...`);
let formateurs = [];
let result; // Déclarer result ici !
if (systemStatus.canAccessFormateurView) {
console.log('Utilisation de la vue Formateurs...');
result = await pool.request().query(`
SELECT
userPrincipalName,
displayName,
surname,
givenname,
Campus,
Contrat
FROM [dbo].[Formateurs]
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
ORDER BY surname, givenname
`);
} else if (systemStatus.hasFormateurLocal) {
console.log('Utilisation de formateurs_local...');
result = await pool.request().query(`
SELECT
userPrincipalName,
displayName,
surname,
givenname,
Campus,
Contrat
FROM formateurs_local
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
ORDER BY surname, givenname
`);
} else {
console.log('Aucune source de formateurs disponible');
return res.json({
success: true,
count: 0,
formateurs: [],
mode: systemStatus.operatingMode,
message: 'Aucune source de formateurs disponible'
});
}
console.log(`Résultat requête: ${result.recordset.length} formateurs`);
if (result.recordset.length === 0) {
console.log('Aucun formateur CDD trouvé, test sans filtre...');
// Test sans le filtre CDD pour voir s'il y a des formateurs
let testResult;
if (systemStatus.hasFormateurLocal) {
testResult = await pool.request().query(`
SELECT TOP 5
userPrincipalName,
displayName,
surname,
givenname,
Campus,
Contrat
FROM formateurs_local
ORDER BY surname, givenname
`);
console.log('Test sans filtre:', testResult.recordset);
console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]);
}
}
formateurs = result.recordset.map(f => ({
userPrincipalName: f.userPrincipalName,
displayName: f.displayName,
nom: f.surname || '',
prenom: f.givenname || '',
campus: f.Campus || 'Non défini',
contrat: f.Contrat || '',
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim()
}));
console.log(`${formateurs.length} formateurs CDD traités`);
res.json({
success: true,
count: formateurs.length,
formateurs: formateurs,
mode: systemStatus.operatingMode,
filtre: 'CDD_uniquement'
});
} catch (error) {
console.error('Erreur récupération formateurs CDD:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// ==================== ROUTES MICROSOFT GRAPH (INCHANGÉES) ====================
app.post('/api/auth', async (req, res) => {
const { userPrincipalName } = req.body;
const accessToken = getAccessToken(req);
if (!userPrincipalName || !accessToken) {
return res.json({
authorized: false,
message: 'Email ou token manquant'
});
}
try {
const authResult = await authenticateUserWithGraph(userPrincipalName, accessToken);
res.json(authResult);
} catch (error) {
console.error('Erreur authentification:', error);
res.json({
authorized: false,
message: 'Erreur serveur: ' + error.message
});
}
});
/**
* Authentifier un utilisateur avec Microsoft Graph
*/
async function authenticateUserWithGraph(userPrincipalName, accessToken) {
try {
const existingUser = await pool.request()
.input('email', sql.VarChar, userPrincipalName)
.query('SELECT id,nom, prenom, email FROM rh WHERE email = @email');
if (existingUser.recordset.length > 0) {
const user = existingUser.recordset[0];
return {
authorized: true,
role: user.role || 'Collaborateur',
groups: [user.role || 'Collaborateur'],
localUserId: parseInt(user.id),
user: user
};
}
const userGraph = await callGraph(
`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`,
accessToken
);
if (!userGraph) {
throw new Error('Utilisateur introuvable dans Entra ou token invalide');
}
const isInTargetGroup = await isUserInGroup(userGraph.id, GROUP_ID, accessToken);
if (!isInTargetGroup) {
throw new Error('Utilisateur non autorisé : il n\'appartient pas au groupe requis');
}
const prenom = userGraph.givenName || '';
const nom = userGraph.surname || '';
const email = userGraph.mail || userPrincipalName;
const insertResult = await pool.request()
.input('nom', sql.VarChar, nom)
.input('prenom', sql.VarChar, prenom)
.input('email', sql.VarChar, email)
.query(`INSERT INTO rh (nom, prenom, email)
OUTPUT INSERTED.id
VALUES (@nom, @prenom, @email)`);
const newUserId = insertResult.recordset[0].id;
return {
authorized: true,
role: 'Collaborateur',
groups: ['Collaborateur'],
localUserId: parseInt(newUserId),
user: {
id: newUserId,
nom: nom,
prenom: prenom,
email: email,
role: 'Collaborateur'
}
};
} catch (error) {
console.error('Erreur authentification Microsoft Graph:', error);
throw error;
}
}
/**
* Route pour extraire 3 personnes du service administratif
*/
app.get('/api/admin-users', async (req, res) => {
console.log('=== Route /api/admin-users appelée (RH) ===');
const accessToken = getAccessToken(req);
console.log('Token reçu:', accessToken ? `Présent (${accessToken.substring(0, 20)}...)` : 'Absent');
try {
let users = [];
if (accessToken) {
try {
const adminUsers = await getGroupMembers(GROUP_ID, accessToken, 'administratif', 2);
users = adminUsers.map(user => ({
nom: user.surname || '',
prenom: user.givenName || '',
email: user.mail || ''
}));
console.log(`${users.length} utilisateurs AD récupérés`);
} catch (adError) {
console.error('Erreur récupération AD:', adError.message);
}
}
users.push({
nom: 'Lambert',
prenom: 'Kevin',
email: 'kevin.lambert@test.com'
});
const response = {
success: true,
count: users.length,
users: users
};
if (!accessToken) {
response.warning = 'Mode test - Token manquant, seules les données de test sont affichées';
}
res.json(response);
} catch (error) {
console.error('Erreur générale:', error);
res.json({
success: true,
count: 1,
users: [{
nom: 'Lambert',
prenom: 'Kevin',
email: 'kevin.lambert@test.com'
}],
error: error.message,
warning: 'Données de secours uniquement'
});
}
});
/**
* Route pour obtenir tous les membres du groupe
*/
app.get('/api/group-members', async (req, res) => {
console.log('=== Route /api/group-members appelée (RH) ===');
const accessToken = getAccessToken(req);
const service = req.query.service;
const limit = req.query.limit ? parseInt(req.query.limit) : null;
console.log('Paramètres reçus:', { service, limit });
console.log('Token reçu:', accessToken ? `Présent (${accessToken.substring(0, 20)}...)` : 'Absent');
try {
let members = [];
if (accessToken) {
try {
const adMembers = await getGroupMembers(GROUP_ID, accessToken, service, limit);
members = adMembers.map(m => ({
entraUserId: m.id,
prenom: m.givenName || '',
nom: m.surname || '',
email: m.mail || '',
service: m.department || '',
poste: m.jobTitle || '',
nomComplet: m.displayName || ''
}));
console.log(`${members.length} membres AD récupérés`);
} catch (adError) {
console.error('Erreur récupération AD:', adError.message);
}
}
if (members.length === 0) {
members = [
{ entraUserId: 'test-user-1', prenom: 'Kevin', nom: 'Lambert', email: 'kevin.lambert@test.com', service: service || 'Administratif', poste: 'Administrateur Test', nomComplet: 'Kevin Lambert' },
{ entraUserId: 'test-user-2', prenom: 'Marie', nom: 'Dubois', email: 'marie.dubois@test.com', service: service || 'Administratif', poste: 'Assistante RH Test', nomComplet: 'Marie Dubois' },
{ entraUserId: 'test-user-3', prenom: 'Jean', nom: 'Martin', email: 'jean.martin@test.com', service: service || 'Pédagogique', poste: 'Formateur Test', nomComplet: 'Jean Martin' }
];
if (limit && limit < members.length) members = members.slice(0, limit);
if (service) members = members.filter(m => m.service.toLowerCase().includes(service.toLowerCase()));
}
const response = {
success: true,
count: members.length,
members: members
};
if (!accessToken) {
response.warning = 'Mode test - Token manquant, seules les données de test sont affichées';
}
res.json(response);
} catch (error) {
console.error('Erreur générale:', error);
res.json({
success: true,
count: 1,
members: [{
entraUserId: 'test-user-fallback',
prenom: 'Kevin',
nom: 'Lambert',
email: 'kevin.lambert@test.com',
service: 'Administratif',
poste: 'Administrateur Test',
nomComplet: 'Kevin Lambert'
}],
error: error.message,
warning: 'Données de secours uniquement'
});
}
});
// Route de test à ajouter temporairement
app.get('/api/test-permissions', async (req, res) => {
try {
console.log('Test des permissions sur HP-TO-O365...');
// Test direct de la vue
const result = await pool.request().query(`
SELECT TOP 3
userPrincipalName,
displayName,
Campus
FROM [HP-TO-O365].[dbo].[V_Formateurs_Augmentees]
`);
res.json({
success: true,
message: 'Permissions OK !',
data: result.recordset,
count: result.recordset.length
});
} catch (error) {
console.error('Erreur test permissions:', error.message);
res.status(500).json({
success: false,
error: error.message,
message: 'Permissions insuffisantes'
});
}
});
// ==================== ROUTES TABLE RH ====================
app.post('/api/exchange-token', async (req, res) => {
try {
console.log('=== DÉBUT EXCHANGE TOKEN (RH) ===');
console.log('Body reçu:', req.body);
const { code, code_verifier } = req.body;
if (!code) {
return res.json({ success: false, message: 'Code manquant' });
}
const params = new URLSearchParams();
params.append('client_id', CLIENT_ID);
params.append('code', code);
params.append('redirect_uri', REDIRECT_URI);
params.append('grant_type', 'authorization_code');
params.append('scope', 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/User.Read');
if (code_verifier) {
params.append('code_verifier', code_verifier);
} else {
return res.json({ success: false, message: 'Code verifier manquant (PKCE requis)' });
}
console.log('Échange du code avec Microsoft (mode PKCE)...');
const response = await axios.post(
`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const { access_token } = response.data;
console.log('✅ Token obtenu avec succès');
const userInfo = await callGraph(
'https://graph.microsoft.com/v1.0/me?$select=id,displayName,givenName,surname,mail,userPrincipalName',
access_token
);
if (!userInfo) {
throw new Error('Impossible de récupérer les informations utilisateur');
}
console.log('Utilisateur récupéré:', userInfo.userPrincipalName);
const isAuthorized = await isUserInGroup(userInfo.id, GROUP_ID, access_token);
if (!isAuthorized) {
return res.json({ success: false, message: 'Utilisateur non autorisé - pas dans le groupe requis' });
}
console.log('✅ Utilisateur autorisé');
const email = userInfo.mail || userInfo.userPrincipalName;
const prenom = userInfo.givenName || '';
const nom = userInfo.surname || '';
if (!pool) {
throw new Error('Base de données non connectée');
}
let userResult = await pool.request()
.input('email', sql.VarChar, email)
.query('SELECT id FROM rh WHERE email = @email');
let userId;
if (userResult.recordset.length > 0) {
userId = userResult.recordset[0].id;
console.log('Utilisateur existant trouvé, ID:', userId);
} else {
const insertResult = await pool.request()
.input('nom', sql.VarChar, nom)
.input('prenom', sql.VarChar, prenom)
.input('email', sql.VarChar, email)
.query('INSERT INTO rh (nom, prenom, email) OUTPUT INSERTED.id VALUES (@nom, @prenom, @email)');
userId = insertResult.recordset[0].id;
console.log('Nouvel utilisateur créé, ID:', userId);
}
const userData = { id: userId, nom, prenom, email, role: 'Collaborateur' };
console.log('✅ Succès complet, retour des données');
res.json({
success: true,
accessToken: access_token,
user: userData
});
} catch (error) {
console.error('🚨 ERREUR DANS EXCHANGE TOKEN:');
console.error('Message:', error.message);
if (error.response) {
console.error('Erreur Microsoft:', error.response.status, error.response.data);
}
res.status(500).json({
success: false,
message: 'Erreur serveur: ' + error.message
});
}
});
app.post('/api/check-user-groups', async (req, res) => {
try {
const { userPrincipalName } = req.body;
const accessToken = getAccessToken(req);
if (!userPrincipalName || !accessToken) {
return res.json({
authorized: false,
message: "Email ou token manquant"
});
}
const existingUser = await pool.request()
.input('email', sql.VarChar, userPrincipalName)
.query('SELECT id, nom, prenom, email FROM rh WHERE email = @email');
if (existingUser.recordset.length > 0) {
const user = existingUser.recordset[0];
return res.json({
authorized: true,
role: 'Collaborateur',
groups: ['Collaborateur'],
localUserId: parseInt(user.id),
user: {
id: user.id,
prenom: user.prenom,
nom: user.nom,
email: user.email,
role: 'Collaborateur'
}
});
}
const userGraph = await callGraph(
`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`,
accessToken
);
if (!userGraph) {
return res.json({
authorized: false,
message: "Utilisateur introuvable dans Entra ou token invalide"
});
}
const isInTargetGroup = await isUserInGroup(userGraph.id, GROUP_ID, accessToken);
if (!isInTargetGroup) {
return res.json({
authorized: false,
message: "Utilisateur non autorisé : il n'appartient pas au groupe requis"
});
}
const prenom = userGraph.givenName || '';
const nom = userGraph.surname || '';
const email = userGraph.mail || userPrincipalName;
const insertResult = await pool.request()
.input('nom', sql.VarChar, nom)
.input('prenom', sql.VarChar, prenom)
.input('email', sql.VarChar, email)
.query(`
INSERT INTO rh (nom, prenom, email)
OUTPUT INSERTED.id
VALUES (@nom, @prenom, @email)
`);
const newUserId = insertResult.recordset[0].id;
res.json({
authorized: true,
role: 'Collaborateur',
groups: ['Collaborateur'],
localUserId: parseInt(newUserId),
user: {
id: newUserId,
nom: nom,
prenom: prenom,
email: email,
role: 'Collaborateur'
}
});
} catch (error) {
console.error('Erreur check-user-groups:', error);
res.json({
authorized: false,
message: 'Erreur serveur: ' + error.message
});
}
});
app.post('/api/initial-sync', async (req, res) => {
try {
console.log('🔄 Démarrage de la synchronisation initiale (RH)...');
const accessToken = await getApplicationToken();
const group = await callGraph(
`https://graph.microsoft.com/v1.0/groups/${GROUP_ID}?$select=id,displayName,description,mail,createdDateTime`,
accessToken
);
if (!group) {
return res.json({
success: false,
message: "Impossible de récupérer le groupe Ensup-Groupe"
});
}
const membersResponse = await callGraph(
`https://graph.microsoft.com/v1.0/groups/${GROUP_ID}/members?$select=id,givenName,surname,mail,department,jobTitle`,
accessToken
);
const members = membersResponse?.value || [];
let usersInserted = 0;
for (const member of members) {
const prenom = member.givenName || '';
const nom = member.surname || '';
const email = member.mail || '';
if (!email) continue;
try {
const existingUser = await pool.request()
.input('email', sql.VarChar, email)
.query('SELECT id FROM rh WHERE email = @email');
if (existingUser.recordset.length === 0) {
await pool.request()
.input('nom', sql.VarChar, nom)
.input('prenom', sql.VarChar, prenom)
.input('email', sql.VarChar, email)
.query('INSERT INTO rh (nom, prenom, email) VALUES (@nom, @prenom, @email)');
} else {
await pool.request()
.input('nom', sql.VarChar, nom)
.input('prenom', sql.VarChar, prenom)
.input('email', sql.VarChar, email)
.query('UPDATE rh SET nom = @nom, prenom = @prenom WHERE email = @email');
}
usersInserted++;
} catch (dbError) {
console.error(`Erreur insertion utilisateur ${email}:`, dbError.message);
}
}
res.json({
success: true,
message: "Synchronisation terminée",
groupe_sync: group.displayName,
users_sync: usersInserted
});
} catch (error) {
console.error('Erreur synchronisation:', error);
res.json({
success: false,
message: 'Erreur lors de la synchronisation: ' + error.message
});
}
});
app.post('/api/login-hybrid', async (req, res) => {
try {
const { email, mot_de_passe, entraUserId, userPrincipalName } = req.body;
const accessToken = getAccessToken(req);
if (accessToken && entraUserId) {
const userResult = await pool.request()
.input('email', sql.VarChar, email)
.query('SELECT * FROM rh WHERE email = @email');
if (userResult.recordset.length === 0) {
return res.json({
success: false,
message: "Utilisateur non autorisé (pas dans l'annuaire RH)"
});
}
const user = userResult.recordset[0];
const userGroups = [];
try {
const memberOfResponse = await callGraph(
`https://graph.microsoft.com/v1.0/users/${userPrincipalName}/memberOf?$select=id`,
accessToken
);
if (memberOfResponse?.value) {
memberOfResponse.value.forEach(g => {
if (g.id) userGroups.push(g.id);
});
}
} catch (graphError) {
console.error('Erreur récupération groupes:', graphError.message);
}
const authorized = userGroups.includes(GROUP_ID);
if (authorized) {
return res.json({
success: true,
message: "Connexion réussie via Azure AD",
user: {
id: user.id,
prenom: user.prenom,
nom: user.nom,
email: user.email,
role: 'Collaborateur'
}
});
} else {
return res.json({
success: false,
message: "Utilisateur non autorisé - pas dans le groupe requis"
});
}
}
if (email && mot_de_passe) {
const userResult = await pool.request()
.input('email', sql.VarChar, email)
.input('password', sql.VarChar, mot_de_passe)
.query(`
SELECT
u.ID,
u.Prenom,
u.Nom,
u.Email,
u.Role,
u.ServiceId,
s.Nom AS ServiceNom
FROM Users u
LEFT JOIN Services s ON u.ServiceId = s.Id
WHERE u.Email = @email AND u.MDP = @password
`);
if (userResult.recordset.length === 1) {
const user = userResult.recordset[0];
return res.json({
success: true,
message: "Connexion réussie (mode local)",
user: {
id: user.ID,
prenom: user.Prenom,
nom: user.Nom,
email: user.Email,
role: user.Role,
service: user.ServiceNom || 'Non défini'
}
});
} else {
return res.json({
success: false,
message: "Identifiants incorrects (mode local)"
});
}
}
res.json({
success: false,
message: "Aucune méthode de connexion fournie"
});
} catch (error) {
console.error('Erreur login-hybrid:', error);
res.json({
success: false,
message: 'Erreur serveur: ' + error.message
});
}
});
/**
* Route pour tester la table RH
*/
app.get('/api/rh-test', async (req, res) => {
try {
if (!pool) {
return res.status(500).json({ error: 'Base non connectée' });
}
const result = await pool.request().query('SELECT COUNT(*) as total FROM rh');
const sample = await pool.request().query('SELECT TOP 3 * FROM rh');
res.json({
message: 'Table RH OK',
total_utilisateurs: result.recordset[0].total,
echantillon: sample.recordset,
systemStatus: systemStatus
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* Route pour lister tous les utilisateurs de la table RH
*/
app.get('/api/rh-users', async (req, res) => {
try {
const limit = req.query.limit ? parseInt(req.query.limit) : 50;
const offset = req.query.offset ? parseInt(req.query.offset) : 0;
const result = await pool.request()
.input('limit', sql.Int, limit)
.input('offset', sql.Int, offset)
.query(`
SELECT id, nom, prenom, email
FROM rh
ORDER BY nom, prenom
OFFSET @offset ROWS
FETCH NEXT @limit ROWS ONLY
`);
const total = await pool.request().query('SELECT COUNT(*) as total FROM rh');
res.json({
success: true,
users: result.recordset,
total: total.recordset[0].total,
limit: limit,
offset: offset
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Démarrage du serveur
async function startServer() {
const dbConnected = await connectDatabase();
if (!dbConnected) {
console.log('Impossible de démarrer sans base de données');
return;
}
app.listen(PORT, () => {
console.log(`🚀 Serveur RH démarré sur http://localhost:${PORT}`);
console.log(`📊 Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
console.log('');
console.log('Routes disponibles :');
console.log('- GET /api/diagnostic (vérifier l\'état)');
console.log('- POST /api/migrate (appliquer la migration)');
console.log('- GET /api/test');
console.log('- GET /api/db-test');
console.log('- GET /api/get_declarations');
console.log('- PUT /api/declarations/:id/status');
console.log('- POST /api/exchange-token (Échange code Microsoft)');
console.log('- POST /api/auth (Microsoft Graph - legacy)');
console.log('- GET /api/admin-users (3 utilisateurs administratifs)');
console.log('- GET /api/group-members (tous les membres du groupe)');
console.log('- GET /api/formateurs-avec-declarations');
console.log('- GET /api/formateurs-vue');
console.log('- GET /api/rh-test (test table RH)');
console.log('- GET /api/rh-users (liste utilisateurs RH)');
console.log('');
switch (systemStatus.operatingMode) {
case 'new_with_view':
console.log('✅ Système optimal - utilise la vue Formateurs');
break;
case 'new_with_local':
console.log('⚠️ Mode dégradé - utilise la table formateurs_local');
console.log('💡 Conseil: Vérifier les permissions sur HP-TO-O365');
break;
case 'new_email_only':
console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs');
break;
case 'legacy_hash':
console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash');
console.log('💡 Conseil: Appliquer la migration avec POST /api/migrate');
break;
}
if (!CLIENT_SECRET) {
console.warn('⚠️ Variable d\'environnement manquante: CLIENT_SECRET');
console.warn(' Ajoutez CLIENT_SECRET dans votre fichier .env');
} else {
console.log('✅ Configuration Microsoft OAuth OK');
console.log(` Client ID: ${CLIENT_ID}`);
console.log(` Tenant ID: ${TENANT_ID}`);
console.log(` Redirect URI: ${REDIRECT_URI}`);
}
});
}
// Arrêt propre
process.on('SIGINT', async () => {
console.log('Arrêt du serveur RH...');
if (pool) {
await pool.close();
}
process.exit(0);
});
// Démarrer
startServer();