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