console.log('🚀 1. Démarrage du serveur...');
import express from 'express';
import cors from 'cors';
import sql from 'mssql';
import jwt from 'jsonwebtoken';
import { ConfidentialClientApplication } from '@azure/msal-node';
import axios from 'axios';
import dotenv from 'dotenv';
import crypto from 'crypto';
import multer from 'multer';
import { Client } from '@microsoft/microsoft-graph-client';
import 'isomorphic-fetch';
import PDFDocument from 'pdfkit';
import { PDFDocument as PDFLib } from 'pdf-lib';
import { generateFicheSignee, preparerLignesPDF }
from './ndfPdfGenerator.js';
console.log('✅ 2. Modules de base chargés');
dotenv.config();
console.log('✅ 3. Dotenv chargé');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const proxyCache = new Map();
const PROXY_TTL = 30 * 60 * 1000; // 10 → 30 min
const PROXY_MAX_SIZE = 200; // 50 → 200 entrées
function getCached(url) {
const entry = proxyCache.get(url);
if (!entry) return null;
if (Date.now() - entry.at > PROXY_TTL) { proxyCache.delete(url); return null; }
return entry;
}
function setCache(url, buffer, contentType) {
if (proxyCache.size >= PROXY_MAX_SIZE) {
const oldest = [...proxyCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
proxyCache.delete(oldest[0]);
}
proxyCache.set(url, { buffer, contentType, at: Date.now() });
}
const SHAREPOINT_CONFIG = {
siteId: process.env.SHAREPOINT_SITE_ID,
driveId: process.env.SHAREPOINT_DRIVE_ID,
basePath: 'Notes de Frais',
};
process.on('uncaughtException', (error) => {
console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌');
console.error(error);
console.error(error.stack);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌');
console.error('Raison:', reason);
});
process.on('exit', (code) => {
console.log(`\n⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code}\n`);
});
console.log('✅ 4. Handlers d\'erreurs installés');
const app = express();
console.log('✅ 5. Express initialisé');
const PORT = process.env.PORT || 3024;
console.log(`✅ 6. Port configuré: ${PORT}`);
app.use(cors({
origin: [
'http://myndf.ensup-adm.net',
'https://myndf.ensup-adm.net',
'http://localhost:3025',
'http://localhost:81'
],
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
console.log('✅ 7. Middlewares installés');
const dbConfig = {
server: process.env.DB_SERVER || '192.168.0.3',
user: process.env.DB_USER || 'ndf_app',
password: process.env.DB_PASSWORD || 'P@ssw0rd2026!',
database: process.env.DB_NAME || 'NDF',
port: parseInt(process.env.DB_PORT) || 1433,
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000,
useUTC: false
},
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 }
};
console.log('🔄 8. Test connexion SQL Server...');
let pool;
const AZURE_CONFIG = {
tenantId: process.env.AZURE_TENANT_ID,
clientId: process.env.AZURE_CLIENT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET,
groupId: process.env.AZURE_GROUP_ID || 'c1ea877c-6bca-4f47-bfad-f223640813a0'
};
async function initializeDatabase() {
try {
pool = await sql.connect(dbConfig);
console.log('✅ 9. Connexion SQL Server réussie');
console.log(' Server:', dbConfig.server);
console.log(' User:', dbConfig.user);
console.log(' Database:', dbConfig.database);
console.log(' Port:', dbConfig.port);
} catch (err) {
console.error('❌ 9. ERREUR CONNEXION SQL SERVER:', err.message);
throw err;
}
}
initializeDatabase().catch(err => {
console.error('❌ Impossible de démarrer le serveur:', err);
process.exit(1);
});
const msalConfig = {
auth: {
clientId: process.env.AZURE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
clientSecret: process.env.AZURE_CLIENT_SECRET
},
system: {
loggerOptions: {
loggerCallback(loglevel, message) {
if (process.env.NODE_ENV === 'development') console.log('[MSAL]', message);
},
piiLoggingEnabled: false,
logLevel: 'Info',
},
}
};
const BAREME_KM_SERVER = {
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
};
function getIndemniteKmServer(kmTotal, chevaux) {
const cv = Math.min(Math.max(parseInt(chevaux) || 7, 3), 7);
const b = BAREME_KM_SERVER[cv];
if (!b || kmTotal <= 0) return 0;
if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2));
if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2));
return parseFloat((kmTotal * b.t3).toFixed(2));
}
function normalizeCampus(campus) {
if (!campus) return null;
const c = campus.toUpperCase();
if (c.includes('SQY') || c.includes('SAINT') || c.includes('SQUY')) return 'SQY';
if (c.includes('CGY') || c.includes('CERGY')) return 'CGY';
if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS';
if (c.includes('NTE') || c.includes('NANTES')) return 'NTE';
return null;
}
function recalculerMontantNote(note) {
try {
const lignes = JSON.parse(note.lignesJson || '[]');
if (!lignes.length) return parseFloat(note.montant) || 0;
return lignes.reduce((sum, l) => {
if ((l.categorie || '').toLowerCase().includes('kilom'))
return sum + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
return sum + (parseFloat(l.montant) || 0);
}, 0);
} catch { return parseFloat(note.montant) || 0; }
}
// ── Helper getTarifKm ────────────────────────────────────────────
async function getTarifKm() {
try {
const annee = new Date().getFullYear();
const result = await pool.request()
.input('annee', sql.Int, annee)
.query(`
SELECT TOP 1 tarifParKm
FROM ParametresKm
WHERE annee = @annee AND actif = 1
ORDER BY DateCreation DESC
`);
if (result.recordset[0]?.tarifParKm) {
return parseFloat(result.recordset[0].tarifParKm);
}
return 0.697; // Fallback 7 CV+
} catch (e) {
console.warn('⚠️ getTarifKm fallback 0.697:', e.message);
return 0.697;
}
}
async function getConfigDebiteur(campus = null) {
try {
const request = pool.request();
let campusWhere = '';
if (campus) {
const campusCode = normalizeCampus(campus) || campus;
request.input('campus', sql.NVarChar, campusCode);
campusWhere = `AND (campus = @campus OR campus IS NULL)`;
}
const result = await request.query(`
SELECT TOP 1 companyName, companyIban, companyBic,
companyAddress, companyCp, companyVille, companyPays, campus
FROM ConfigDebiteurXML
WHERE actif = 1 ${campusWhere}
ORDER BY
CASE WHEN campus IS NOT NULL AND campus != '' THEN 0 ELSE 1 END ASC,
DateModification DESC
`);
if (result.recordset.length) return result.recordset[0];
} catch (e) {
console.warn('⚠️ getConfigDebiteur fallback .env:', e.message);
}
return {
companyName: process.env.COMPANY_NAME || 'ENSUP GROUP',
companyIban: process.env.COMPANY_IBAN || 'FR0000000000000000000000000',
companyBic: process.env.COMPANY_BIC || 'BNPAFRPP',
companyAddress: process.env.COMPANY_ADDRESS || '',
companyCp: process.env.COMPANY_CP || '',
companyVille: process.env.COMPANY_VILLE || '',
companyPays: process.env.COMPANY_PAYS || 'FR',
};
}
// ── Helpers IBAN ─────────────────────────────────────────────────
// ── Helpers IBAN ─────────────────────────────────────────────────
function getIbanKey() {
const key = process.env.IBAN_ENCRYPTION_KEY;
if (!key) throw new Error('IBAN_ENCRYPTION_KEY manquante');
const buf = Buffer.from(key, 'hex');
if (buf.length !== 32) throw new Error(`IBAN_ENCRYPTION_KEY invalide: ${buf.length} octets (attendu: 32)`);
return buf;
}
function encryptIban(iban) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', getIbanKey(), iv);
const encrypted = Buffer.concat([cipher.update(iban, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
}
function decryptIban(stored) {
const [ivHex, tagHex, encHex] = stored.split(':');
const decipher = crypto.createDecipheriv('aes-256-gcm', getIbanKey(), Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
return decipher.update(Buffer.from(encHex, 'hex')) + decipher.final('utf8');
}
function validateIban(iban) {
const lengths = { FR: 27, BE: 16, DE: 22, ES: 24, IT: 27 };
const country = iban.slice(0, 2);
if (lengths[country] && iban.length !== lengths[country]) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.split('').map(c => isNaN(c) ? (c.charCodeAt(0) - 55).toString() : c).join('');
let remainder = 0;
for (const chunk of numeric.match(/.{1,9}/g)) {
remainder = parseInt(remainder + chunk) % 97;
}
return remainder === 1;
}
function maskIban(iban) {
if (!iban || iban.length < 8) return iban;
return iban.slice(0, 4) + ' ' +
iban.slice(4, -4).replace(/./g, '*').match(/.{1,4}/g).join(' ') +
' ' + iban.slice(-4);
}
function hashIban(iban) {
return crypto.createHash('sha256').update(iban + process.env.IBAN_HASH_SALT).digest('hex');
}
// ✅ NOUVEAU — Charge les rôles depuis UtilisateurRoles (remplace CollaborateurAD.role)
async function getRolesForUser(collaborateurId) {
try {
const result = await pool.request()
.input('id', sql.Int, collaborateurId)
.query(`
SELECT role
FROM UtilisateurRoles
WHERE collaborateur_id = @id AND actif = 1
`);
return result.recordset.map(r => r.role);
// Retourne ex: ['Collaboratrice', 'Finance'] ou ['superUtilisateur']
} catch (e) {
console.warn('⚠️ getRolesForUser erreur:', e.message);
return [];
}
}
function formatDateParis(date) {
if (!date) return '—';
const d = new Date(date);
return d.toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
const cca = new ConfidentialClientApplication(msalConfig);
console.log('✅ 10. MSAL configuré');
// ================================================
// 🔑 TOKEN MICROSOFT GRAPH
// ================================================
// Cache du token Graph (évite 1 appel HTTP Azure par opération)
let _graphTokenCache = null;
let _sharePointTokenCache = null;
async function getGraphToken() {
const now = Date.now();
if (_graphTokenCache && now < _graphTokenCache.expiresAt) {
return _graphTokenCache.token;
}
try {
console.log('🔑 Obtention nouveau token Graph...');
if (!AZURE_CONFIG.tenantId || !AZURE_CONFIG.clientId || !AZURE_CONFIG.clientSecret) {
throw new Error('Configuration Azure incomplète');
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
scope: 'https://graph.microsoft.com/.default'
});
const response = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const token = response.data.access_token;
_graphTokenCache = { token, expiresAt: now + 55 * 60 * 1000 }; // 55 min
console.log('✅ Token Graph obtenu et mis en cache (55 min)');
return token;
} catch (error) {
console.error('❌ Erreur obtention token:', error.message);
return null;
}
}
// ================================================
// 🔄 SYNCHRONISATION ENTRA ID
// ================================================
async function syncEntraIdUsers() {
const syncResults = { processed: 0, inserted: 0, updated: 0, deactivated: 0, errors: [] };
try {
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
const accessToken = await getGraphToken();
if (!accessToken) { console.error('❌ Impossible d\'obtenir le token'); return syncResults; }
console.log('✅ Token obtenu');
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`📋 Groupe : ${groupName}`);
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
console.log('📥 Récupération des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, { headers: { Authorization: `Bearer ${accessToken}` } });
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) console.log(` 📄 ${allAzureMembers.length} membres récupérés...`);
}
console.log(`✅ ${allAzureMembers.length} membres trouvés`);
const validMembers = allAzureMembers.filter(m => {
if (!m.mail || m.mail.trim() === '') return false;
if (m.accountEnabled === false) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.mail);
});
console.log(`✅ ${validMembers.length} membres valides`);
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const azureEmails = new Set();
validMembers.forEach(m => azureEmails.add(m.mail.toLowerCase().trim()));
console.log('\n📝 Traitement des utilisateurs...');
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
const request = new sql.Request(transaction);
request.input('email', sql.NVarChar, emailClean);
const result = await request.query(`
SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = LOWER(@email)
`);
if (result.recordset.length > 0) {
const updateRequest = new sql.Request(transaction);
updateRequest.input('entraUserId', sql.NVarChar, m.id);
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
updateRequest.input('nom', sql.NVarChar, m.surname || '');
updateRequest.input('departement', sql.NVarChar, m.department || '');
updateRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
updateRequest.input('campus', sql.NVarChar, m.officeLocation || '');
updateRequest.input('email', sql.NVarChar, emailClean);
await updateRequest.query(`
UPDATE CollaborateurAD SET
entraUserId = @entraUserId, prenom = @prenom, nom = @nom,
departement = @departement, fonction = @fonction, campus = @campus,
Actif = 1, dateMiseAJour = GETDATE(), DateModification = GETDATE()
WHERE LOWER(email) = LOWER(@email)
`);
syncResults.updated++;
console.log(` ✓ Mis à jour : ${emailClean}`);
} else {
const insertRequest = new sql.Request(transaction);
insertRequest.input('entraUserId', sql.NVarChar, m.id);
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
insertRequest.input('nom', sql.NVarChar, m.surname || '');
insertRequest.input('email', sql.NVarChar, emailClean);
insertRequest.input('departement', sql.NVarChar, m.department || '');
insertRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
insertRequest.input('campus', sql.NVarChar, m.officeLocation || '');
await insertRequest.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, departement, fonction, campus, role, service, Actif, DateCreation, DateModification, dateMiseAJour)
VALUES (@entraUserId, @prenom, @nom, @email, @departement, @fonction, @campus, 'Collaborateur', NULL, 1, GETDATE(), GETDATE(), GETDATE())
`);
syncResults.inserted++;
console.log(` ✓ Créé : ${emailClean}`);
}
} catch (userError) {
syncResults.errors.push({ email: m.mail, error: userError.message });
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
}
}
console.log('\n🔍 Désactivation des comptes obsolètes...');
if (azureEmails.size > 0) {
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
const deactivateRequest = new sql.Request(transaction);
const deactivateResult = await deactivateRequest.query(`
UPDATE CollaborateurAD SET Actif = 0, DateModification = GETDATE()
WHERE email IS NOT NULL AND email != ''
AND LOWER(email) NOT IN (${activeEmailsList})
AND (Actif = 1 OR Actif IS NULL)
`);
syncResults.deactivated = deactivateResult.rowsAffected[0];
console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`);
}
await transaction.commit();
console.log('\n📊 === RÉSUMÉ ===');
console.log(` Groupe: ${groupName}`);
console.log(` Total Entra: ${allAzureMembers.length}`);
console.log(` Valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis à jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${syncResults.errors.length}`);
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('\n❌ ERREUR SYNCHRONISATION:', error.message);
}
return syncResults;
}
// ================================================
// MIDDLEWARE JWT
// ================================================
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token manquant' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Token invalide ou expiré' });
}
};
// requireRole gère les rôles multiples (tableau req.user.roles)
const requireRole = (...rolesRequis) => (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Non authentifié' });
const hasRole = req.user.roles && req.user.roles.some(r => rolesRequis.includes(r));
if (!hasRole) {
return res.status(403).json({ error: `Accès refusé — rôle(s) requis : ${rolesRequis.join(' ou ')}` });
}
next();
};
// Helper : vérifie si l'utilisateur possède un rôle parmi une liste
const hasAnyRole = (user, ...roles) => user.roles && user.roles.some(r => roles.includes(r));
// ================================================
// ROUTES DE BASE
// ================================================
app.get('/', (req, res) => {
res.json({ message: 'API Gestion des Notes de Frais', version: '1.0.0', status: 'OK' });
});
app.get('/users-dev', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT id, email, nom, prenom, role, Actif FROM CollaborateurAD
WHERE Actif = 1 OR Actif IS NULL ORDER BY nom, prenom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /users-dev:', error);
res.status(500).json({ error: error.message });
}
});
// ✅ MODIFIÉ — login-dev avec rôles depuis UtilisateurRoles
app.post('/login-dev', async (req, res) => {
try {
const { accessToken } = req.body;
if (!accessToken) return res.status(400).json({ error: 'Token d\'accès manquant' });
let userInfo;
try {
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (!graphResponse.ok) throw new Error('Token invalide');
userInfo = await graphResponse.json();
} catch (graphError) {
return res.status(401).json({ error: 'Token d\'accès invalide', details: graphError.message });
}
const userEmail = userInfo.mail || userInfo.userPrincipalName;
const result = await pool.request()
.input('email', sql.VarChar, userEmail)
.query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`);
if (!result.recordset.length)
return res.status(404).json({ error: 'Utilisateur non trouvé ou compte désactivé' });
const user = result.recordset[0];
// ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role
const userRoles = await getRolesForUser(user.id);
if (!userRoles.length)
return res.status(403).json({ error: 'Aucun rôle assigné dans UtilisateurRoles' });
const rolesAutorises = [
'Collaborateur', 'Collaboratrice',
'Validateur', 'Validatrice',
'Finance', 'VerificateurFinance', 'ValidateurFinance',
'superUtilisateur'
];
const hasAccess = userRoles.some(r => rolesAutorises.includes(r));
if (!hasAccess)
return res.status(403).json({ error: `Vos rôles n'ont pas accès à cette application` });
const token = jwt.sign(
{
id: user.id,
email: user.email,
roles: userRoles,
nom: user.nom,
prenom: user.prenom,
societe: user.societe,
campus: normalizeCampus(user.campus),
},
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
res.json({
token,
user: {
id: user.id,
nom: user.nom,
prenom: user.prenom,
email: user.email,
roles: userRoles,
societe: user.societe,
campus: user.campus,
}
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur inattendue', details: error.message });
}
});
app.get('/auth/verify', authenticateToken, (req, res) => {
res.json({ valid: true, user: req.user });
});
app.post('/auth/logout', authenticateToken, (req, res) => {
res.json({ success: true, message: 'Déconnexion réussie' });
});
const pkceStore = new Map();
app.get('/api/auth/microsoft', (req, res) => {
const tenantId = process.env.AZURE_TENANT_ID;
const clientId = process.env.AZURE_CLIENT_ID;
const state = Math.random().toString(36).substring(2, 15);
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
pkceStore.set(state, codeVerifier);
setTimeout(() => pkceStore.delete(state), 10 * 60 * 1000);
// ✅ Détecte automatiquement l'URL d'origine
const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback';
console.log('🔗 Redirect URI:', redirectUri);
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri,
response_mode: 'query',
scope: 'openid profile email User.Read',
state,
prompt: 'select_account',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
res.redirect(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`);
});
// ✅ MODIFIÉ — auth/callback avec rôles depuis UtilisateurRoles
app.get('/api/auth/callback', async (req, res) => {
const { code, error, state } = req.query;
if (error) return res.redirect(`/login?error=${error}&desc=${encodeURIComponent(req.query.error_description || '')}`);
if (!code) return res.redirect(`/login?error=no_code`);
const codeVerifier = pkceStore.get(state);
if (!codeVerifier) return res.redirect(`/login?error=invalid_state`);
pkceStore.delete(state);
try {
// ✅ Détecte automatiquement l'URL d'origine (même logique que /api/auth/microsoft)
const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback';
console.log('🔗 Callback Redirect URI:', redirectUri);
const tokenResponse = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
new URLSearchParams({
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
code,
redirect_uri: redirectUri, // ✅ URI détectée automatiquement
grant_type: 'authorization_code',
code_verifier: codeVerifier
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const accessToken = tokenResponse.data.access_token;
const userResponse = await axios.get('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${accessToken}` }
});
const userEmail = userResponse.data.mail || userResponse.data.userPrincipalName;
const result = await pool.request()
.input('email', sql.VarChar, userEmail)
.query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`);
if (!result.recordset.length) return res.redirect(`/login?error=user_not_found`);
const user = result.recordset[0];
// ✅ Rôles depuis UtilisateurRoles
const userRoles = await getRolesForUser(user.id);
if (!userRoles.length)
return res.redirect(`/login?error=no_role_assigned`);
const rolesAutorises = ['Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Finance', 'VerificateurFinance', 'ValidateurFinance', , 'superUtilisateur'];
const hasAccess = userRoles.some(r => rolesAutorises.includes(r));
if (!hasAccess) return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=unauthorized_role`);
const jwtToken = jwt.sign(
{
id: user.id,
email: user.email,
roles: userRoles,
nom: user.nom,
prenom: user.prenom,
societe: user.societe,
campus: normalizeCampus(user.campus),
},
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
// ✅ Redirection vers le frontend avec le token
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/auth/callback?token=${jwtToken}`);
} catch (error) {
console.error('❌ Erreur callback OAuth:', error.message);
if (error.response?.data) {
console.error('Détails:', JSON.stringify(error.response.data, null, 2));
}
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=auth_failed&details=${encodeURIComponent(error.message)}`);
}
});
// GET /api/verificateur/notes — notes approuvées à vérifier
app.get('/api/verificateur/notes', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé VerificateurFinance' });
try {
const request = pool.request();
let campusWhere = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusWhere = `AND c.campus LIKE @campus`;
}
}
const result = await request.query(`
SELECT n.*,
c.nom + ' ' + c.prenom AS collaborateur,
c.email AS collaborateurEmail,
c.departement, c.campus, c.societe,
v1.nom + ' ' + v1.prenom AS nomN1
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
WHERE n.statut = 'approuve'
${campusWhere}
ORDER BY n.DateCreation DESC
`);
const notes = result.recordset;
if (!notes.length) return res.json([]);
// Charger les lignes refusées actives en batch (utile si une note "approuve"
// a déjà eu un refus archivé qu'on veut afficher en historique côté UI)
const noteIds = notes.map(n => n.id).join(',');
const refusedRows = await pool.request().query(`
SELECT noteDeFraisId, ligneIndex, motif, statut, dateRefus
FROM LignesRefusees
WHERE noteDeFraisId IN (${noteIds}) AND statut = 'active'
`);
const refusedByNote = {};
for (const r of refusedRows.recordset) {
if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = [];
refusedByNote[r.noteDeFraisId].push({ index: r.ligneIndex, motif: r.motif });
}
for (const note of notes) {
note.lignesRefusees = refusedByNote[note.id] || [];
}
res.json(notes);
} catch (error) {
console.error('GET /api/verificateur/notes:', error.message);
res.status(500).json({ error: error.message });
}
});
// PUT /api/verificateur/notes/:id/verifier
app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé VerificateurFinance' });
const { commentaire, montantsModifies } = req.body;
const noteId = parseInt(req.params.id);
try {
const noteResult = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT n.*, c.prenom, c.nom, c.email, c.campus
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id = @id AND n.statut = 'approuve'
`);
if (!noteResult.recordset.length)
return res.status(404).json({ error: 'Note introuvable ou statut incompatible' });
const note = noteResult.recordset[0];
let lignesParsed = [];
try { lignesParsed = JSON.parse(note.lignesJson || '[]'); } catch { }
let montantFinalAjuste = parseFloat(note.montant);
let lignesJsonModifie = note.lignesJson;
if (Array.isArray(montantsModifies) && montantsModifies.length > 0) {
for (const mod of montantsModifies) {
const { ligneIndex, montantRetenu } = mod;
if (
typeof ligneIndex === 'number' &&
ligneIndex >= 0 &&
ligneIndex < lignesParsed.length &&
typeof montantRetenu === 'number' &&
montantRetenu > 0
) {
const ligneOriginale = lignesParsed[ligneIndex];
const montantOriginalVal = parseFloat(ligneOriginale.montant) || montantRetenu;
const ratio = montantOriginalVal > 0 ? montantRetenu / montantOriginalVal : 1;
let tvaItemsMisAJour = ligneOriginale.tvaItems;
if (Array.isArray(ligneOriginale.tvaItems) && ligneOriginale.tvaItems.length > 0) {
tvaItemsMisAJour = ligneOriginale.tvaItems.map(item => {
const itemTTC = parseFloat((parseFloat(item.montantTTC) * ratio).toFixed(2));
const itemTau = parseFloat(item.taux) || 0;
const itemHT = itemTau > 0
? parseFloat((itemTTC / (1 + itemTau / 100)).toFixed(2))
: itemTTC;
return { ...item, montantTTC: itemTTC.toFixed(2), montantHT: itemHT.toFixed(2) };
});
}
lignesParsed[ligneIndex] = {
...ligneOriginale,
montant: montantRetenu.toFixed(2),
montantOriginal: ligneOriginale.montant,
montantAjuste: true,
tvaItems: tvaItemsMisAJour,
};
}
}
const tarifKm = await getTarifKm();
montantFinalAjuste = lignesParsed.reduce((total, l) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
if (isKm) {
const km = parseFloat(l.km) || 0;
const cv = parseInt(l.chevaux) || 7;
return total + getIndemniteKmServer(km, cv);
}
return total + (parseFloat(l.montant) || 0);
}, 0);
montantFinalAjuste = parseFloat(montantFinalAjuste.toFixed(2));
lignesJsonModifie = JSON.stringify(lignesParsed);
}
const nbLignes = lignesParsed.length;
const commentaireVerif = montantsModifies?.length > 0
? ``
: commentaire || null;
await pool.request()
.input('id', sql.Int, noteId)
.input('verificateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, commentaireVerif)
.input('montant', sql.Decimal, montantFinalAjuste)
.input('lignesJson', sql.NVarChar, lignesJsonModifie)
.query(`
UPDATE NoteDeFrais SET
statut = 'verifie',
verificateurFinanceId = @verificateurId,
dateVerification = GETDATE(),
commentaireVerification = @commentaire,
montant = @montant,
lignesJson = @lignesJson,
DateModification = GETDATE()
WHERE id = @id
`);
if (Array.isArray(montantsModifies) && montantsModifies.length > 0) {
for (const mod of montantsModifies) {
const { ligneIndex, montantRetenu } = mod;
if (typeof ligneIndex !== 'number' || montantRetenu <= 0) continue;
const numPiece = ligneIndex + 1;
const l = lignesParsed[ligneIndex] || {};
const taux = parseFloat(l.tauxTVA) || 0;
const ht = taux > 0 ? montantRetenu / (1 + taux / 100) : montantRetenu;
await pool.request()
.input('noteId', sql.Int, noteId)
.input('numPiece', sql.Int, numPiece)
.input('montantTTC', sql.Decimal, montantRetenu)
.input('montantHT', sql.Decimal, parseFloat(ht.toFixed(2)))
.query(`
UPDATE LigneNoteDeFrais
SET montantTTC = @montantTTC, montantHT = @montantHT
WHERE noteDeFraisId = @noteId AND numPiece = @numPiece
`);
}
}
const commentaireHisto = `${nbLignes} ligne${nbLignes > 1 ? 's' : ''} validée${nbLignes > 1 ? 's' : ''}${montantsModifies?.length > 0 ? ` — ${montantsModifies.length} montant(s) proratisé(s)` : ''}${commentaire ? ' — ' + commentaire : ''}`;
await pool.request()
.input('noteId', sql.Int, noteId)
.input('validateurId', sql.Int, req.user.id)
.input('action', sql.NVarChar, 'verifier')
.input('commentaire', sql.NVarChar, commentaireHisto)
.input('statut', sql.NVarChar, 'verifie')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, 'VERIF', @action, @commentaire, @statut, GETDATE())
`);
res.json({
success: true,
statut: 'verifie',
montantAjuste: montantFinalAjuste,
nbMontantsModifies: Array.isArray(montantsModifies) ? montantsModifies.length : 0,
});
setImmediate(async () => {
try {
// ── Récupérer historique des signatures ───────────────────
const histResult = await pool.request()
.input('noteId', sql.Int, noteId)
.query(`
SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction,
c.prenom + ' ' + c.nom AS nomPrenom
FROM HistoriqueValidation h
JOIN CollaborateurAD c ON c.id = h.ValidateurId
WHERE h.NoteDeFraisId = @noteId
ORDER BY h.DateAction ASC
`);
const noteComplete = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT n.reference, n.libelle, n.montant, n.date,
n.categorie, n.lignesJson, n.fichiers, n.DateCreation,
c.prenom + ' ' + c.nom AS nomPrenom,
c.prenom AS collabPrenom, c.nom AS collabNom,
c.departement
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id = @id
`);
const nd = noteComplete.recordset[0];
if (!nd) return;
const moisStr = (() => {
const d = new Date(nd.date);
const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return m.charAt(0).toUpperCase() + m.slice(1);
})();
const nomPrenom = nd.nomPrenom;
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
// ── Construire les signatures COLLAB + N1/N2 + VERIF ──────
const signatures = [];
signatures.push({
niveau: 'COLLAB', nomPrenom,
date: nd.DateCreation, action: 'soumettre', commentaire: null
});
for (const h of histResult.recordset) {
if (h.Niveau !== 'VERIF') {
signatures.push({
niveau: h.Niveau, nomPrenom: h.nomPrenom,
date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null
});
}
}
signatures.push({
niveau: 'VERIF', nomPrenom: verificateurNom,
date: new Date(), action: 'verifier', commentaire: commentaireVerif || null
});
const noteDataPDF = {
reference: nd.reference,
nomPrenom,
mois: moisStr,
departement: nd.departement,
lignesJson: lignesJsonModifie,
tarifKm: await getTarifKm(),
statut: 'verifie',
montant: montantFinalAjuste,
};
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { }
const existingFolder = fichiersExistants[0]?.folderPath;
const nomDossier = existingFolder
? existingFolder.split('/')[1]
: `${nd.collabNom}_${nd.collabPrenom}`
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9_]/g, '_');
const moisDossier = existingFolder
? existingFolder.split('/')[2]
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
// ── Générer PDF fiche vérifiée (fiche seule) ──────────────
try {
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
const suffixe = montantsModifies?.length > 0 ? 'verifie-proratise' : 'verifie';
const signedResult = await uploadToSharePointHierarchique(
{
buffer: pdfSigne,
originalname: `${nd.reference}-${suffixe}.pdf`,
mimetype: 'application/pdf',
size: pdfSigne.length
},
nd.reference, nomDossier, moisDossier
);
fichiersExistants.push(signedResult);
await pool.request()
.input('id', sql.Int, noteId)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
console.log(`✅ [ASYNC] PDF vérification ${suffixe} généré: ${signedResult.fileName}`);
} catch (e) {
console.error('❌ [ASYNC] PDF vérification:', e.message);
}
// ── Régénérer le recap complet (fiche + justifs + 3 signatures) ──
try {
const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap'];
const justifFiles = [];
for (const f of fichiersExistants) {
const fname = (f.fileName || '').toLowerCase();
if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue;
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
const mimetype = fname.endsWith('.pdf') ? 'application/pdf'
: fname.endsWith('.png') ? 'image/png' : 'image/jpeg';
justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length });
} catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); }
}
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures);
const recapResult = await uploadToSharePointHierarchique(
{
buffer: recapBuffer,
originalname: `${nd.reference}_recap.pdf`,
mimetype: 'application/pdf',
size: recapBuffer.length
},
nd.reference, nomDossier, moisDossier
);
// Récupérer la liste de fichiers à jour après upload du PDF verifie
const noteUpdated = await pool.request()
.input('id', sql.Int, noteId)
.query('SELECT fichiers FROM NoteDeFrais WHERE id = @id');
let fichiersAJour = [];
try { fichiersAJour = JSON.parse(noteUpdated.recordset[0]?.fichiers || '[]'); } catch { }
// Remplacer l'ancien _recap.pdf (garder recap-paiement intact)
const fichiersFinaux = fichiersAJour.filter(f => {
const fname = (f.fileName || '').toLowerCase();
return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement');
});
fichiersFinaux.push(recapResult);
await pool.request()
.input('id', sql.Int, noteId)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersFinaux))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s) (proratisé: ${Array.isArray(montantsModifies) && montantsModifies.length > 0}): ${recapResult.fileName}`);
} catch (recapError) {
console.error('❌ [ASYNC] Régénération recap vérification:', recapError.message);
}
// ── Notifier les ValidateurFinance ────────────────────────
try {
const campusNorm = normalizeCampus(note.campus);
const validateurs = await pool.request()
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%')
.query(`
SELECT c.id, c.email, c.prenom, c.nom
FROM CollaborateurAD c
JOIN UtilisateurRoles r ON r.collaborateur_id = c.id
WHERE r.role = 'ValidateurFinance' AND r.actif = 1
AND c.campus LIKE @campus AND c.Actif = 1
`);
const montantFormate = montantFinalAjuste.toFixed(2);
const montantOriginal = parseFloat(note.montant).toFixed(2);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
const aProratise = Array.isArray(montantsModifies) && montantsModifies.length > 0;
for (const val of validateurs.recordset) {
try {
await creerNotification({
destinataireId: val.id,
destinataireEmail: val.email,
type: 'paiement',
titre: `✅ Note vérifiée à valider — ${note.reference}`,
message: `${verificateurNom} a vérifié la note ${note.reference} (${montantFormate} €${aProratise ? ` — montant ajusté de ${montantOriginal} €` : ''}) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`,
noteId
});
} catch (e) { console.error('Notif ValidateurFinance:', e.message); }
try {
await sendMailGraph(
val.email,
`✅ Note vérifiée — validation paiement requise : ${note.reference}`,
`
✅ Note vérifiée — paiement à valider
Bonjour ${val.prenom} ${val.nom},
La note ${note.reference} de ${note.prenom} ${note.nom} a été vérifiée par ${verificateurNom} et est prête pour le paiement.
${aProratise ? `
⚠️ Montants proratisés
Montant original : ${montantOriginal} € → Montant retenu : ${montantFormate} € (${montantsModifies.length} repas plafonné${montantsModifies.length > 1 ? 's' : ''} à 25 €/pers.)
` : ''}
${commentaire ? `
💬 ${commentaire}
` : ''}
`
);
} catch (e) { console.error('Email ValidateurFinance:', e.message); }
}
// Notifier le collaborateur
const montantAjusteMsg = aProratise
? `Votre note ${note.reference} a été vérifiée. Montant retenu : ${montantFormate} € (ajusté depuis ${montantOriginal} € — plafonnement repas à 25 €/pers.).`
: `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`;
try {
await creerNotification({
destinataireId: note.collaborateurId,
destinataireEmail: note.email,
type: 'paiement',
titre: `Note ${note.reference} ${aProratise ? 'vérifiée — montant ajusté' : 'en cours de traitement'}`,
message: montantAjusteMsg,
noteId
});
if (aProratise) {
await sendMailGraph(
note.email,
`ℹ️ Montant ajusté — Note ${note.reference}`,
`
ℹ️ Montant de votre note ajusté
Bonjour ${note.prenom} ${note.nom},
Votre note ${note.reference} a été vérifiée par la Finance. Certains frais de repas ont été plafonnés à 25 €/personne conformément à la politique de l'entreprise.
| Montant soumis | ${montantOriginal} € |
| Montant retenu | ${montantFormate} € |
| Ajustements | ${montantsModifies.length} ligne${montantsModifies.length > 1 ? 's' : ''} de repas plafonnée${montantsModifies.length > 1 ? 's' : ''} |
Le plafond légal pour les frais de repas est de 25 € par personne. Les montants ont été ajustés en conséquence.
`
);
}
} catch (e) { console.error('Notif collab vérification:', e.message); }
} catch (e) {
console.error('❌ [ASYNC] Notifications vérification:', e.message);
}
} catch (e) {
console.error('❌ [ASYNC] PDF vérification général:', e.message);
}
});
} catch (error) {
console.error('Erreur PUT /verificateur/notes/:id/verifier:', error.message);
res.status(500).json({ error: error.message });
}
});
app.post('/api/verificateur/notes/:id/refuser', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé VerificateurFinance' });
const noteId = parseInt(req.params.id);
const { lignesRefusees, notifier = true } = req.body;
if (!Array.isArray(lignesRefusees) || lignesRefusees.length === 0)
return res.status(400).json({ error: 'Au moins une ligne refusée est requise' });
// Validation : chaque entrée doit avoir index (number) + motif (string non vide)
for (const r of lignesRefusees) {
if (typeof r.index !== 'number' || r.index < 0)
return res.status(400).json({ error: 'Chaque ligne refusée doit avoir un index (number) >= 0' });
if (!r.motif || typeof r.motif !== 'string' || !r.motif.trim())
return res.status(400).json({ error: `Motif manquant pour la ligne d'index ${r.index}` });
}
const transaction = new sql.Transaction(pool);
try {
// Récupérer la note + collab + N1 + N2 (avant transaction pour validation)
const noteResult = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT n.id, n.reference, n.libelle, n.montant, n.statut,
n.collaborateurId, n.lignesJson,
c.prenom, c.nom, c.email, c.campus,
v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
WHERE n.id = @id AND n.statut = 'approuve'
`);
if (!noteResult.recordset.length)
return res.status(404).json({ error: 'Note introuvable ou statut incompatible (doit être "approuve")' });
const note = noteResult.recordset[0];
// Parser les lignes pour récupérer libellé + catégorie au moment du refus
let lignesData = [];
try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
// Vérifier que les index sont valides
for (const r of lignesRefusees) {
if (r.index >= lignesData.length)
return res.status(400).json({ error: `Index ${r.index} hors limites (note a ${lignesData.length} lignes)` });
}
await transaction.begin();
// ── 1. Archiver les anciens refus actifs (si re-refus après correction partielle)
await new sql.Request(transaction)
.input('noteId', sql.Int, noteId)
.query(`
UPDATE LignesRefusees
SET statut = 'archive'
WHERE noteDeFraisId = @noteId AND statut = 'active'
`);
// ── 2. Insérer les nouveaux refus
for (const r of lignesRefusees) {
const ligne = lignesData[r.index] || {};
await new sql.Request(transaction)
.input('noteId', sql.Int, noteId)
.input('ligneIndex', sql.Int, r.index)
.input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`)
.input('ligneCategorie', sql.NVarChar, ligne.categorie || null)
.input('motif', sql.NVarChar, r.motif.trim())
.input('verificateurId', sql.Int, req.user.id)
.query(`
INSERT INTO LignesRefusees
(noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut)
VALUES
(@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active')
`);
}
// ── 3. Passer la note en 'refuse_verif'
const commentaireSynth = lignesRefusees.map(r => {
const ligne = lignesData[r.index] || {};
const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`;
return `• ${label} — ${r.motif.trim()}`;
}).join(' | ');
await new sql.Request(transaction)
.input('id', sql.Int, noteId)
.input('verificateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} : ${commentaireSynth}`)
.query(`
UPDATE NoteDeFrais SET
statut = 'refuse_verif',
verificateurFinanceId = @verificateurId,
dateVerification = GETDATE(),
commentaireVerification = @commentaire,
DateModification = GETDATE()
WHERE id = @id
`);
// ── 4. Historique
await new sql.Request(transaction)
.input('noteId', sql.Int, noteId)
.input('validateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, commentaireSynth)
.input('statut', sql.NVarChar, 'refuse_verif')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, 'VERIF', 'refuser', @commentaire, @statut, GETDATE())
`);
await transaction.commit();
// ── 5. Notifications (en dehors de la transaction)
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
const montantFormate = recalculerMontantNote(note).toFixed(2);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
// HTML : tableau récap des lignes refusées
const tableLignesHtml = lignesRefusees.map(r => {
const ligne = lignesData[r.index] || {};
const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`;
const cat = ligne.categorie || '—';
return `
| ${r.index + 1} |
${label}
${cat}
|
${r.motif.trim()} |
`;
}).join('');
let notifiedCollab = false, notifiedN1 = false;
if (notifier) {
// Notif collaborateur
try {
await creerNotification({
destinataireId: note.collaborateurId,
destinataireEmail: note.email,
type: 'refus',
titre: `❌ Note ${note.reference} refusée par la Finance`,
message: `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} sur votre note ${note.reference}. Vous devez corriger uniquement ces lignes et resoumettre.`,
noteId
});
notifiedCollab = true;
} catch (e) { console.error('Notif BDD collab refus:', e.message); }
try {
await sendMailGraph(
note.email,
`❌ Note refusée — corrections demandées : ${note.reference}`,
`
❌ Votre note a été refusée par la Finance
${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger
Bonjour ${note.prenom} ${note.nom},
Votre note ${note.reference} (${montantFormate} €) a été refusée par ${verificateurNom} (Vérificateur Finance).
Lignes à corriger (${lignesRefusees.length})
| N° |
Ligne |
Motif |
${tableLignesHtml}
📝 Que faire maintenant ?
- Connectez-vous à la plateforme NDF
- Ouvrez la note ${note.reference}
- Corrigez uniquement les lignes listées ci-dessus
- Resoumettez la note (les lignes validées sont conservées)
`
);
} catch (e) { console.error('Email collab refus:', e.message); }
// Notif N1
if (note.n1Id && note.emailN1) {
try {
await creerNotification({
destinataireId: note.n1Id,
destinataireEmail: note.emailN1,
type: 'refus',
titre: `⚠️ Note ${note.reference} refusée par la Finance`,
message: `La note ${note.reference} de ${note.prenom} ${note.nom} a été refusée par ${verificateurNom} (${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger).`,
noteId
});
notifiedN1 = true;
} catch (e) { console.error('Notif BDD N1 refus:', e.message); }
try {
await sendMailGraph(
note.emailN1,
`⚠️ Note ${note.reference} refusée par la Finance`,
`
⚠️ Note refusée par la Finance
Bonjour ${note.prenomN1} ${note.nomN1},
La note ${note.reference} de ${note.prenom} ${note.nom} que vous aviez validée a été refusée par ${verificateurNom} (Vérificateur Finance).
${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger
${lignesRefusees.map(r => {
const ligne = lignesData[r.index] || {};
const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`;
return `| ${label} | ${r.motif.trim()} |
`;
}).join('')}
${note.prenom} ${note.nom} a été notifié et doit corriger uniquement les lignes listées.
`
);
} catch (e) { console.error('Email N1 refus:', e.message); }
}
}
res.json({
success: true,
statut: 'refuse_verif',
nbLignesRefusees: lignesRefusees.length,
notifiedCollab,
notifiedN1
});
} catch (error) {
try { await transaction.rollback(); } catch { }
console.error('Erreur POST /verificateur/notes/:id/refuser:', error.message);
res.status(500).json({ error: error.message });
}
});
app.get('/api/verificateur/historique', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès refusé' });
try {
const request = pool.request().input('verificateurId', sql.Int, req.user.id);
let campusWhere = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusWhere = 'AND c.campus LIKE @campus';
}
}
const result = await request.query(`
SELECT
n.id, n.reference, n.libelle, n.montant, n.statut,
n.dateVerification, n.commentaireVerification,
n.lignesJson, n.fichiers,
c.nom + ' ' + c.prenom AS collaborateur,
c.campus, c.departement
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.verificateurFinanceId = @verificateurId
AND n.statut IN ('verifie', 'paiementenattente', 'payee', 'refuse_verif',
'refuse_verif_archive', 'non_conforme_verif', 'non_conforme_archive')
${campusWhere}
ORDER BY n.dateVerification DESC
`);
const notes = result.recordset;
if (!notes.length) return res.json([]);
// Récupérer toutes les lignes refusées en un seul appel
const noteIds = notes.map(n => n.id).join(',');
const refusedRows = await pool.request().query(`
SELECT noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, dateRefus
FROM LignesRefusees
WHERE noteDeFraisId IN (${noteIds})
ORDER BY noteDeFraisId, ligneIndex ASC
`);
const refusedByNote = {};
for (const r of refusedRows.recordset) {
if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = [];
refusedByNote[r.noteDeFraisId].push({
index: r.ligneIndex,
motif: r.motif,
ligneLibelle: r.ligneLibelle,
ligneCategorie: r.ligneCategorie,
dateRefus: r.dateRefus
});
}
const enriched = notes.map(row => {
let nbLignes = 0;
try { nbLignes = (JSON.parse(row.lignesJson || '[]')).length; } catch { }
const lignesRefusees = refusedByNote[row.id] || [];
const nbLignesRefusees = lignesRefusees.length;
const nbLignesOk = Math.max(0, nbLignes - nbLignesRefusees);
const isRefusee = row.statut === 'refuse_verif' || row.statut === 'refuse_verif_archive'
|| row.statut === 'non_conforme_verif' || row.statut === 'non_conforme_archive';
return {
...row,
statut: isRefusee ? 'REFUSEE' : 'VERIFIEE',
nbLignes,
nbLignesOk: isRefusee ? nbLignesOk : nbLignes,
nbLignesRefusees,
lignesRefusees,
commentaire: row.commentaireVerification,
};
});
res.json(enriched);
} catch (error) {
console.error('GET /api/verificateur/historique:', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// SYNC ENTRA — réservé Finance
// ================================================
app.post('/api/sync-entra', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ success: false, message: 'Accès refusé - réservé à la Finance' });
const results = await syncEntraIdUsers();
res.json({ success: true, message: 'Synchronisation terminée', stats: results });
});
app.get('/api/sync-status', authenticateToken, async (req, res) => {
try {
const totalDB = await pool.request().query(`
SELECT COUNT(*) as total,
SUM(CASE WHEN Actif = 1 THEN 1 ELSE 0 END) as actifs,
SUM(CASE WHEN Actif = 0 THEN 1 ELSE 0 END) as inactifs
FROM CollaborateurAD
`);
const derniers = await pool.request().query(`
SELECT TOP 10 id, prenom, nom, email, role, Actif, dateCreation, dateMiseAJour
FROM CollaborateurAD ORDER BY dateMiseAJour DESC
`);
let entraStatus = { connected: false };
try {
const token = await getGraphToken();
if (token) {
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${token}` } }
);
entraStatus = { connected: true, groupName: groupResponse.data.displayName, groupId: AZURE_CONFIG.groupId };
}
} catch (err) { entraStatus.error = err.message; }
res.json({
success: true,
database: totalDB.recordset[0],
entraId: entraStatus,
derniers_utilisateurs: derniers.recordset
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// ── GET tarif km actif ─────────────────────────────────────────────
app.get('/api/parametres/km', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT TOP 1 tarifParKm
FROM ParametresKm
WHERE actif = 1
ORDER BY annee DESC
`);
const tarif = result.recordset[0]?.tarifParKm ?? 0.697;
res.json({ tarifKm: tarif });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── GET taux TVA actifs ────────────────────────────────────────────
app.get('/api/parametres/tva', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT taux, libelle, categorie
FROM ParametresTVA
WHERE actif = 1
AND dateDebut <= GETDATE()
AND (dateFin IS NULL OR dateFin >= GETDATE())
ORDER BY taux ASC
`);
res.json(result.recordset);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── PUT tarif km — réservé Finance ────────────────────────────────
app.put('/api/parametres/km', authenticateToken, requireRole('Finance'), async (req, res) => {
const { tarifParKm, annee } = req.body;
if (!tarifParKm || isNaN(tarifParKm))
return res.status(400).json({ error: 'Tarif invalide' });
try {
await pool.request().query(`UPDATE ParametresKm SET actif = 0 WHERE actif = 1`);
await pool.request()
.input('tarif', sql.Decimal(10, 4), parseFloat(tarifParKm))
.input('annee', sql.Int, annee || new Date().getFullYear())
.query(`INSERT INTO ParametresKm (tarifParKm, annee, actif, DateCreation) VALUES (@tarif, @annee, 1, GETDATE())`);
res.json({ success: true, tarifParKm: parseFloat(tarifParKm) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/parametres/bareme-km', authenticateToken, (req, res) => {
res.json({
baremes: [
{ chevaux: 3, label: '3 CV et moins', t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
{ chevaux: 4, label: '4 CV', t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
{ chevaux: 5, label: '5 CV', t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
{ chevaux: 6, label: '6 CV', t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
{ chevaux: 7, label: '7 CV et plus', t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
]
});
});
// ✅ MODIFIÉ — profil expose les rôles depuis UtilisateurRoles
app.get('/api/profile', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`
SELECT id, nom, prenom, email, role, fonction as poste,
TypeContrat, DateEntree, campus, departement, societe,
adresse_rue, adresse_cp, adresse_ville, adresse_pays
FROM CollaborateurAD WHERE id = @id
`);
if (!result.recordset.length) return res.status(404).json({ error: 'Profil non trouvé' });
const userProfile = result.recordset[0];
// ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role
const rolesDB = await getRolesForUser(req.user.id);
userProfile.roles = rolesDB.length > 0 ? rolesDB : ['Collaborateur'];
res.json(userProfile);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/profile/adresse', authenticateToken, async (req, res) => {
try {
const { adresse_rue, adresse_cp, adresse_ville, adresse_pays, societe } = req.body;
if (!adresse_rue || !adresse_cp || !adresse_ville || !adresse_pays)
return res.status(400).json({ error: 'Tous les champs adresse sont obligatoires' });
await pool.request()
.input('id', sql.Int, req.user.id)
.input('adresse_rue', sql.NVarChar, adresse_rue.trim())
.input('adresse_cp', sql.NVarChar, adresse_cp.trim())
.input('adresse_ville', sql.NVarChar, adresse_ville.trim())
.input('adresse_pays', sql.NVarChar, adresse_pays.trim())
.input('societe', sql.NVarChar, societe ? societe.trim() : null)
.query(`
UPDATE CollaborateurAD SET
adresse_rue = @adresse_rue,
adresse_cp = @adresse_cp,
adresse_ville = @adresse_ville,
adresse_pays = @adresse_pays,
societe = COALESCE(@societe, societe),
DateModification = GETDATE()
WHERE id = @id
`);
res.json({ success: true });
} catch (error) {
console.error('PUT /api/profile/adresse :', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// HELPERS
// ================================================
async function genererReference(campus, nom, prenom) {
const now = new Date();
const jour = String(now.getDate()).padStart(2, '0');
const mois = String(now.getMonth() + 1).padStart(2, '0');
const annee = now.getFullYear();
const campusCode = normalizeCampus(campus) || 'XXX';
const nomClean = (nom || '').toUpperCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Z]/g, '');
const prenomInitiale = (prenom || '').charAt(0).toUpperCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Z]/g, '');
const nomPart = `${nomClean}.${prenomInitiale}`;
const tx = new sql.Transaction(pool);
await tx.begin();
try {
// ✅ MERGE atomique — crée la ligne si elle n'existe pas, puis incrémente
const result = await new sql.Request(tx).query(`
MERGE NDFSequence WITH (HOLDLOCK) AS target
USING (SELECT ${annee} AS annee) AS source
ON target.annee = source.annee
WHEN MATCHED THEN
UPDATE SET compteur = ISNULL(target.compteur, 0) + 1
WHEN NOT MATCHED THEN
INSERT (annee, compteur) VALUES (${annee}, 1);
SELECT compteur FROM NDFSequence WHERE annee = ${annee};
`);
await tx.commit();
const compteur = result.recordset?.[0]?.compteur;
if (!compteur || Number.isNaN(Number(compteur))) {
throw new Error(`Compteur NDFSequence invalide pour ${annee}`);
}
// ✅ padStart(3) pour avoir NDF001, NDF002... NDF999
const num = String(compteur).padStart(3, '0');
return `NDF N\u00B0${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`;
} catch (e) {
try { await tx.rollback(); } catch { }
throw e;
}
}
async function uploadToSharePoint(file, ndfReference, collaborateurNomPrenom) {
const accessToken = await getGraphToken();
if (!accessToken) throw new Error('Token Graph indisponible');
const safeName = file.originalname.replace(/[^a-zA-Z0-9._\-]/g, '_');
const fileName = `${ndfReference}_${safeName}`;
const folderPath = `${SHAREPOINT_CONFIG.basePath}/${collaborateurNomPrenom}`;
const uploadPath = `${folderPath}/${fileName}`;
const res = await axios.put(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
file.buffer,
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype } }
);
return { fileName, uploadUrl: res.data.webUrl };
}
const downloadUrlCache = new Map(); // webUrl SP → { url, expiresAt }
async function downloadFromSharePoint(webUrl) {
const accessToken = await getGraphToken();
// ✅ Cache de l'URL de téléchargement direct (valable ~1h)
const cached = downloadUrlCache.get(webUrl);
if (cached && Date.now() < cached.expiresAt) {
try {
const fileRes = await axios.get(cached.url, {
responseType: 'arraybuffer',
timeout: 15000
});
return Buffer.from(fileRes.data);
} catch {
downloadUrlCache.delete(webUrl); // URL expirée, on refait
}
}
const urlObj = new URL(webUrl);
const fullPath = decodeURIComponent(urlObj.pathname);
const marker = '/Shared Documents/';
const markerAlt = '/Documents/';
let relativePath = '';
if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1];
else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1];
else {
const parts = fullPath.split('/sites/')[1]?.split('/');
relativePath = parts?.slice(2).join('/') || '';
}
const metaRes = await axios.get(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`,
{ headers: { Authorization: `Bearer ${accessToken}` }, timeout: 5000 }
);
const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl'];
if (!downloadUrl) throw new Error('downloadUrl absent de la réponse Graph');
// Mettre en cache 50 min (les URLs pré-signées expirent vers 1h)
downloadUrlCache.set(webUrl, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 });
const fileRes = await axios.get(downloadUrl, {
responseType: 'arraybuffer',
timeout: 15000
});
return Buffer.from(fileRes.data);
}
async function uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier) {
const accessToken = await getGraphToken();
if (!accessToken) throw new Error('Token Graph indisponible');
const safeName = (file.originalname || 'fichier').replace(/[^a-zA-Z0-9._\-]/g, '_');
const fileName = safeName.startsWith(noteRef) ? safeName : `${noteRef}_${safeName}`;
// moisDossier format "2026-05" → annee="2026", mois="05"
const [annee, mois] = (moisDossier || '').split('-');
// Structure : Notes de Frais / Nom_Prenom / 2026 / 05 / NDF01-SQY-IMER.O-05-05-2026 /
const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${annee}/${mois}/${noteRef}`;
const uploadPath = `${folderPath}/${fileName}`;
const res = await axios.put(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
file.buffer,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': file.mimetype || 'application/octet-stream'
},
maxBodyLength: Infinity,
maxContentLength: Infinity
}
);
return { fileName, uploadUrl: res.data.webUrl, folderPath };
}
// ══════════════════════════════════════════════════════
// 📎 RÉCAP COMPLET (fiche PDF/A + justificatifs fusionnés)
// ══════════════════════════════════════════════════════
async function generateRecapWithJustifs(noteData, justifFiles, signaturesOpt) {
const signatures = signaturesOpt || [
{ niveau: 'COLLAB', nomPrenom: noteData.nomPrenom || '', date: new Date(), action: 'soumettre', commentaire: null }
];
const ficheBuffer = await generateFicheSignee(noteData, signatures);
const finalPdf = await PDFLib.create();
const fichePdf = await PDFLib.load(ficheBuffer);
const fichePages = await finalPdf.copyPages(fichePdf, fichePdf.getPageIndices());
fichePages.forEach(p => finalPdf.addPage(p));
for (const file of justifFiles) {
const mimetype = file.mimetype || '';
if (mimetype === 'application/pdf') {
try {
const justifPdf = await PDFLib.load(file.buffer);
const pages = await finalPdf.copyPages(justifPdf, justifPdf.getPageIndices());
pages.forEach(p => finalPdf.addPage(p));
} catch (e) { console.warn(`⚠️ PDF non intégrable: ${file.originalname} — ${e.message}`); }
} else if (mimetype.startsWith('image/')) {
try {
const page = finalPdf.addPage([595, 842]);
const img = mimetype === 'image/png' ? await finalPdf.embedPng(file.buffer) : await finalPdf.embedJpg(file.buffer);
const maxW = 495, maxH = 742;
const ratio = Math.min(maxW / img.width, maxH / img.height);
const w = img.width * ratio, h = img.height * ratio;
page.drawImage(img, { x: (595 - w) / 2, y: (842 - h) / 2, width: w, height: h });
} catch (e) { console.warn(`⚠️ Image non intégrable: ${file.originalname} — ${e.message}`); }
}
}
return Buffer.from(await finalPdf.save());
}
// GET /api/notes/:id/download-urls — préchargement des URLs directes
app.get('/api/notes/:id/download-urls', authenticateToken, async (req, res) => {
try {
const noteId = parseInt(req.params.id);
// Récupérer les fichiers de la note
const noteResult = await pool.request()
.input('id', sql.Int, noteId)
.query(`SELECT fichiers, lignesJson FROM NoteDeFrais WHERE id = @id`);
if (!noteResult.recordset.length) return res.json({});
const note = noteResult.recordset[0];
let allUrls = [];
// Fichiers globaux
try {
const fichiers = JSON.parse(note.fichiers || '[]');
fichiers.forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); });
} catch { }
// Fichiers des lignes
try {
const lignes = JSON.parse(note.lignesJson || '[]');
lignes.forEach(l => {
(l.qrFiles || []).forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); });
});
} catch { }
if (!allUrls.length) return res.json({});
const accessToken = await getGraphToken();
const result = {};
// ✅ Graph Batch — résout toutes les URLs en UNE SEULE requête HTTP
const batchRequests = allUrls.slice(0, 20).map((url, i) => {
const urlObj = new URL(url);
const fullPath = decodeURIComponent(urlObj.pathname);
const marker = '/Shared Documents/';
const markerAlt = '/Documents/';
let relativePath = '';
if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1];
else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1];
else {
const parts = fullPath.split('/sites/')[1]?.split('/');
relativePath = parts?.slice(2).join('/') || '';
}
return {
id: String(i),
method: 'GET',
url: `/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`
};
});
const batchRes = await axios.post(
'https://graph.microsoft.com/v1.0/$batch',
{ requests: batchRequests },
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }
);
for (const response of batchRes.data.responses) {
const i = parseInt(response.id);
const downloadUrl = response.body?.['@microsoft.graph.downloadUrl'];
if (downloadUrl && allUrls[i]) {
result[allUrls[i]] = downloadUrl;
// Mettre en cache côté serveur aussi
downloadUrlCache.set(allUrls[i], {
url: downloadUrl,
expiresAt: Date.now() + 50 * 60 * 1000
});
}
}
res.json(result);
} catch (error) {
console.error('GET /api/notes/:id/download-urls:', error.message);
res.json({}); // Fail silencieux — le client tombera sur le proxy normal
}
});
// ══════════════════════════════════════════════════════
// POST /api/notes — Créer une note de frais (multi-lignes)
// ══════════════════════════════════════════════════════
app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => {
try {
const { libelle, date, description, participants, nombreParticipants, lignes, qrNoteRef } = req.body;
if (!libelle || !date || !lignes)
return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' });
let lignesParsed;
try {
lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes;
} catch {
return res.status(400).json({ error: 'Format des lignes invalide' });
}
if (!Array.isArray(lignesParsed) || lignesParsed.length === 0)
return res.status(400).json({ error: 'Au moins une ligne est obligatoire' });
// ── Tarif KM ─────────────────────────────────────────────────────
let tarifKm = await getTarifKm();
try {
const annee = new Date().getFullYear();
const kmParam = await pool.request()
.input('annee', sql.Int, annee)
.query(`SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC`);
if (kmParam.recordset[0]?.tarifParKm) tarifKm = parseFloat(kmParam.recordset[0].tarifParKm);
} catch { }
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm);
const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0);
const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0);
const indemKm = lignesParsed.reduce((s, l) => {
if ((l.categorie || '').toLowerCase().includes('kilom'))
return s + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
return s;
}, 0);
const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2));
const montantFormate = montantFinal.toFixed(2);
const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0);
const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple';
// ── Collaborateur + hiérarchie ────────────────────────────────────
const collabResult = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`);
if (!collabResult.recordset.length)
return res.status(404).json({ error: 'Collaborateur non trouvé' });
const collaborateur = collabResult.recordset[0];
const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`;
const dateObj = new Date(date);
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
const hierarchie = await pool.request()
.input('collabId', sql.Int, req.user.id)
.query(`
SELECT h.SuperieurId,
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
WHERE h.CollaborateurId = @collabId
`);
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9_]/g, '_');
const now = new Date();
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// ── Collecte des fichiers (QR global) ────────────────────────────
const allFiles = [...(req.files || [])];
if (qrNoteRef) {
const qrToken = await pool.request()
.input('noteRef', sql.NVarChar, qrNoteRef)
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
if (qrToken.recordset.length && qrToken.recordset[0].fichiers) {
const qrFichiers = JSON.parse(qrToken.recordset[0].fichiers);
const qrDownloads = await Promise.all(
qrFichiers.map(f =>
downloadFromSharePoint(f.uploadUrl)
.then(buf => ({
buffer: buf,
originalname: f.fileName,
mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
size: buf.length
}))
.catch(e => { console.warn('⚠️ QR global download fail:', e.message); return null; })
)
);
qrDownloads.filter(Boolean).forEach(f => allFiles.push(f));
}
}
// ── QR par ligne : téléchargement + upload en parallèle ──────────
await Promise.all(
lignesParsed.map(async (ligne, i) => {
const ligneQrRef = ligne.qrNoteRef;
if (!ligneQrRef) return;
try {
const qrLigne = await pool.request()
.input('noteRef', sql.NVarChar, ligneQrRef)
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
if (!qrLigne.recordset.length || !qrLigne.recordset[0].fichiers) {
console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`);
return;
}
const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers);
if (!ligne.qrFiles) ligne.qrFiles = [];
await Promise.all(
qrFichiers.map(async f => {
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
const fileObj = {
buffer: buf,
originalname: f.fileName,
mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg',
size: buf.length
};
allFiles.push(fileObj);
const uploaded = await uploadToSharePointHierarchique(fileObj, reference, nomDossier, moisDossier);
const dejaSauve = ligne.qrFiles.some(x => x.fileName === uploaded.fileName);
if (!dejaSauve) {
ligne.qrFiles.push({ fileName: uploaded.fileName, uploadUrl: uploaded.uploadUrl });
}
console.log(`✅ QR ligne ${i} stocké: ${uploaded.fileName}`);
} catch (e) {
console.warn(`⚠️ QR ligne ${i} download/upload fail:`, e.message);
}
})
);
} catch (e) {
console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message);
}
})
);
// ── Upload fichiers soumis directement par ligne (files_${depId}) ──
// Identique à la logique du PUT /api/notes/brouillons/:id
const perLigneFieldnameSet = new Set();
const perLigneUploaded = [];
for (const file of req.files || []) {
const match = (file.fieldname || '').match(/^files_(.+)$/);
if (!match) continue;
const depId = String(match[1]);
perLigneFieldnameSet.add(file.fieldname);
// Trouver la ligne par son id frontend (stocké dans lignesJson)
const li = lignesParsed.findIndex(l => String(l.id) === depId);
try {
const up = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
perLigneUploaded.push(up);
if (li >= 0) {
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
const dejaSauve = lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName);
if (!dejaSauve) {
lignesParsed[li].qrFiles.push({
fileName: up.fileName,
uploadUrl: up.uploadUrl,
origin: 'upload'
});
}
console.log(`✅ Fichier injecté ligne ${li}: ${up.fileName}`);
} else {
console.warn(`⚠️ Fichier ${file.originalname} : aucune ligne trouvée pour depId=${depId}`);
}
} catch (e) {
console.error(`❌ Upload ligne file ${file.originalname}:`, e.message);
}
}
// lignesJsonFinal APRÈS injection des qrFiles dans chaque ligne
const lignesJsonFinal = JSON.stringify(lignesParsed);
// ── Upload justificatifs restants (QR globaux, hors per-ligne déjà traités) ──
const fichiersUploades = [
...perLigneUploaded,
...(await Promise.all(
allFiles
.filter(file => !perLigneFieldnameSet.has(file.fieldname))
.map(file =>
uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier)
.catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; })
)
)).filter(Boolean)
];
const noteDataPDF = {
reference,
nomPrenom,
mois: moisCapitalized,
date,
categorie: categorieNote,
libelle,
montant: montantFinal,
lignes: lignesParsed,
lignesJson: lignesJsonFinal,
tarifKm,
statut: 'enattente',
departement: collaborateur.departement,
participants: participants || null,
};
// ── Insérer la note en BDD ───────────────────────────────────────
const insertResult = await pool.request()
.input('reference', sql.NVarChar, reference)
.input('collaborateurId', sql.Int, req.user.id)
.input('libelle', sql.NVarChar, libelle)
.input('montant', sql.Decimal, montantFinal)
.input('date', sql.Date, new Date(date))
.input('categorie', sql.NVarChar, categorieNote)
.input('description', sql.NVarChar, description || null)
.input('participants', sql.NVarChar, participants ? String(participants) : null)
.input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null)
.input('sharepointUrl', sql.NVarChar, fichiersUploades[0]?.uploadUrl || null)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades))
.input('statut', sql.NVarChar, 'enattente')
.input('validateurN1Id', sql.Int, n1Id)
.input('montantHT', sql.Decimal, null)
.input('tauxTVA', sql.Decimal, null)
.input('montantTVA21', sql.Decimal, null)
.input('montantTVA55', sql.Decimal, null)
.input('montantTVA10', sql.Decimal, null)
.input('montantTVA20', sql.Decimal, null)
.input('km', sql.Decimal, kmTotal || null)
.input('indemniteKm', sql.Decimal, indemKm || null)
.input('lignesJson', sql.NVarChar, lignesJsonFinal)
.query(`
INSERT INTO NoteDeFrais
(reference, collaborateurId, libelle, montant, date, categorie,
description, participants, nombreParticipants, sharepointUrl, fichiers,
statut, validateurN1Id,
montantHT, tauxTVA, montantTVA21, montantTVA55, montantTVA10, montantTVA20,
km, indemniteKm, lignesJson)
OUTPUT INSERTED.id, INSERTED.reference
VALUES
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
@description, @participants, @nombreParticipants, @sharepointUrl, @fichiers,
@statut, @validateurN1Id,
@montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20,
@km, @indemniteKm, @lignesJson)
`);
const noteCreee = insertResult.recordset[0];
// ── Insérer les lignes ────────────────────────────────────────────
for (let i = 0; i < lignesParsed.length; i++) {
const l = lignesParsed[i];
const pdf = lignesPDF[i];
try {
await pool.request()
.input('noteId', sql.Int, noteCreee.id)
.input('numPiece', sql.Int, i + 1)
.input('date', sql.Date, new Date(l.date))
.input('nature', sql.NVarChar, l.categorie || '')
.input('libelle', sql.NVarChar, l.libelle || '')
.input('km', sql.Decimal, pdf.km || null)
.input('montantTTC', sql.Decimal, pdf.montantTTC || null)
.input('tva21', sql.Decimal, pdf.tva21 || null)
.input('tva55', sql.Decimal, pdf.tva55 || null)
.input('tva10', sql.Decimal, pdf.tva10 || null)
.input('tva20', sql.Decimal, pdf.tva20 || null)
.input('montantHT', sql.Decimal, pdf.montantHT || null)
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
.input('indemniteKm', sql.Decimal, pdf.indemniteKm || null)
.query(`
INSERT INTO LigneNoteDeFrais
(noteDeFraisId, numPiece, date, nature, libelle,
km, montantTTC, tva21, tva55, tva10, tva20,
montantHT, tauxTVA, indemniteKm)
VALUES
(@noteId, @numPiece, @date, @nature, @libelle,
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
@montantHT, @tauxTVA, @indemniteKm)
`);
} catch (e) { console.error(`❌ Insertion ligne ${i + 1}:`, e.message); }
}
console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`);
res.status(201).json({
success: true,
id: noteCreee.id,
reference: noteCreee.reference,
fichiers: fichiersUploades,
recapUrl: null,
pending: true,
});
// ── Traitement lourd en arrière-plan ─────────────────────────────
setImmediate(async () => {
const fichiersAsync = [...fichiersUploades];
try {
console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`);
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
let ficheResult = null;
try {
const fichePDF = await generateFicheSignee(
noteDataPDF,
[{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: null }]
);
ficheResult = await uploadToSharePointHierarchique(
{ buffer: fichePDF, originalname: `${reference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
reference, nomDossier, moisDossier
);
fichiersAsync.push(ficheResult);
console.log(`✅ [ASYNC] Fiche soumission: ${ficheResult.fileName}`);
} catch (e) { console.error('❌ [ASYNC] Fiche PDF:', e.message); }
let recapUrl = null;
try {
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles);
const recapResult = await uploadToSharePointHierarchique(
{ buffer: recapBuffer, originalname: `${reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length },
reference, nomDossier, moisDossier
);
fichiersAsync.push(recapResult);
recapUrl = recapResult.uploadUrl;
console.log(`✅ [ASYNC] Récap PDF: ${recapResult.fileName}`);
} catch (e) { console.error('❌ [ASYNC] Récap PDF:', e.message); }
try {
await pool.request()
.input('id', sql.Int, noteCreee.id)
.input('sharepointUrl', sql.NVarChar, recapUrl || ficheResult?.uploadUrl || null)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersAsync))
.query(`
UPDATE NoteDeFrais SET
sharepointUrl = @sharepointUrl,
fichiers = @fichiers,
DateModification = GETDATE()
WHERE id = @id
`);
} catch (e) { console.error('❌ [ASYNC] UPDATE BDD fichiers:', e.message); }
await Promise.all([
creerNotification({
destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission',
titre: `✅ Note ${reference} soumise avec succès`,
message: `Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été reçue.`,
noteId: noteCreee.id
}).catch(e => console.error('❌ [ASYNC] Notif BDD collab:', e.message)),
n1Id ? creerNotification({
destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation',
titre: `📋 Note à valider — ${reference}`,
message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de ${montantFormate} € en attente de votre validation.`,
noteId: noteCreee.id
}).catch(e => console.error('❌ [ASYNC] Notif BDD N1:', e.message)) : Promise.resolve(),
]);
await Promise.all([
sendMailGraph(
collaborateur.email,
`✅ Accusé de réception — Note ${reference}`,
`
✅ Note de frais bien reçue
Bonjour ${collaborateur.prenom} ${collaborateur.nom},
Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été enregistrée.
${nomN1 ? `
Validateur : ${prenomN1} ${nomN1}
` : ''}
${recapUrl ? `
📎 Voir le récapitulatif PDF
` : ''}
`
).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)),
(n1Id && emailN1) ? sendMailGraph(
emailN1,
`📋 Note de frais à valider — ${reference}`,
`
📋 Note de frais à valider
Bonjour ${prenomN1} ${nomN1},
${collaborateur.prenom} ${collaborateur.nom} a soumis la note ${reference} — ${libelle} (${montantFormate} €).
${recapUrl ? `
📎 Récapitulatif + justificatifs
` : ''}
`
).catch(e => console.error('❌ [ASYNC] Email N1:', e.message)) : Promise.resolve(),
]);
console.log(`✅ [ASYNC] Traitement terminé pour ${reference}`);
} catch (e) {
console.error(`❌ [ASYNC] Erreur générale ${reference}:`, e.message);
}
});
} catch (error) {
console.error('❌ Erreur POST /api/notes:', error.message);
res.status(500).json({ error: error.message });
}
});
app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => {
try {
const noteId = parseInt(req.params.id);
const userId = req.user.id;
const noteCheck = await pool.request()
.input('id', sql.Int, noteId)
.input('collabId', sql.Int, userId)
.query(`
SELECT * FROM NoteDeFrais
WHERE id = @id AND collaborateurId = @collabId
AND statut IN ('enattente', 'refuse', 'refuse_verif', 'non_conforme_verif', 'brouillon')
`);
if (!noteCheck.recordset.length)
return res.status(403).json({ error: 'Note introuvable ou non modifiable (statut incompatible)' });
const noteExist = noteCheck.recordset[0];
const estCorrection = noteExist.statut === 'refuse'
|| noteExist.statut === 'refuse_verif'
|| noteExist.statut === 'non_conforme_verif';
const { libelle, date, description, participants, nombreParticipants, lignes } = req.body;
if (!libelle || !date || !lignes)
return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' });
let lignesParsed;
try { lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; }
catch { return res.status(400).json({ error: 'Format des lignes invalide' }); }
// ── Calculs communs ───────────────────────────────────────────────────
const montantFinal = lignesParsed.reduce((total, l) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
if (isKm) {
const km = parseFloat(l.km) || 0;
const cv = parseInt(l.chevaux) || 7;
return total + getIndemniteKmServer(km, cv);
}
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
return total + items.reduce((s, i) => s + (parseFloat(i.montantTTC) || 0), 0);
}, 0);
const indemKm = lignesParsed.reduce((s, l) => {
if ((l.categorie || '').toLowerCase().includes('kilom'))
return s + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
return s;
}, 0);
const kmTotal = lignesParsed.reduce((s, l) =>
s + ((l.categorie || '').toLowerCase().includes('kilom') ? (parseFloat(l.km) || 0) : 0), 0
);
const isKmOnly = lignesParsed.every(l => (l.categorie || '').toLowerCase().includes('kilom'));
const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple';
const collabResult = await pool.request()
.input('id', sql.Int, userId)
.query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`);
const collaborateur = collabResult.recordset[0];
const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`;
const dateObj = new Date(date);
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}`
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9_]/g, '_');
const now = new Date();
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const tarifKmVal = await getTarifKm();
// ════════════════════════════════════════════════════════════════════
// CAS 1 — Note REFUSÉE ou NON_CONFORME_VERIF → créer une NOUVELLE note
// ════════════════════════════════════════════════════════════════════
if (estCorrection) {
const statutArchive = noteExist.statut === 'non_conforme_verif'
? 'non_conforme_archive'
: noteExist.statut === 'refuse_verif'
? 'refuse_verif_archive'
: 'refuse_archive';
await pool.request()
.input('id', sql.Int, noteId)
.input('statutArchive', sql.NVarChar, statutArchive)
.query(`
UPDATE NoteDeFrais SET
statut = @statutArchive,
DateModification = GETDATE()
WHERE id = @id
`);
const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
// ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
const fichiersUploades = [];
const perLigneFieldnameSetCas1 = new Set();
for (const file of req.files || []) {
const match = (file.fieldname || '').match(/^files_(.+)$/);
if (!match) continue;
const depId = String(match[1]);
perLigneFieldnameSetCas1.add(file.fieldname);
const li = lignesParsed.findIndex(l => String(l.id) === depId);
try {
const up = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
fichiersUploades.push(up);
if (li >= 0) {
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
if (!lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName)) {
lignesParsed[li].qrFiles.push({ fileName: up.fileName, uploadUrl: up.uploadUrl, origin: 'upload' });
}
console.log(`✅ [CAS1] Fichier injecté ligne ${li}: ${up.fileName}`);
}
} catch (e) { console.error(`❌ Upload justif correction ligne ${file.originalname}:`, e.message); }
}
// Fichiers globaux non per-ligne
for (const file of req.files || []) {
if (perLigneFieldnameSetCas1.has(file.fieldname)) continue;
try {
const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
fichiersUploades.push(r);
} catch (e) { console.error(`❌ Upload justif correction global ${file.originalname}:`, e.message); }
}
// QR par ligne
for (let i = 0; i < lignesParsed.length; i++) {
const ligneQrRef = lignesParsed[i].qrNoteRef;
if (!ligneQrRef) continue;
try {
const qrLigne = await pool.request()
.input('noteRef', sql.NVarChar, ligneQrRef)
.query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`);
if (qrLigne.recordset.length && qrLigne.recordset[0].fichiers) {
const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers);
for (const f of qrFichiers) {
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
const r = await uploadToSharePointHierarchique(
{ buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length },
nouvelleReference, nomDossier, moisDossier
);
fichiersUploades.push(r);
} catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); }
}
}
} catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); }
}
// lignesJsonCorrige APRÈS injection
const lignesJsonCorrige = JSON.stringify(lignesParsed);
// Générer fiche PDF
const noteDataPDF = {
reference: nouvelleReference, nomPrenom, mois: moisCapitalized, date,
categorie: categorieNote, libelle,
montant: parseFloat(montantFinal.toFixed(2)),
lignes: lignesParsed, lignesJson: lignesJsonCorrige,
tarifKm: tarifKmVal, statut: 'enattente',
departement: collaborateur.departement,
};
try {
const commentairePDF = noteExist.statut === 'non_conforme_verif'
? `Correction suite à non-conformité signalée sur ${noteExist.reference}`
: `Correction suite au refus de ${noteExist.reference}`;
const fichePDF = await generateFicheSignee(noteDataPDF, [{
niveau: 'COLLAB', nomPrenom, date: new Date(),
action: 'soumettre', commentaire: commentairePDF
}]);
const ficheResult = await uploadToSharePointHierarchique(
{ buffer: fichePDF, originalname: `${nouvelleReference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
nouvelleReference, nomDossier, moisDossier
);
fichiersUploades.push(ficheResult);
} catch (e) { console.error('❌ Fiche PDF correction:', e.message); }
const hierarchie = await pool.request()
.input('collabId', sql.Int, userId)
.query(`
SELECT h.SuperieurId,
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
WHERE h.CollaborateurId = @collabId
`);
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
const insertResult = await pool.request()
.input('reference', sql.NVarChar, nouvelleReference)
.input('collaborateurId', sql.Int, userId)
.input('libelle', sql.NVarChar, libelle)
.input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2)))
.input('date', sql.Date, new Date(date))
.input('categorie', sql.NVarChar, categorieNote)
.input('description', sql.NVarChar, description || null)
.input('participants', sql.NVarChar, participants ? String(participants) : null)
.input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades))
.input('statut', sql.NVarChar, 'enattente')
.input('validateurN1Id', sql.Int, n1Id)
.input('km', sql.Decimal, kmTotal || null)
.input('indemniteKm', sql.Decimal, indemKm || null)
.input('lignesJson', sql.NVarChar, lignesJsonCorrige)
.input('noteRefuseeId', sql.Int, noteId)
.query(`
INSERT INTO NoteDeFrais
(reference, collaborateurId, libelle, montant, date, categorie,
description, participants, nombreParticipants,
fichiers, statut, validateurN1Id,
km, indemniteKm, lignesJson, noteRefuseeId,
DateCreation, DateModification)
OUTPUT INSERTED.id, INSERTED.reference
VALUES
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
@description, @participants, @nombreParticipants,
@fichiers, @statut, @validateurN1Id,
@km, @indemniteKm, @lignesJson, @noteRefuseeId,
GETDATE(), GETDATE())
`);
const nouvelleNote = insertResult.recordset[0];
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal);
for (let i = 0; i < lignesParsed.length; i++) {
const l = lignesParsed[i];
const pdf = lignesPDF[i] || {};
const isKmLine = (l.categorie || '').toLowerCase().includes('kilom');
const kmLine = parseFloat(l.km) || 0;
const cvLine = parseInt(l.chevaux) || 7;
const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0;
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
try {
await pool.request()
.input('noteId', sql.Int, nouvelleNote.id)
.input('numPiece', sql.Int, i + 1)
.input('date', sql.Date, new Date(l.date))
.input('nature', sql.NVarChar, l.categorie || '')
.input('libelle', sql.NVarChar, l.libelle || '')
.input('km', sql.Decimal, isKmLine ? kmLine : null)
.input('montantTTC', sql.Decimal, ttcLine || null)
.input('tva21', sql.Decimal, pdf.tva21 || null)
.input('tva55', sql.Decimal, pdf.tva55 || null)
.input('tva10', sql.Decimal, pdf.tva10 || null)
.input('tva20', sql.Decimal, pdf.tva20 || null)
.input('montantHT', sql.Decimal, pdf.montantHT || null)
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
.input('indemniteKm', sql.Decimal, indemLine || null)
.query(`
INSERT INTO LigneNoteDeFrais
(noteDeFraisId, numPiece, date, nature, libelle,
km, montantTTC, tva21, tva55, tva10, tva20,
montantHT, tauxTVA, indemniteKm)
VALUES
(@noteId, @numPiece, @date, @nature, @libelle,
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
@montantHT, @tauxTVA, @indemniteKm)
`);
} catch (e) { console.error(`❌ Insertion ligne correction ${i + 1}:`, e.message); }
}
if (n1Id && emailN1) {
try {
const titreNotif = `📋 Note corrigée à valider — ${nouvelleReference}`;
const msgNotif = `${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée.
Ancienne référence : ${noteExist.reference} (${statutArchive})
Nouvelle référence : ${nouvelleReference}
Montant : ${parseFloat(montantFinal.toFixed(2))} €`;
await creerNotification({
destinataireId: n1Id, destinataireEmail: emailN1,
type: 'validation', titre: titreNotif, message: msgNotif,
noteId: nouvelleNote.id
});
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
const couleurHeader = noteExist.statut === 'non_conforme_verif'
? 'linear-gradient(135deg,#f97316,#ea580c)'
: 'linear-gradient(135deg,#f59e0b,#d97706)';
const contexte = noteExist.statut === 'non_conforme_verif'
? `suite à la non-conformité signalée sur ${noteExist.reference}`
: `suite au refus de ${noteExist.reference}`;
await sendMailGraph(emailN1, `📋 Note corrigée à valider — ${nouvelleReference}`,
`
📋 Note corrigée — à valider
Bonjour ${prenomN1} ${nomN1},
${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée ${contexte}.
Nouvelle référence : ${nouvelleReference} — ${parseFloat(montantFinal.toFixed(2))} €
`
);
} catch (e) { console.error('❌ Notif N1 correction:', e.message); }
}
console.log(`✅ Note corrigée ${nouvelleReference} créée (remplace ${noteExist.reference} → ${statutArchive}) par ${collaborateur.email}`);
return res.json({
success: true,
id: nouvelleNote.id,
reference: nouvelleReference,
statut: 'enattente',
isCorrection: true,
ancienneReference: noteExist.reference
});
}
// ════════════════════════════════════════════════════════════════════
// CAS 2 — Note EN ATTENTE → modifier sur place
// ════════════════════════════════════════════════════════════════════
const reference = noteExist.reference;
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { }
// ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
const perLigneFieldnameSetCas2 = new Set();
for (const file of req.files || []) {
const match = (file.fieldname || '').match(/^files_(.+)$/);
if (!match) continue;
const depId = String(match[1]);
perLigneFieldnameSetCas2.add(file.fieldname);
const li = lignesParsed.findIndex(l => String(l.id) === depId);
try {
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
fichiersExistants.push(r);
if (li >= 0) {
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
if (!lignesParsed[li].qrFiles.some(f => f.fileName === r.fileName)) {
lignesParsed[li].qrFiles.push({ fileName: r.fileName, uploadUrl: r.uploadUrl, origin: 'upload' });
}
console.log(`✅ [CAS2] Fichier injecté ligne ${li}: ${r.fileName}`);
}
} catch (e) { console.error(`❌ Upload justif modif ligne ${file.originalname}:`, e.message); }
}
// Fichiers globaux non per-ligne
for (const file of req.files || []) {
if (perLigneFieldnameSetCas2.has(file.fieldname)) continue;
try {
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
fichiersExistants.push(r);
} catch (e) { console.error(`❌ Upload justif modif global ${file.originalname}:`, e.message); }
}
const noteDataPDF = {
reference, nomPrenom, mois: moisCapitalized, date,
categorie: categorieNote, libelle,
montant: parseFloat(montantFinal.toFixed(2)),
lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed),
tarifKm: tarifKmVal, statut: 'enattente',
departement: collaborateur.departement,
};
try {
const fichePDF = await generateFicheSignee(noteDataPDF, [
{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: 'Note modifiée et resoumise' }
]);
const ficheResult = await uploadToSharePointHierarchique(
{ buffer: fichePDF, originalname: `${reference}_resoumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length },
reference, nomDossier, moisDossier
);
fichiersExistants.push(ficheResult);
} catch (e) { console.error('❌ Fiche PDF re-soumission:', e.message); }
await pool.request()
.input('id', sql.Int, noteId)
.input('libelle', sql.NVarChar, libelle)
.input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2)))
.input('date', sql.Date, new Date(date))
.input('categorie', sql.NVarChar, categorieNote)
.input('description', sql.NVarChar, description || null)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
.input('statut', sql.NVarChar, 'enattente')
.input('km', sql.Decimal, kmTotal || null)
.input('indemniteKm', sql.Decimal, indemKm || null)
.input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed))
.query(`
UPDATE NoteDeFrais SET
libelle = @libelle, montant = @montant, date = @date,
categorie = @categorie, description = @description,
fichiers = @fichiers, statut = @statut,
km = @km, indemniteKm = @indemniteKm,
lignesJson = @lignesJson,
motifRefus = NULL, commentaireN1 = NULL, commentaireN2 = NULL,
dateValidationN1 = NULL, dateValidationN2 = NULL,
DateModification = GETDATE()
WHERE id = @id
`);
await pool.request()
.input('noteId', sql.Int, noteId)
.query(`DELETE FROM LigneNoteDeFrais WHERE noteDeFraisId = @noteId`);
const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal);
for (let i = 0; i < lignesParsed.length; i++) {
const l = lignesParsed[i];
const pdf = lignesPDF[i] || {};
const isKmLine = (l.categorie || '').toLowerCase().includes('kilom');
const kmLine = parseFloat(l.km) || 0;
const cvLine = parseInt(l.chevaux) || 7;
const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0;
const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }];
const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0);
try {
await pool.request()
.input('noteId', sql.Int, noteId)
.input('numPiece', sql.Int, i + 1)
.input('date', sql.Date, new Date(l.date))
.input('nature', sql.NVarChar, l.categorie || '')
.input('libelle', sql.NVarChar, l.libelle || '')
.input('km', sql.Decimal, isKmLine ? kmLine : null)
.input('montantTTC', sql.Decimal, ttcLine || null)
.input('tva21', sql.Decimal, pdf.tva21 || null)
.input('tva55', sql.Decimal, pdf.tva55 || null)
.input('tva10', sql.Decimal, pdf.tva10 || null)
.input('tva20', sql.Decimal, pdf.tva20 || null)
.input('montantHT', sql.Decimal, pdf.montantHT || null)
.input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null)
.input('indemniteKm', sql.Decimal, indemLine || null)
.query(`
INSERT INTO LigneNoteDeFrais
(noteDeFraisId, numPiece, date, nature, libelle,
km, montantTTC, tva21, tva55, tva10, tva20,
montantHT, tauxTVA, indemniteKm)
VALUES
(@noteId, @numPiece, @date, @nature, @libelle,
@km, @montantTTC, @tva21, @tva55, @tva10, @tva20,
@montantHT, @tauxTVA, @indemniteKm)
`);
} catch (e) { console.error(`❌ Insertion ligne modif ${i + 1}:`, e.message); }
}
const hierarchie = await pool.request()
.input('collabId', sql.Int, userId)
.query(`
SELECT h.SuperieurId, s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
WHERE h.CollaborateurId = @collabId
`);
const n1 = hierarchie.recordset[0];
if (n1?.SuperieurId && n1?.emailN1) {
try {
await creerNotification({
destinataireId: n1.SuperieurId, destinataireEmail: n1.emailN1,
type: 'validation',
titre: `📋 Note modifiée à valider — ${reference}`,
message: `${collaborateur.prenom} ${collaborateur.nom} a modifié et resoumis la note ${reference} (${parseFloat(montantFinal.toFixed(2))} €).`,
noteId
});
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
await sendMailGraph(n1.emailN1, `📋 Note modifiée à valider — ${reference}`,
`
📋 Note modifiée — à valider
Bonjour ${n1.prenomN1} ${n1.nomN1},
${collaborateur.prenom} ${collaborateur.nom} a modifié et resoumis la note ${reference} — ${libelle} (${parseFloat(montantFinal.toFixed(2))} €).
`
);
} catch (e) { console.error('❌ Notif N1 modif:', e.message); }
}
console.log(`✅ Note ${reference} modifiée par ${collaborateur.email}`);
res.json({ success: true, id: noteId, reference, statut: 'enattente', isCorrection: false });
} catch (error) {
console.error('❌ Erreur PUT /api/notes/:id:', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// GET /api/notes
// ================================================
app.get('/api/notes', authenticateToken, async (req, res) => {
try {
const { statut, mois, annee } = req.query;
const request = pool.request()
.input('collaborateurId', sql.Int, req.user.id);
let where = 'WHERE n.collaborateurId = @collaborateurId';
if (statut) {
request.input('statut', sql.NVarChar, statut);
where += ' AND n.statut = @statut';
}
if (mois && annee) {
request.input('mois', sql.Int, parseInt(mois));
request.input('annee', sql.Int, parseInt(annee));
where += ' AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee';
}
const result = await request.query(`
SELECT n.*,
v1.nom + ' ' + v1.prenom as nomValidateurN1
FROM NoteDeFrais n
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
${where}
ORDER BY n.DateCreation DESC
`);
const notes = result.recordset;
if (!notes.length) return res.json([]);
// ── 1. Une seule requête pour tous les non-conformes ─────────────
const noteIds = notes.map(n => n.id).join(',');
let ncByNote = {};
try {
const ncAll = await pool.request().query(`
SELECT noteDeFraisId, fileName, motif, statut, dateSignalement
FROM JustificatifsNonConformes
WHERE noteDeFraisId IN (${noteIds})
ORDER BY dateSignalement DESC
`);
for (const row of ncAll.recordset) {
if (!ncByNote[row.noteDeFraisId]) ncByNote[row.noteDeFraisId] = [];
ncByNote[row.noteDeFraisId].push(row);
}
} catch (e) { console.warn('⚠️ NC batch fetch:', e.message); }
// ── 2. Collecter tous les qrNoteRef qui manquent encore de qrFiles
const allQrRefs = new Set();
for (const note of notes) {
if (!note.lignesJson) continue;
try {
const lignes = JSON.parse(note.lignesJson);
for (const l of lignes) {
if (l.qrNoteRef && !(l.qrFiles?.length)) allQrRefs.add(l.qrNoteRef);
}
} catch { }
}
// ── 3. Une seule requête pour tous les tokens QR manquants ───────
let qrByRef = {};
if (allQrRefs.size > 0) {
const refsStr = [...allQrRefs]
.map(r => `'${r.replace(/'/g, "''")}'`)
.join(',');
try {
const qrAll = await pool.request().query(`
SELECT noteRef, fichiers
FROM UploadTokens
WHERE noteRef IN (${refsStr}) AND used = 1
`);
for (const row of qrAll.recordset) {
try { qrByRef[row.noteRef] = JSON.parse(row.fichiers || '[]'); } catch { }
}
} catch (e) { console.warn('⚠️ QR batch fetch:', e.message); }
}
// ── 4. Enrichissement en mémoire — zéro requête SQL ──────────────
for (const note of notes) {
// Parser fichiers → sharepointFiles
try { note.sharepointFiles = JSON.parse(note.fichiers || '[]'); } catch { note.sharepointFiles = []; }
// Non-conformes depuis le batch
if (note.statut === 'non_conforme_verif') {
note.nonConformes = ncByNote[note.id] || [];
}
// Enrichissement QR en mémoire
if (note.lignesJson) {
try {
const lignes = JSON.parse(note.lignesJson);
let enrichi = false;
const lignesEnrichies = lignes.map(l => {
if (l.qrFiles?.length > 0) return l;
if (!l.qrNoteRef) return l;
const fichiers = qrByRef[l.qrNoteRef];
if (fichiers?.length) { enrichi = true; return { ...l, qrFiles: fichiers }; }
return l;
});
if (enrichi) note.lignesJson = JSON.stringify(lignesEnrichies);
} catch (e) { console.warn('⚠️ Enrichissement QR fail:', note.id, e.message); }
}
}
const notesAvecMontant = notes.map(n => ({
...n,
montant: recalculerMontantNote(n),
}));
res.json(notesAvecMontant);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================
// 🔑 TOKEN SHAREPOINT (scope différent de Graph)
// ================================================
async function getSharePointToken() {
const now = Date.now();
if (_sharePointTokenCache && now < _sharePointTokenCache.expiresAt) {
return _sharePointTokenCache.token;
}
try {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
scope: 'https://ensup.sharepoint.com/.default'
});
const response = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const token = response.data.access_token;
_sharePointTokenCache = { token, expiresAt: now + 55 * 60 * 1000 };
console.log('✅ Token SharePoint mis en cache (55 min)');
return token;
} catch (error) {
console.error('❌ Erreur token SharePoint:', error.response?.data || error.message);
return null;
}
}
// routes/sharepoint.js
// ✅ route proxy-file — force les bons headers
app.get('/api/proxy-pdf', async (req, res) => {
let url = req.query.url;
if (!url) return res.status(400).send('URL manquante');
try { url = decodeURIComponent(url); } catch { }
if (url.includes('/api/proxy-pdf?url=')) {
url = url.split('/api/proxy-pdf?url=')[1];
try { url = decodeURIComponent(url); } catch { }
}
if (!url.startsWith('http')) return res.status(400).send('URL invalide');
// ✅ Cache HIT → redirection instantanée vers CDN
const cached = downloadUrlCache.get(url);
if (cached && Date.now() < cached.expiresAt) {
res.setHeader('Cache-Control', 'private, max-age=3600');
return res.redirect(302, cached.url);
}
try {
const accessToken = await getGraphToken();
const urlObj = new URL(url);
const fullPath = decodeURIComponent(urlObj.pathname);
const marker = '/Shared Documents/';
const markerAlt = '/Documents/';
let relativePath = '';
if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1];
else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1];
else {
const parts = fullPath.split('/sites/')[1]?.split('/');
relativePath = parts?.slice(2).join('/') || '';
}
const metaRes = await axios.get(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000 // ← 5000 → 15000ms
}
);
const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl'];
if (!downloadUrl) throw new Error('downloadUrl absent');
downloadUrlCache.set(url, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 });
res.setHeader('Cache-Control', 'private, max-age=3600');
return res.redirect(302, downloadUrl);
} catch (err) {
console.error('proxy-pdf erreur:', err.message);
res.status(500).json({ error: err.message });
}
});
// ================================================
// GET /api/notes/pending
// ================================================
app.get('/api/notes/pending', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.user.id)
.query(`
SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut,
n.description, n.participants, n.nombreParticipants,
n.montantHT, n.tauxTVA, n.km, n.indemniteKm,
n.commentaireN1, n.commentaireN2, n.sharepointUrl, n.fichiers,
n.lignesJson,
n.noteRefuseeId,
ancienne.reference AS ancienneReference,
ancienne.motifRefus AS ancienMotifRefus,
c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId
WHERE (n.validateurN1Id = @userId AND n.statut = 'enattente')
ORDER BY n.date DESC
`);
// Pour chaque note, récupérer les lignes depuis LigneNoteDeFrais
// si lignesJson est vide
const notes = result.recordset;
for (const note of notes) {
// ✅ Parser fichiers → sharepointFiles pour le frontend
if (note.fichiers) {
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
} else { note.sharepointFiles = []; }
// ✅ Toujours re-parser lignesJson depuis la BDD pour avoir les qrFiles à jour
// Ne reconstruire depuis LigneNoteDeFrais qu'en dernier recours
// ✅ Enrichir chaque ligne avec ses fichiers QR depuis UploadTokens
if (note.lignesJson) {
try {
const lignes = JSON.parse(note.lignesJson);
let enrichi = false;
const lignesEnrichies = await Promise.all(lignes.map(async (l) => {
if (l.qrFiles && l.qrFiles.length > 0) return l;
const qrRef = l.qrNoteRef || '';
if (!qrRef) return l;
try {
const qrResult = await pool.request()
.input('noteRef', sql.NVarChar, qrRef)
.query(`SELECT TOP 1 fichiers FROM UploadTokens
WHERE noteRef = @noteRef AND used = 1
ORDER BY expiresAt DESC`);
if (qrResult.recordset.length && qrResult.recordset[0].fichiers) {
const fichiers = JSON.parse(qrResult.recordset[0].fichiers);
if (fichiers.length > 0) {
enrichi = true;
return { ...l, qrFiles: fichiers };
}
}
} catch (e) { }
return l;
}));
if (enrichi) {
note.lignesJson = JSON.stringify(lignesEnrichies);
}
} catch (e) {
console.warn('⚠️ Enrichissement QR fail:', note.id, e.message);
}
}
}
const notesAvecMontant = notes.map(n => ({
...n,
montant: recalculerMontantNote(n),
}));
res.json(notesAvecMontant);
} catch (e) {
console.error('Erreur /api/notes/pending:', e.message);
res.status(500).json({ error: e.message });
}
});
// ================================================
// GET /api/notes/:id/lignes
// ================================================
app.get('/api/notes/:id/lignes', authenticateToken, async (req, res) => {
try {
const isValidator = hasAnyRole(req.user, 'Finance', 'Validateur', 'Validatrice', 'superUtilisateur') ? 1 : 0;
const result = await pool.request()
.input('noteId', sql.Int, req.params.id)
.input('userId', sql.Int, req.user.id)
.input('isVal', sql.Int, isValidator)
.query(`
SELECT l.*
FROM LigneNoteDeFrais l
JOIN NoteDeFrais n ON n.id = l.noteDeFraisId
WHERE l.noteDeFraisId = @noteId
AND (n.collaborateurId = @userId OR @isVal = 1)
ORDER BY l.numPiece ASC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/notes/:id/lignes-refusees-n1', authenticateToken, async (req, res) => {
try {
const noteId = parseInt(req.params.id);
// Vérifier accès : collaborateur propriétaire ou validateur ou Finance
const noteCheck = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT collaborateurId, validateurN1Id
FROM NoteDeFrais WHERE id = @id
`);
if (!noteCheck.recordset.length)
return res.status(404).json({ error: 'Note introuvable' });
const note = noteCheck.recordset[0];
const isOwner = note.collaborateurId === req.user.id;
const isN1 = note.validateurN1Id === req.user.id;
const isFinance = hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'ValidateurFinance', 'superUtilisateur');
if (!isOwner && !isN1 && !isFinance)
return res.status(403).json({ error: 'Accès refusé' });
const result = await pool.request()
.input('noteId', sql.Int, noteId)
.query(`
SELECT lr.ligneIndex, lr.ligneLibelle, lr.ligneCategorie,
lr.motif, lr.statut, lr.dateRefus,
c.prenom + ' ' + c.nom AS verificateur
FROM LignesRefusees lr
JOIN CollaborateurAD c ON c.id = lr.verificateurId
WHERE lr.noteDeFraisId = @noteId
AND lr.statut = 'active'
ORDER BY lr.ligneIndex ASC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/notes/:id/detail — récupère une note par ID (pour validateur + collaborateur)
app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => {
try {
const noteId = parseInt(req.params.id);
const userId = req.user.id;
const result = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT n.*,
c.prenom + ' ' + c.nom AS collaborateur,
c.departement, c.campus,
ancienne.reference AS ancienneReference,
ancienne.motifRefus AS ancienMotifRefus
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId
WHERE n.id = @id
AND (
n.collaborateurId = ${userId}
OR n.validateurN1Id = ${userId}
OR EXISTS (
SELECT 1 FROM UtilisateurRoles r
WHERE r.collaborateur_id = ${userId}
AND r.role IN ('Finance','superUtilisateur','VerificateurFinance','ValidateurFinance')
AND r.actif = 1
)
)
`);
if (!result.recordset.length)
return res.status(404).json({ error: 'Note introuvable ou accès refusé' });
const note = result.recordset[0];
if (note.fichiers) {
try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; }
} else { note.sharepointFiles = []; }
// juste avant res.json(notes);
const notesAvecMontant = notes.map(n => ({
...n,
montant: recalculerMontantNote(n),
}));
res.json(notesAvecMontant);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================
// PUT /api/notes/:id/statut — Valider ou refuser
// ================================================
app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// lignesDecisions = [{ ligneIndex, statut: 'valide'|'refuse', commentaire }]
const { action, commentaire, motifRefus, lignesDecisions } = req.body;
const userId = Number(req.user.id);
const noteResult = await pool.request()
.input('id', sql.Int, id)
.query('SELECT * FROM NoteDeFrais WHERE id = @id');
if (!noteResult.recordset.length)
return res.status(404).json({ error: 'Note non trouvée' });
const note = noteResult.recordset[0];
const n1Id = Number(note.validateurN1Id);
const statutNote = note.statut?.trim();
// Seul le N1 peut valider une note 'enattente'
if (!(n1Id === userId && statutNote === 'enattente'))
return res.status(403).json({ error: 'Non autorisé à valider cette note' });
// --- Déterminer le nouveau statut ---
// Si lignesDecisions fourni, on fait une validation ligne par ligne
let nouveauStatut;
let lignesRefuseesFinal = [];
if (Array.isArray(lignesDecisions) && lignesDecisions.length > 0) {
// Vérifier si au moins une ligne est refusée
const lignesRefusees = lignesDecisions.filter(l => l.statut === 'refuse');
const lignesValides = lignesDecisions.filter(l => l.statut === 'valide');
if (lignesRefusees.length === 0) {
// Toutes les lignes sont validées → approuvé
nouveauStatut = 'approuve';
} else {
// Au moins une ligne refusée → refus global avec détail
nouveauStatut = 'refuse';
lignesRefuseesFinal = lignesRefusees;
}
} else {
// Comportement legacy (validation globale sans ligne)
nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
}
// --- Construire le commentaire synthétique ---
let commentaireFinal = commentaire || null;
let motifRefusFinal = motifRefus || null;
if (lignesRefuseesFinal.length > 0) {
// Parser les lignes de la note pour avoir les libellés
let lignesData = [];
try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
const detailRefus = lignesRefuseesFinal.map(l => {
const ligne = lignesData[l.ligneIndex] || {};
const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
return `• ${label} — ${l.commentaire || 'Non conforme'}`;
}).join('\n');
motifRefusFinal = `${lignesRefuseesFinal.length} ligne(s) refusée(s) :\n${detailRefus}`;
commentaireFinal = motifRefusFinal;
}
// --- Transaction : mise à jour note + LignesRefusees ---
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
// 1. Mettre à jour la note
await new sql.Request(transaction)
.input('id', sql.Int, id)
.input('statut', sql.NVarChar, nouveauStatut)
.input('commentaire', sql.NVarChar, commentaireFinal)
.input('motifRefus', sql.NVarChar, motifRefusFinal)
.query(`
UPDATE NoteDeFrais
SET statut = @statut,
dateValidationN1 = GETDATE(),
commentaireN1 = @commentaire,
motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END,
DateModification = GETDATE()
WHERE id = @id
`);
// 2. Si des lignes sont refusées, les enregistrer dans LignesRefusees
if (lignesRefuseesFinal.length > 0) {
let lignesData = [];
try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
// Archiver les anciens refus actifs si re-validation
await new sql.Request(transaction)
.input('noteId', sql.Int, id)
.query(`
UPDATE LignesRefusees
SET statut = 'archive'
WHERE noteDeFraisId = @noteId AND statut = 'active'
`);
// Insérer les nouveaux refus
for (const l of lignesRefuseesFinal) {
const ligne = lignesData[l.ligneIndex] || {};
await new sql.Request(transaction)
.input('noteId', sql.Int, id)
.input('ligneIndex', sql.Int, l.ligneIndex)
.input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`)
.input('ligneCategorie', sql.NVarChar, ligne.categorie || null)
.input('motif', sql.NVarChar, l.commentaire || 'Non conforme')
.input('verificateurId', sql.Int, userId)
.query(`
INSERT INTO LignesRefusees
(noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut)
VALUES
(@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active')
`);
}
}
// 3. Historique de validation
await new sql.Request(transaction)
.input('noteId', sql.Int, id)
.input('validateurId', sql.Int, userId)
.input('niveau', sql.NVarChar, 'N1')
.input('action', sql.NVarChar, nouveauStatut === 'approuve' ? 'valider' : 'refuser')
.input('commentaire', sql.NVarChar, commentaireFinal)
.input('motifRefus', sql.NVarChar, motifRefusFinal)
.input('statut', sql.NVarChar, nouveauStatut)
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, MotifRefus, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE())
`);
await transaction.commit();
} catch (e) {
try { await transaction.rollback(); } catch { }
throw e;
}
// Répondre immédiatement
res.json({
success: true,
statut: nouveauStatut,
niveau: 'N1',
nbLignesRefusees: lignesRefuseesFinal.length,
nbLignesValidees: Array.isArray(lignesDecisions)
? lignesDecisions.filter(l => l.statut === 'valide').length
: (nouveauStatut === 'approuve' ? 1 : 0)
});
// --- Traitement asynchrone : PDFs + emails ---
setImmediate(async () => {
try {
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
const montantFormate = parseFloat(note.montant).toFixed(2);
const [collabResult, validateurResult, noteCompleteResult] = await Promise.all([
pool.request().input('id', sql.Int, note.collaborateurId)
.query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'),
pool.request().input('id', sql.Int, userId)
.query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'),
pool.request().input('id', sql.Int, id).query(`
SELECT n.reference, n.libelle, n.montant, n.date, n.categorie,
n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson,
n.commentaireN1, n.dateValidationN1,
c.prenom + ' ' + c.nom AS nomPrenom,
c.prenom AS collabPrenom, c.nom AS collabNom, c.departement,
v1.prenom + ' ' + v1.nom AS nomValidateurN1
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
WHERE n.id = @id
`)
]);
const c = collabResult.recordset[0];
const v = validateurResult.recordset[0];
const nd = noteCompleteResult.recordset[0];
if (!c || !nd) return;
console.log('🔍 nd.montant =', nd.montant);
console.log('🔍 note.montant =', note.montant);
const nomValidateurActuel = v
? `${v.prenom} ${v.nom}`.trim()
: `${req.user.prenom} ${req.user.nom}`.trim();
// Construire les signatures pour le PDF
const signatures = [
{ niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null },
{ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action: nouveauStatut === 'approuve' ? 'valider' : 'refuser', commentaire: commentaireFinal }
];
const moisStr = (() => {
if (!nd.date) return '';
const d = new Date(nd.date);
const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return m.charAt(0).toUpperCase() + m.slice(1);
})();
const noteDataPDF = {
reference: nd.reference,
nomPrenom: nd.nomPrenom,
mois: moisStr,
departement: nd.departement,
lignesJson: nd.lignesJson,
tarifKm: await getTarifKm(),
statut: nouveauStatut,
montant: parseFloat(nd.montant),
};
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { }
const existingFolder = fichiersExistants[0]?.folderPath;
let nomDossier = existingFolder
? existingFolder.split('/')[1]
: `${nd.collabNom}_${nd.collabPrenom}`
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9_]/g, '_');
let moisDossier = existingFolder
? existingFolder.split('/')[2]
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
// Génération PDF signé
try {
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse';
const signedResult = await uploadToSharePointHierarchique(
{ buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length },
nd.reference, nomDossier, moisDossier
);
fichiersExistants.push(signedResult);
await pool.request().input('id', sql.Int, id).input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
} catch (pdfError) {
console.error('❌ [ASYNC] PDF signé N1:', pdfError.message);
}
// Régénérer le recap
try {
const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap'];
const justifFiles = [];
for (const f of fichiersExistants) {
const fname = (f.fileName || '').toLowerCase();
if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue;
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
const mimetype = fname.endsWith('.pdf') ? 'application/pdf' : fname.endsWith('.png') ? 'image/png' : 'image/jpeg';
justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length });
} catch { }
}
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures);
const recapResult = await uploadToSharePointHierarchique(
{ buffer: recapBuffer, originalname: `${nd.reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length },
nd.reference, nomDossier, moisDossier
);
const fichiersAvecRecap = fichiersExistants.filter(f => {
const fname = (f.fileName || '').toLowerCase();
return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement');
});
fichiersAvecRecap.push(recapResult);
await pool.request().input('id', sql.Int, id).input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap))
.query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id');
} catch (recapError) {
console.error('❌ [ASYNC] Recap N1:', recapError.message);
}
// --- Emails + notifications ---
const isApprouve = nouveauStatut === 'approuve';
const isRefus = nouveauStatut === 'refuse';
// Construire le tableau HTML des lignes refusées (pour l'email)
let lignesRefuseesHtml = '';
if (lignesRefuseesFinal.length > 0) {
let lignesData = [];
try { lignesData = JSON.parse(nd.lignesJson || '[]'); } catch { }
lignesRefuseesHtml = `
Lignes à corriger (${lignesRefuseesFinal.length})
| N° |
Dépense |
Motif |
${lignesRefuseesFinal.map(l => {
const ligne = lignesData[l.ligneIndex] || {};
const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
const cat = ligne.categorie || '';
return `
| ${l.ligneIndex + 1} |
${label}
${cat ? `${cat} ` : ''}
|
${l.commentaire || 'Non conforme'} |
`;
}).join('')}
`;
}
const titreCollab = isApprouve
? `Note ${note.reference} approuvée ✅`
: `Note ${note.reference} — corrections demandées ❌`;
const msgCollab = isApprouve
? `Votre note ${note.reference} (${montantFormate}€) a été approuvée par ${nomValidateurActuel}.`
: `Votre note ${note.reference} a été refusée par ${nomValidateurActuel}. ${lignesRefuseesFinal.length} ligne(s) à corriger.`;
const emailCollabHtml = isRefus ? `
❌ Corrections demandées sur votre note
${lignesRefuseesFinal.length} ligne(s) à corriger
Bonjour ${c.prenom} ${c.nom},
Votre note ${note.reference} a été examinée par ${nomValidateurActuel}. Certaines dépenses nécessitent des corrections avant approbation.
${lignesRefuseesHtml}
📝 Que faire maintenant ?
- Connectez-vous à la plateforme NDF
- Ouvrez la note ${note.reference}
- Corrigez uniquement les lignes listées ci-dessus
- Resoumettez la note
` : `
${titreCollab}
Bonjour ${c.prenom} ${c.nom},
${msgCollab}
`;
await Promise.all([
creerNotification({
destinataireId: c.id,
destinataireEmail: c.email,
type: isRefus ? 'refus' : 'validation',
titre: titreCollab,
message: msgCollab,
noteId: parseInt(id)
}).catch(e => console.error('❌ [ASYNC N1] Notif collab:', e.message)),
sendMailGraph(
c.email,
isRefus ? `❌ Corrections demandées — ${note.reference}` : titreCollab,
emailCollabHtml
).catch(e => console.error('❌ [ASYNC N1] Email collab:', e.message)),
]);
console.log(`✅ [ASYNC N1] Validation terminée note ${id} → ${nouveauStatut} (${lignesRefuseesFinal.length} ligne(s) refusée(s))`);
} catch (e) {
console.error(`❌ [ASYNC N1] Erreur générale validation note ${id}:`, e.message);
}
});
} catch (error) {
console.error('Erreur validation N1:', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// GET /api/notes/:id/historique
// ================================================
app.get('/api/notes/:id/historique', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('noteId', sql.Int, req.params.id)
.query(`
SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus,
h.NouveauStatut, h.DateAction,
v.prenom + ' ' + v.nom AS validateur, v.role AS roleValidateur
FROM HistoriqueValidation h
JOIN CollaborateurAD v ON v.id = h.ValidateurId
WHERE h.NoteDeFraisId = @noteId ORDER BY h.DateAction ASC
`);
res.json(result.recordset);
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// GET /api/validateur/historique
// ================================================
app.get('/api/validateur/historique', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('validateurId', sql.Int, req.user.id)
.query(`
SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus,
h.NouveauStatut, h.DateAction,
n.reference, n.libelle, n.montant, n.categorie,
c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus
FROM HistoriqueValidation h
JOIN NoteDeFrais n ON n.id = h.NoteDeFraisId
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE h.ValidateurId = @validateurId ORDER BY h.DateAction DESC
`);
res.json(result.recordset);
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// GET /api/notes/all — réservé Finance
// ================================================
app.get('/api/notes/all', authenticateToken, async (req, res) => {
try {
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
const result = await pool.request().query(`
SELECT n.*, c.nom + ' ' + c.prenom as collaborateur, c.departement, c.campus,
v1.nom + ' ' + v1.prenom as nomN1
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
ORDER BY n.DateCreation DESC
`);
res.json(result.recordset);
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// GET /api/admin/notes — réservé superUtilisateur
// ================================================
// GET /api/admin/notes — superUtilisateur, filtré sur ENSUP SOLUTION ET SUPPORT
app.get('/api/admin/notes', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé superUtilisateur' });
try {
const { mois, annee, statut } = req.query;
let query = `
SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut,
n.DateCreation, n.montantHT, n.tauxTVA, n.km, n.indemniteKm,
n.sharepointUrl, n.fichiers, n.lignesJson,
c.prenom + ' ' + c.nom AS collaborateur, c.email AS collaborateurEmail,
c.departement, c.campus, c.societe
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE c.societe = 'ENSUP SOLUTION ET SUPPORT'
`;
const req2 = pool.request();
if (mois && annee) {
query += ` AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee`;
req2.input('mois', sql.Int, parseInt(mois));
req2.input('annee', sql.Int, parseInt(annee));
}
if (statut) {
query += ` AND n.statut = @statut`;
req2.input('statut', sql.NVarChar, statut);
}
query += ` ORDER BY c.nom, n.date DESC`;
const result = await req2.query(query);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================
// PAIEMENTS
// ================================================
app.get('/api/paiements/prochain', authenticateToken, async (req, res) => {
try {
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
const today = new Date();
const jour = today.getDate(), mois = today.getMonth(), annee = today.getFullYear();
const datePaiement = jour <= jourPaiement ? new Date(annee, mois, jourPaiement) : new Date(annee, mois + 1, jourPaiement);
res.json({ jourPaiement, datePaiement: datePaiement.toISOString().split('T')[0], libelle: `Paiement le ${datePaiement.toLocaleDateString('fr-FR')}` });
} catch (error) { res.status(500).json({ error: error.message }); }
});
app.post('/api/paiements/marquer-payees', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
const today = new Date();
const mois = today.getMonth() + 1, annee = today.getFullYear();
const datePaiementExacte = new Date(annee, today.getMonth(), jourPaiement);
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, c.nom + ' ' + c.prenom AS collaborateur, c.email
FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.statut = 'approuve' AND n.datePaiement IS NULL
`);
if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note approuvée à payer', count: 0 });
await pool.request()
.input('datePaiement', sql.DateTime, datePaiementExacte)
.input('moisPaiement', sql.Int, mois)
.input('anneePaiement', sql.Int, annee)
.query(`
UPDATE NoteDeFrais SET statut = 'payee', datePaiement = @datePaiement,
moisPaiement = @moisPaiement, anneePaiement = @anneePaiement, DateModification = GETDATE()
WHERE statut = 'approuve' AND datePaiement IS NULL
`);
res.json({ success: true, count: notes.recordset.length, datePaiement: datePaiementExacte.toISOString().split('T')[0], notes: notes.recordset });
} catch (error) { res.status(500).json({ error: error.message }); }
});
app.get('/api/paiements/historique', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur', 'ValidateurFinance', 'VerificateurFinance'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const mois = req.query.mois ? parseInt(req.query.mois) : null;
const annee = req.query.annee ? parseInt(req.query.annee) : null;
const request = pool.request();
request.input('mois', sql.Int, mois);
request.input('annee', sql.Int, annee);
let campusFilter = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusFilter = 'AND c.campus LIKE @campus';
}
}
const result = await request.query(`
SELECT
n.id, n.reference, n.libelle, n.montant,
n.datePaiement, n.moisPaiement, n.anneePaiement,
n.categorie, n.lignesJson, n.indemniteKm, n.km,
n.statut,
c.nom + ' ' + c.prenom AS collaborateur,
c.campus,
c.societe
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.statut = 'payee'
AND (@mois IS NULL OR n.moisPaiement = @mois)
AND (@annee IS NULL OR n.anneePaiement = @annee)
${campusFilter}
ORDER BY n.datePaiement DESC, c.nom
`);
const data = result.recordset.map(row => {
let montantRepas = 0, montantHebergement = 0;
let montantKilometrique = row.indemniteKm || 0;
let montantTransport = 0, montantAutres = 0;
const cat = (row.categorie || '').toLowerCase();
if (cat !== 'multiple') {
if (cat.includes('repas') || cat.includes('restaurant')) montantRepas = row.montant;
else if (cat.includes('hebergement') || cat.includes('hotel')) montantHebergement = row.montant;
else if (cat.includes('kilom') || cat.includes('km')) montantKilometrique = row.indemniteKm || row.montant;
else if (cat.includes('transport') || cat.includes('avion') || cat.includes('train') || cat.includes('taxi')) montantTransport = row.montant;
else montantAutres = row.montant;
} else {
try {
const lignes = JSON.parse(row.lignesJson || '[]');
lignes.forEach(ligne => {
const lcat = (ligne.categorie || ligne.nature || '').toLowerCase();
const montantLigne = parseFloat(ligne.montantTTC || ligne.montant || 0);
if (lcat.includes('repas') || lcat.includes('restaurant')) montantRepas += montantLigne;
else if (lcat.includes('hebergement') || lcat.includes('hotel')) montantHebergement += montantLigne;
else if (lcat.includes('kilom') || lcat.includes('km')) montantKilometrique += parseFloat(ligne.indemniteKm || montantLigne);
else if (lcat.includes('transport') || lcat.includes('avion') || lcat.includes('train') || lcat.includes('taxi')) montantTransport += montantLigne;
else montantAutres += montantLigne;
});
} catch { montantAutres = row.montant; }
}
return {
...row,
montantRepas: Math.round(montantRepas * 100) / 100,
montantHebergement: Math.round(montantHebergement * 100) / 100,
montantKilometrique: Math.round(montantKilometrique * 100) / 100,
montantTransport: Math.round(montantTransport * 100) / 100,
montantAutres: Math.round(montantAutres * 100) / 100,
};
});
res.json(data);
} catch (error) {
console.error('Erreur historique:', error.message);
res.status(500).json({ error: error.message });
}
});
app.get('/api/paiements/config', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`SELECT TOP 1 Id, JourPaiement, Actif, DateModif FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
res.json(result.recordset[0] ?? { JourPaiement: 20 });
} catch (error) { res.status(500).json({ error: error.message }); }
});
app.put('/api/paiements/config', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const { jourPaiement } = req.body;
if (!jourPaiement || jourPaiement < 1 || jourPaiement > 28) return res.status(400).json({ error: 'Jour de paiement invalide (1-28)' });
await pool.request().query(`UPDATE ConfigPaiement SET Actif = 0 WHERE Actif = 1`);
await pool.request().input('jour', sql.Int, jourPaiement).query(`INSERT INTO ConfigPaiement (JourPaiement, Actif, DateModif) VALUES (@jour, 1, GETDATE())`);
res.json({ success: true, jourPaiement });
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// HELPERS EMAIL + NOTIFICATION
// ================================================
async function sendMailGraph(to, subject, htmlBody) {
try {
const accessToken = await getGraphToken();
if (!accessToken) throw new Error('Token Graph indisponible');
const senderEmail = process.env.MAIL_SENDER || process.env.MAIL_FROM;
if (!senderEmail) { console.error('MAIL_SENDER non défini'); return; }
await axios.post(
`https://graph.microsoft.com/v1.0/users/${senderEmail}/sendMail`,
{ message: { subject, body: { contentType: 'HTML', content: htmlBody }, from: { emailAddress: { address: senderEmail } }, toRecipients: [{ emailAddress: { address: to } }] }, saveToSentItems: false },
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }
);
console.log(`✅ Email envoyé à ${to} via ${senderEmail}`);
} catch (error) {
console.error('Erreur sendMailGraph:', error.response?.data || error.message);
}
}
async function creerNotification({ destinataireId, destinataireEmail, type, titre, message, noteId }) {
if (!destinataireId) {
console.error('❌ creerNotification annulée : destinataireId manquant');
return;
}
try {
await pool.request()
.input('destinataireId', sql.Int, destinataireId)
.input('type', sql.NVarChar, type)
.input('titre', sql.NVarChar, titre)
.input('message', sql.NVarChar, message)
.input('noteId', sql.Int, noteId || null)
.query(`
INSERT INTO Notifications
(CollaborateurId, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation)
VALUES
(@destinataireId, @type, @titre, @message, @noteId, 0, GETDATE())
`);
console.log(`🔔 Notification insérée pour ${destinataireEmail} (id: ${destinataireId})`);
} catch (err) {
console.error('❌ Erreur insertion notification:', err.message);
}
}
// ================================================
// PAIEMENTS — NOTIFIER
// ================================================
app.post('/api/paiements/notifier', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, n.libelle, c.nom + ' ' + c.prenom AS collaborateur
FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.statut = 'approuve' AND n.datePaiement IS NULL
`);
if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note en attente de paiement' });
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const alexandre = await pool.request()
.input('email', sql.NVarChar, process.env.RESPONSABLE_PAIEMENT_EMAIL)
.query(`SELECT TOP 1 id, email, prenom, nom FROM CollaborateurAD WHERE email = @email AND Actif = 1`);
if (!alexandre.recordset.length) return res.status(404).json({ error: 'Responsable paiement introuvable' });
const responsable = alexandre.recordset[0];
const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`);
const jourPaiement = config.recordset[0]?.JourPaiement ?? 20;
const titre = `⚠️ ${notes.recordset.length} note(s) à payer — ${total.toFixed(2)} €`;
await creerNotification({ destinataireId: responsable.id, destinataireEmail: responsable.email, type: 'paiement', titre, message: `${notes.recordset.length} note(s) en attente avant le ${jourPaiement}. Total : ${total.toFixed(2)} €`, noteId: null });
await sendMailGraph(responsable.email, titre, `Bonjour ${responsable.prenom},
${notes.recordset.length} note(s) en attente de paiement avant le ${jourPaiement}. Total : ${total.toFixed(2)} €
`);
res.json({ success: true, count: notes.recordset.length, total: parseFloat(total.toFixed(2)) });
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// NOTIFICATIONS
// ================================================
app.get('/api/notifications', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.user.id)
.query(`
SELECT TOP 50
id, Type, Titre, Message, NoteDeFraisId, Lu,
CONVERT(VARCHAR(23), DateCreation, 126) +
CASE DATEDIFF(HOUR, GETUTCDATE(), GETDATE())
WHEN 2 THEN '+02:00'
WHEN 1 THEN '+01:00'
ELSE '+00:00'
END AS DateCreation
FROM Notifications
WHERE CollaborateurId = @userId
ORDER BY DateCreation DESC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/notifications/:id/lu', authenticateToken, async (req, res) => {
try {
await pool.request().input('id', sql.Int, req.params.id).input('userId', sql.Int, req.user.id)
.query(`UPDATE Notifications SET Lu = 1 WHERE id = @id AND CollaborateurId = @userId`);
res.json({ success: true });
} catch (error) { res.status(500).json({ error: error.message }); }
});
// ================================================
// CATÉGORIES / PARAMÈTRES TVA / KM
// ================================================
app.get('/api/categories', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT c.*, p.PlafondParPersonne, p.DescriptionPlafond
FROM CategorieNDF c LEFT JOIN PlafondRepas p ON p.CategorieId = c.id AND p.Actif = 1
WHERE c.Actif = 1 ORDER BY c.Ordre, c.Nom
`);
res.json(result.recordset);
} catch (error) { res.status(500).json({ error: error.message }); }
});
app.get('/api/parametres/tva', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT id, taux, libelle, categorie FROM ParametresTVA
WHERE actif = 1 AND dateDebut <= GETDATE() AND (dateFin IS NULL OR dateFin >= GETDATE())
ORDER BY taux ASC
`);
res.json(result.recordset);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/parametres/km', authenticateToken, async (req, res) => {
try {
const annee = new Date().getFullYear();
const result = await pool.request().input('annee', sql.Int, annee).query(`
SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC
`);
res.json({ tarifKm: result.recordset[0]?.tarifParKm ?? await getTarifKm() });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ================================================
// QR CODE UPLOAD MOBILE
// ================================================
app.post('/api/upload/generate-link', authenticateToken, async (req, res) => {
try {
const { noteRef } = req.body;
if (!noteRef) return res.status(400).json({ error: 'noteRef obligatoire' });
const collabResult = await pool.request().input('id', sql.Int, req.user.id).query('SELECT nom, prenom FROM CollaborateurAD WHERE id = @id');
if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur introuvable' });
const { nom, prenom } = collabResult.recordset[0];
const token = crypto.randomBytes(32).toString('hex');
await pool.request()
.input('token', sql.VarChar, token)
.input('nomPrenom', sql.NVarChar, `${nom.toUpperCase()}_${prenom}`)
.input('noteRef', sql.NVarChar, noteRef)
.query(`INSERT INTO UploadTokens (token, nomPrenom, noteRef, expiresAt) VALUES (@token, @nomPrenom, @noteRef, DATEADD(MINUTE, 120, GETDATE()))`);
res.json({ uploadLink: `${process.env.FRONTEND_URL}/upload/${token}`, expiresAt: new Date(Date.now() + 120 * 60 * 1000), token });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/upload/check/:token', async (req, res) => {
try {
const valid = await pool.request().input('token', sql.VarChar, req.params.token)
.query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`);
if (!valid.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' });
const t = valid.recordset[0];
res.json({ valid: true, noteRef: t.noteRef, nomPrenom: t.nomPrenom });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/upload/submit/:token', upload.array('files', 10), async (req, res) => {
try {
const result = await pool.request().input('token', sql.VarChar, req.params.token)
.query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`);
if (!result.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' });
const { nomPrenom, noteRef } = result.recordset[0];
const files = req.files;
if (!files || !files.length) return res.status(400).json({ error: 'Aucun fichier reçu' });
const uploaded = [];
for (const file of files) {
const r = await uploadToSharePoint(file, noteRef, nomPrenom);
uploaded.push(r);
}
await pool.request()
.input('token', sql.VarChar, req.params.token)
.input('firstUrl', sql.NVarChar, uploaded[0].uploadUrl)
.input('allFiles', sql.NVarChar, JSON.stringify(uploaded))
.query(`UPDATE UploadTokens SET used = 1, sharepointUrl = @firstUrl, fichiers = @allFiles WHERE token = @token`);
res.json({ success: true, uploaded });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/upload/status/:noteRef', async (req, res) => {
try {
const result = await pool.request().input('noteRef', sql.NVarChar, req.params.noteRef)
.query(`SELECT TOP 1 used, fichiers, sharepointUrl FROM UploadTokens WHERE noteRef = @noteRef ORDER BY expiresAt DESC`);
if (!result.recordset.length) return res.json({ uploaded: false });
const row = result.recordset[0];
const fichiers = row.fichiers ? JSON.parse(row.fichiers) : [];
res.json({
uploaded: row.used === true || row.used === 1,
files: fichiers, // ← ajout pour le frontend
fichiers: fichiers, // ← conservé pour rétrocompat
sharepointUrl: row.sharepointUrl
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// GET /api/notes-all — réservé Finance (filtré par campus) + superUtilisateur (ENSUP SOLUTION ET SUPPORT)
app.get('/api/notes-all', authenticateToken, async (req, res) => {
try {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance'))
return res.status(403).json({ error: 'Accès réservé Finance' });
const request = pool.request();
let campusWhere = '';
if (req.user.campus) {
const campusCode = normalizeCampus(req.user.campus);
// Variantes de recherche par campus normalisé
const campusVariants = {
'SQY': ['%SQY%', '%SAINT%'],
'CGY': ['%CGY%', '%CERGY%'],
'MRS': ['%MRS%', '%MARSEILLE%'],
'NTE': ['%NTE%', '%NANTES%'],
};
const variants = campusVariants[campusCode] || [`%${campusCode}%`];
// Construire les conditions OR pour chaque variante
const conditions = variants.map((v, i) => {
request.input(`campus${i}`, sql.NVarChar, v);
return `c.campus LIKE @campus${i}`;
});
campusWhere = `AND (${conditions.join(' OR ')})`;
}
const statutFilter = hasAnyRole(req.user, 'ValidateurFinance') && !hasAnyRole(req.user, 'Finance')
? `AND LOWER(n.statut) IN ('verifie', 'en_attente_president', 'paiementenattente', 'payee')`
: `AND LOWER(REPLACE(n.statut COLLATE Latin1_General_CI_AI, ' ', '')) IN (
'approuve', 'approuv', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee'
)`;
const result = await request.query(`
SELECT n.*,
c.nom + ' ' + c.prenom AS collaborateur,
c.departement, c.campus, c.societe,
v1.nom + ' ' + v1.prenom AS nomN1,
vf.nom + ' ' + vf.prenom AS nomVerificateur,
n.dateVerification, n.commentaireVerification
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId
WHERE 1=1 ${statutFilter} ${campusWhere}
ORDER BY n.DateCreation DESC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /api/paiements/generer-xml
// Body: { noteIds: number[] }
// POST /api/paiements/generer-xml — Génère le XML PAIN.001 et passe statut à 'paiementenattente'
app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
const { noteIds } = req.body;
if (!Array.isArray(noteIds) || noteIds.length === 0)
return res.status(400).json({ error: 'Aucune note sélectionnée' });
try {
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, n.libelle, n.date,
n.fichiers, n.lignesJson, n.categorie, n.DateCreation,
c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe,
c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays,
c.id AS collabId, c.email
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
AND n.statut IN ('approuve', 'approuvé', 'verifie')
`);
if (!notes.recordset.length)
return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' });
// ── Validation des données AVANT de générer quoi que ce soit ─────
// On fait toutes les vérifications en une seule requête SQL (batch)
const checksResult = await pool.request().query(`
SELECT id AS collabId, IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays,
nom, prenom
FROM CollaborateurAD
WHERE id IN (${notes.recordset.map(n => n.collabId).join(',')})
`);
const checksMap = {};
for (const c of checksResult.recordset) checksMap[c.collabId] = c;
const erreurs = [];
for (const n of notes.recordset) {
const c = checksMap[n.collabId];
if (!c) { erreurs.push(`${n.reference} : collaborateur introuvable`); continue; }
if (!c.IBAN) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`);
if (!c.BIC) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`);
if (!c.adresse_rue || !c.adresse_cp || !c.adresse_ville || !c.adresse_pays)
erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`);
}
if (erreurs.length > 0)
return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs });
const now = new Date();
const annee = now.getFullYear();
const mois = String(now.getMonth() + 1).padStart(2, '0');
const todayISO = now.toISOString().split('T')[0];
const creDtTm = now.toISOString().slice(0, 19);
const msgId = `NDF-${annee}${mois}-${Date.now().toString().slice(-7)}`;
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const totalFormate = total.toFixed(2);
// Détecter le campus dominant des notes sélectionnées
const campusDominant = (() => {
const campusCounts = {};
for (const n of notes.recordset) {
const code = normalizeCampus(n.campus || '') || n.campus || '';
if (code) campusCounts[code] = (campusCounts[code] || 0) + 1;
}
// Campus le plus fréquent parmi les notes
return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
})();
const cfg = await getConfigDebiteur(campusDominant);
console.log(`🏦 Config débiteur utilisée : ${cfg.companyName} (campus: ${campusDominant || 'global'})`);
const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic,
companyAddress: dbtrAdrLine, companyCp: dbtrCp,
companyVille: dbtrVille, companyPays: dbtrPays } = cfg;
// ── Générer les transactions XML ──────────────────────────────────
let transactions = '';
for (const n of notes.recordset) {
let ibanClair = 'FR0000000000000000000000000';
try {
if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban);
else if (n.iban) ibanClair = n.iban;
} catch (e) {
console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message);
}
const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`;
const adrLine = (n.adresse_rue || '').toUpperCase();
const cp = n.adresse_cp || '';
const ville = (n.adresse_ville || '').toUpperCase();
const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase();
const benefBicBlock = n.bic
? `${n.bic}`
: `NOTPROVIDED`;
transactions += `
VIREMENT NUM:${n.reference}
${n.reference}
${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
${benefNom}${adrLine || cp || ville ? `
${cp ? `
${cp}` : ''}${ville ? `
${ville}` : ''}
${pays}${adrLine ? `
${adrLine}` : ''}
` : ''}
${pays}
${ibanClair}
`;
}
const xml = `
${msgId}
${creDtTm}
${notes.recordset.length}
${totalFormate}
${dbtrNom}
${msgId}
TRF
true
${notes.recordset.length}
${totalFormate}
SEPA
${todayISO}
${dbtrNom}
${dbtrCp}
${dbtrVille}
${dbtrPays}
${dbtrAdrLine}
${dbtrIban}
NOTPROVIDED
SLEV${transactions}
`;
// ── Passer en 'paiementenattente' + enregistrer date XML ─────────
// Fait AVANT res.send pour que le statut soit correct immédiatement
await pool.request()
.input('dateXml', sql.DateTime, now)
.query(`
UPDATE NoteDeFrais
SET statut = 'paiementenattente',
dateXml = @dateXml,
DateModification = GETDATE()
WHERE id IN (${idList})
AND statut IN ('approuve', 'approuvé', 'verifie')
`);
// ── Réponse immédiate — le client reçoit le XML sans attendre ────
const xmlFileName = `virements-ndf-${annee}-${mois}-${todayISO}.xml`;
res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1');
res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`);
res.send(xml);
// ── Tout le reste en arrière-plan (non bloquant) ─────────────────
setImmediate(async () => {
console.log(`🔄 [ASYNC] Post-XML : SharePoint + PDFs + notifs pour ${notes.recordset.length} note(s)...`);
// 1. Upload XML sur SharePoint
try {
const spXmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`;
const xmlUploadPath = `Virements/${annee}/${mois}/${spXmlFileName}`;
const accessToken = await getGraphToken();
if (accessToken) {
await axios.put(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`,
Buffer.from(xml, 'utf-8'),
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml' }, maxBodyLength: Infinity }
);
console.log(`✅ [ASYNC] XML uploadé sur SharePoint : ${xmlUploadPath}`);
}
} catch (spErr) {
console.error('⚠️ [ASYNC] Upload XML SharePoint échoué :', spErr.message);
}
// 2. Générer les PDFs récap + notifier en parallèle par note
const tarifKm = await getTarifKm();
await Promise.allSettled(notes.recordset.map(async (note) => {
try {
// PDFs récap
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(note.fichiers || '[]'); } catch { }
const justifFiles = [];
for (const f of fichiersExistants) {
const name = (f.fileName || '').toLowerCase();
if (name.includes('soumission') || name.includes('resoumission') || name.includes('recap')) continue;
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
const mimetype = name.endsWith('.pdf') ? 'application/pdf'
: name.endsWith('.png') ? 'image/png' : 'image/jpeg';
justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length });
} catch (e) { console.warn(`⚠️ [ASYNC] Justif non récupérable: ${f.fileName}`, e.message); }
}
const dateObj = new Date(note.date);
const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1);
const nomPrenom = `${note.nom.toUpperCase()} ${note.prenom}`;
const histResult = await pool.request()
.input('noteId', sql.Int, note.id)
.query(`
SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction,
c.prenom + ' ' + c.nom AS nomPrenom
FROM HistoriqueValidation h
JOIN CollaborateurAD c ON c.id = h.ValidateurId
WHERE h.NoteDeFraisId = @noteId
ORDER BY h.DateAction ASC
`);
const signatures = [
{ niveau: 'COLLAB', nomPrenom, date: note.DateCreation || new Date(), action: 'soumettre', commentaire: null },
...histResult.recordset.map(h => ({
niveau: h.Niveau, nomPrenom: h.nomPrenom,
date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null
}))
];
const noteDataPDF = {
reference: note.reference, nomPrenom, mois: moisCapitalized,
date: note.date, categorie: note.categorie || 'Multiple',
libelle: note.libelle, montant: parseFloat(note.montant),
lignesJson: note.lignesJson, tarifKm,
statut: note.statut, departement: note.departement,
};
const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures);
const existingFolder = fichiersExistants[0]?.folderPath;
const nomDossier = existingFolder
? existingFolder.split('/')[1]
: `${note.prenom}_${note.nom}`.replace(/[^a-zA-Z0-9]/g, '_');
const moisDossier = existingFolder
? existingFolder.split('/')[2]
: `${annee}-${mois}`;
const recapResult = await uploadToSharePointHierarchique(
{ buffer: recapBuffer, originalname: `${note.reference}_recap-paiement.pdf`, mimetype: 'application/pdf', size: recapBuffer.length },
note.reference, nomDossier, moisDossier
);
fichiersExistants.push(recapResult);
await pool.request()
.input('id', sql.Int, note.id)
.input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants))
.input('recapUrl', sql.NVarChar, recapResult.uploadUrl)
.query(`
UPDATE NoteDeFrais
SET fichiers = @fichiers, sharepointUrl = @recapUrl, DateModification = GETDATE()
WHERE id = @id
`);
console.log(`✅ [ASYNC] PDF récap-paiement généré : ${note.reference}`);
} catch (pdfErr) {
console.error(`❌ [ASYNC] PDF récap ${note.reference}:`, pdfErr.message);
}
// Notification collaborateur (indépendante du PDF)
try {
await creerNotification({
destinataireId: note.collabId,
destinataireEmail: note.email,
type: 'paiement',
titre: `Paiement en cours de traitement : ${note.reference}`,
message: `Votre note ${note.reference} de ${parseFloat(note.montant).toFixed(2)} € est en cours de traitement bancaire.`,
noteId: note.id
});
} catch (e) { console.error(`❌ [ASYNC] Notif ${note.reference}:`, e.message); }
}));
console.log(`✅ [ASYNC] Traitement post-XML terminé`);
});
} catch (error) {
console.error('Erreur génération XML:', error.message);
res.status(500).json({ error: error.message });
}
});
// GET /api/paiements/xml-historique — liste les XML générés
app.get('/api/paiements/xml-historique', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur', 'President'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const { annee, mois } = req.query;
const request = pool.request();
let where = `WHERE n.dateXml IS NOT NULL AND n.statut IN ('paiementenattente', 'payee')`;
if (annee) { request.input('annee', sql.Int, parseInt(annee)); where += ` AND YEAR(n.dateXml) = @annee`; }
if (mois) { request.input('mois', sql.Int, parseInt(mois)); where += ` AND MONTH(n.dateXml) = @mois`; }
let campusWhere = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur', 'President')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusWhere = `AND c.campus LIKE @campus`;
}
}
const result = await request.query(`
SELECT
CONVERT(NVARCHAR(16), n.dateXml, 120) AS dateXmlJour, -- "2026-05-22 14:32"
MIN(n.dateXml) AS dateXmlExacte,
COUNT(*) AS nbNotes,
SUM(n.montant) AS totalMontant,
STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds,
STRING_AGG(n.reference, ', ') AS listeReferences,
MAX(CASE WHEN n.presidentId IS NOT NULL
THEN p.prenom + ' ' + p.nom ELSE NULL END) AS presidentNom,
MAX(n.dateValidationPresident) AS dateValidationPresident,
MAX(n.commentairePresident) AS commentairePresident
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD p ON p.id = n.presidentId
${where} ${campusWhere}
GROUP BY CONVERT(NVARCHAR(16), n.dateXml, 120)
ORDER BY CONVERT(NVARCHAR(16), n.dateXml, 120) DESC
`);
res.json(result.recordset);
} catch (error) {
console.error('GET /api/paiements/xml-historique:', error.message);
res.status(500).json({ error: error.message });
}
});
// POST /api/paiements/regenerer-xml — régénère le XML pour un batch
app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
const { noteIds } = req.body;
if (!Array.isArray(noteIds) || noteIds.length === 0)
return res.status(400).json({ error: 'Aucune note sélectionnée' });
try {
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, n.libelle, n.dateXml, n.lignesJson, c.nom, c.prenom, c.iban, c.bic
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
AND n.statut IN ('paiementenattente', 'payee')
`);
if (!notes.recordset.length)
return res.status(404).json({ error: 'Notes introuvables' });
const now = new Date();
const annee = now.getFullYear();
const mois = String(now.getMonth() + 1).padStart(2, '0');
const todayISO = now.toISOString().split('T')[0];
const creDtTm = now.toISOString().slice(0, 19);
const msgId = `NDF-REGEN-${annee}${mois}-${Date.now().toString().slice(-7)}`;
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0).toFixed(2);
const campusDominant = (() => {
const campusCounts = {};
for (const n of notes.recordset) {
const code = normalizeCampus(n.campus || '') || n.campus || '';
if (code) campusCounts[code] = (campusCounts[code] || 0) + 1;
}
return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
})();
const cfg = await getConfigDebiteur(campusDominant);
const dbtrNom = cfg.companyName;
const dbtrIban = cfg.companyIban;
const dbtrBic = cfg.companyBic;
const dbtrAdrLine = cfg.companyAddress;
const dbtrCp = cfg.companyCp;
const dbtrVille = cfg.companyVille;
const dbtrPays = cfg.companyPays;
let transactions = '';
for (const n of notes.recordset) {
let ibanClair = 'FR0000000000000000000000000';
try {
if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban);
else if (n.iban) ibanClair = n.iban;
} catch { }
const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`;
const benefBicBlock = n.bic
? `${n.bic}`
: `NOTPROVIDED`;
transactions += `
VIREMENT NUM:${n.reference}
${n.reference}
${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
${benefNom}
${ibanClair}
`;
}
const xml = `
${msgId}
${creDtTm}
${notes.recordset.length}
${total}
${dbtrNom}
${msgId}
TRF
true
${notes.recordset.length}
${total}
SEPA
${todayISO}
${dbtrNom}
${dbtrCp}
${dbtrVille}
${dbtrPays}
${dbtrAdrLine}
${dbtrIban}
NOTPROVIDED
SLEV${transactions}
`;
const xmlFileName = `virements-ndf-REGEN-${todayISO}-${Date.now().toString().slice(-5)}.xml`;
res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1');
res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`);
res.send(xml);
} catch (error) {
console.error('Erreur régénération XML:', error.message);
res.status(500).json({ error: error.message });
}
});
// GET /api/paiements/config-debiteur
// GET — récupérer toutes les configs actives (une par campus)
app.get('/api/paiements/config-debiteur', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const result = await pool.request().query(`
SELECT id, companyName, companyIban, companyBic,
companyAddress, companyCp, companyVille, companyPays,
campus, DateModification
FROM ConfigDebiteurXML WHERE actif = 1
ORDER BY CASE WHEN campus IS NULL THEN 1 ELSE 0 END, campus
`);
res.json(result.recordset);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// PUT — créer/remplacer la config pour un campus donné
app.put('/api/paiements/config-debiteur', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
const { companyName, companyIban, companyBic, companyAddress,
companyCp, companyVille, companyPays, campus } = req.body;
if (!companyName || !companyIban || !companyBic)
return res.status(400).json({ error: 'Nom, IBAN et BIC sont obligatoires' });
const ibanClean = companyIban.replace(/\s+/g, '').toUpperCase();
const bicClean = companyBic.replace(/\s+/g, '').toUpperCase();
const campusCode = campus ? (normalizeCampus(campus) || campus) : null;
try {
// Désactiver uniquement la config du même campus
if (campusCode) {
await pool.request()
.input('campus', sql.NVarChar, campusCode)
.query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus = @campus`);
} else {
await pool.request()
.query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus IS NULL`);
}
await pool.request()
.input('companyName', sql.NVarChar, companyName.trim())
.input('companyIban', sql.NVarChar, ibanClean)
.input('companyBic', sql.NVarChar, bicClean)
.input('companyAddress', sql.NVarChar, (companyAddress || '').trim())
.input('companyCp', sql.NVarChar, (companyCp || '').trim())
.input('companyVille', sql.NVarChar, (companyVille || '').trim())
.input('companyPays', sql.NVarChar, (companyPays || 'FR').trim().slice(0, 2).toUpperCase())
.input('campus', sql.NVarChar, campusCode)
.input('modifiePar', sql.Int, req.user.id)
.query(`
INSERT INTO ConfigDebiteurXML
(companyName, companyIban, companyBic, companyAddress,
companyCp, companyVille, companyPays, campus, actif, modifiePar,
DateCreation, DateModification)
VALUES
(@companyName, @companyIban, @companyBic, @companyAddress,
@companyCp, @companyVille, @companyPays, @campus, 1, @modifiePar,
GETDATE(), GETDATE())
`);
res.json({ success: true, campus: campusCode, companyName, companyIban: ibanClean });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// PUT /api/paiements/config-debiteur
// POST /api/paiements/confirmer-paiement
// Body: { noteIds: number[], datePaiement: string (ISO) }
// POST /api/paiements/confirmer-paiement — Confirme paiement et passe statut à 'payee'
app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
const { noteIds, datePaiement } = req.body;
if (!Array.isArray(noteIds) || noteIds.length === 0)
return res.status(400).json({ error: 'Aucune note sélectionnée' });
if (!datePaiement)
return res.status(400).json({ error: 'Date de paiement obligatoire' });
try {
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
const dateObj = new Date(datePaiement);
const mois = dateObj.getMonth() + 1;
const annee = dateObj.getFullYear();
// Récupérer les notes pour notifications
const notes = await pool.request().query(`
SELECT n.id,n.reference,n.montant,n.libelle,n.lignesJson,c.id AS collabId,c.email,c.prenom,c.nom
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
AND n.statut = 'paiementenattente'
`);
if (!notes.recordset.length)
return res.status(404).json({ error: 'Aucune note en attente de paiement trouvée' });
// Mettre à jour statut → 'payee'
await pool.request()
.input('datePaiement', sql.DateTime, dateObj)
.input('moisPaiement', sql.Int, mois)
.input('anneePaiement', sql.Int, annee)
.query(`
UPDATE NoteDeFrais
SET statut = 'payee',
datePaiement = @datePaiement,
moisPaiement = @moisPaiement,
anneePaiement = @anneePaiement,
DateModification = GETDATE()
WHERE id IN (${idList})
AND statut = 'paiementenattente'
`);
// Notifier chaque collaborateur
for (const n of notes.recordset) {
try {
await creerNotification({
destinataireId: n.collabId,
destinataireEmail: n.email,
type: 'paiement',
titre: `Paiement effectué : ${n.reference}`,
message: `Votre note ${n.reference} de ${recalculerMontantNote(n).toFixed(2) }€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`,
noteId: n.id
});
await sendMailGraph(n.email, `Paiement effectué : ${n.reference}`, `
Paiement effectué
Bonjour ${n.prenom} ${n.nom},
Votre note ${n.reference} — ${n.libelle} d'un montant de ${recalculerMontantNote(n).toFixed(2) }€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.
`);
} catch (e) { console.error('Notif paiement confirmé:', e.message); }
}
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
res.json({
success: true,
count: notes.recordset.length,
total: parseFloat(total.toFixed(2)),
datePaiement
});
} catch (error) {
console.error('Erreur confirmer-paiement:', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTES DE TEST
// ================================================
app.get('/api/test-get-drive', async (req, res) => {
try {
const token = await getGraphToken();
const siteId = 'ensup.sharepoint.com,d94abc08-28eb-47ce-8e12-fbbd6f16b9ea,a052c325-d33a-40e3-9e7b-7896a2ea7ab7';
const r = await axios.get(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, { headers: { Authorization: `Bearer ${token}` } });
res.json({ driveId: r.data.id, name: r.data.name });
} catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); }
});
app.get('/api/test-upload', async (req, res) => {
try {
const token = await getGraphToken();
const testContent = Buffer.from('Test upload NDF - ' + new Date().toISOString());
const path = `Notes de Frais/TEST_Upload/test_${Date.now()}.txt`;
const r = await axios.put(
`https://graph.microsoft.com/v1.0/sites/${process.env.SHAREPOINT_SITE_ID}/drives/${process.env.SHAREPOINT_DRIVE_ID}/root:/${path}:/content`,
testContent,
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' } }
);
res.json({ ok: true, url: r.data.webUrl });
} catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); }
});
// ══════════════════════════════════════════════════════════════════
// BROUILLONS — Sauvegarde serveur (statut = 'brouillon')
// ══════════════════════════════════════════════════════════════════
// GET /api/notes/brouillons — récupère les brouillons du collaborateur connecté
app.get('/api/notes/brouillons', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('collaborateurId', sql.Int, req.user.id)
.query(`
SELECT id, libelle, date, description, montant, categorie,
lignesJson, DateCreation, DateModification
FROM NoteDeFrais
WHERE collaborateurId = @collaborateurId
AND statut = 'brouillon'
ORDER BY DateModification DESC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /api/notes/brouillons — crée un nouveau brouillon
app.post('/api/notes/brouillons', authenticateToken, upload.any(), async (req, res) => {
try {
const { libelle, date, description, lignes } = req.body;
let lignesJson = '[]';
if (lignes) {
lignesJson = typeof lignes === 'string' ? lignes : JSON.stringify(lignes);
}
// Calcul du montant total estimé
let montantEstime = 0;
try {
const tarifKm = await getTarifKm();
const lignesParsed = JSON.parse(lignesJson);
montantEstime = lignesParsed.reduce((acc, l) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km) || 0;
const ttc = isKm
? parseFloat((km * tarifKm).toFixed(2))
: (l.tvaItems?.length
? l.tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0)
: parseFloat(l.montant) || 0);
return acc + ttc;
}, 0);
} catch (e) { /* montant reste 0 */ }
// Référence temporaire pour les brouillons (colonne NOT NULL)
const refBrouillon = `BRO-${req.user.id}-${Date.now().toString().slice(-6)}`;
const insertResult = await pool.request()
.input('collaborateurId', sql.Int, req.user.id)
.input('reference', sql.NVarChar, refBrouillon)
.input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre')
.input('date', sql.Date, date ? new Date(date) : new Date())
.input('description', sql.NVarChar, description || null)
.input('montant', sql.Decimal, montantEstime)
.input('categorie', sql.NVarChar, 'Multiple')
.input('lignesJson', sql.NVarChar, lignesJson)
.input('statut', sql.NVarChar, 'brouillon')
.query(`
INSERT INTO NoteDeFrais
(reference, collaborateurId, libelle, date, description, montant,
categorie, lignesJson, statut, DateCreation, DateModification)
OUTPUT INSERTED.id, INSERTED.DateCreation
VALUES
(@reference, @collaborateurId, @libelle, @date, @description, @montant,
@categorie, @lignesJson, @statut, GETDATE(), GETDATE())
`);
const created = insertResult.recordset[0];
res.status(201).json({ success: true, id: created.id, createdAt: created.DateCreation });
} catch (error) {
console.error('Erreur POST brouillon:', error.message);
res.status(500).json({ error: error.message });
}
});
// PUT /api/notes/brouillons/:id — met à jour un brouillon existant
app.put('/api/notes/brouillons/:id', authenticateToken, upload.any(), async (req, res) => {
try {
const { libelle, date, description, lignes } = req.body;
const brouillonId = parseInt(req.params.id);
// Vérifier que ce brouillon appartient bien à ce collaborateur
const check = await pool.request()
.input('id', sql.Int, brouillonId)
.input('collaborateurId', sql.Int, req.user.id)
.query(`SELECT id FROM NoteDeFrais
WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`);
if (!check.recordset.length) {
return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' });
}
// Récupérer infos collaborateur pour le dossier SharePoint
const collabResult = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`SELECT prenom, nom FROM CollaborateurAD WHERE id = @id`);
const collaborateur = collabResult.recordset[0];
const nomDossier = `${collaborateur.prenom}${collaborateur.nom}`.replace(/[^a-zA-Z0-9]/g, '_');
const now = new Date();
const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const noteRef = `BRO-${brouillonId}`;
let lignesParsed = [];
if (lignes) {
try {
lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes;
} catch (e) { lignesParsed = []; }
}
const uploadedFiles = {};
// ✅ Upload des nouveaux fichiers vers SharePoint et injection dans lignesParsed
const allFiles = req.files || [];
for (const file of allFiles) {
const match = file.fieldname.match(/^files_(.+)$/);
if (!match) continue;
const depId = String(match[1]);
try {
const uploaded = await uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier);
const ligne = lignesParsed.find(l => String(l.id) === depId);
if (ligne) {
if (!Array.isArray(ligne.qrFiles)) ligne.qrFiles = [];
const dejaSauve = ligne.qrFiles.some(f => f.fileName === uploaded.fileName);
if (!dejaSauve) {
// ✅ Stocker avec origin='upload'
ligne.qrFiles.push({
fileName: uploaded.fileName,
uploadUrl: uploaded.uploadUrl,
origin: 'upload' // ✅ AJOUT
});
// ✅ Tracker pour le retour
if (!uploadedFiles[depId]) uploadedFiles[depId] = [];
uploadedFiles[depId].push({
fileName: uploaded.fileName,
uploadUrl: uploaded.uploadUrl,
origin: 'upload'
});
}
}
} catch (e) {
console.error(`Upload brouillon fichier ${file.originalname}:`, e.message);
}
}
// Recalcul montant
let montantEstime = 0;
try {
const tarifKm = await getTarifKm();
montantEstime = lignesParsed.reduce((acc, l) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km) || 0;
const ttc = isKm
? parseFloat((km * tarifKm).toFixed(2))
: (parseFloat(l.montant) || parseFloat(l.tvaItems?.[0]?.montantTTC) || 0);
return acc + ttc;
}, 0);
} catch (e) { /* montant reste 0 */ }
const lignesJson = JSON.stringify(lignesParsed);
await pool.request()
.input('id', sql.Int, brouillonId)
.input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre')
.input('date', sql.Date, date ? new Date(date) : new Date())
.input('description', sql.NVarChar, description || null)
.input('montant', sql.Decimal, montantEstime)
.input('lignesJson', sql.NVarChar, lignesJson)
.query(`
UPDATE NoteDeFrais
SET libelle = @libelle,
date = @date,
description = @description,
montant = @montant,
lignesJson = @lignesJson,
DateModification = GETDATE()
WHERE id = @id
`);
res.json({ success: true, updatedAt: new Date().toISOString(), uploadedFiles });
} catch (error) {
console.error('Erreur PUT brouillon:', error.message);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/notes/brouillons/:id — supprime un brouillon
app.delete('/api/notes/brouillons/:id', authenticateToken, async (req, res) => {
try {
const brouillonId = parseInt(req.params.id);
const check = await pool.request()
.input('id', sql.Int, brouillonId)
.input('collaborateurId', sql.Int, req.user.id)
.query(`SELECT id FROM NoteDeFrais
WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`);
if (!check.recordset.length) {
return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' });
}
await pool.request()
.input('id', sql.Int, brouillonId)
.query(`DELETE FROM NoteDeFrais WHERE id = @id`);
res.json({ success: true });
} catch (error) {
console.error('Erreur DELETE brouillon:', error.message);
res.status(500).json({ error: error.message });
}
});
// ══════════════════════════════════════════════════════════════════
// DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis
// Coller dans server.js après les routes /api/profil
// ══════════════════════════════════════════════════════════════════
// ============================================================
// DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis
// ============================================================
const DOCTYPES = ['rib', 'cartegrise', 'carte_grise', 'permis'];
// GET /api/profil/documents
// GET /api/profil/documents
app.get('/api/profil/documents', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('collabId', sql.Int, req.user.id)
.query(`
SELECT type, fileName, sharepointUrl, dateUpload, DateModification, statut, commentaire
FROM DocumentsCollaborateur
WHERE collaborateurId = @collabId
AND type != 'rib'
`);
const ibanResult = await pool.request()
.input('collabId', sql.Int, req.user.id)
.query(`SELECT IBAN FROM CollaborateurAD WHERE id = @collabId`);
const ibanSaisi = !!(ibanResult.recordset[0]?.IBAN);
const docs = {
rib: ibanSaisi
? { fileName: 'IBAN_saisi', sharepointUrl: '', updatedAt: new Date().toISOString(), statut: 'valide', commentaire: null }
: null,
carte_grise: null,
permis: null
};
for (const row of result.recordset) {
// ✅ Normaliser cartegrise → carte_grise pour le frontend
const frontendKey = row.type === 'cartegrise' ? 'carte_grise' : row.type;
docs[frontendKey] = {
fileName: row.fileName,
sharepointUrl: row.sharepointUrl,
updatedAt: row.DateModification,
statut: row.statut ?? 'en_attente',
commentaire: row.commentaire ?? null
};
}
res.json(docs);
} catch (error) {
console.error('GET /api/profil/documents', error.message);
res.status(500).json({ error: error.message });
}
});
// POST /api/profil/documents/:type — upload ou remplacement
app.post('/api/profil/documents/:type', authenticateToken, upload.single('file'), async (req, res) => {
try {
const rawType = req.params.type;
const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
if (!DOCTYPES.includes(type))
return res.status(400).json({ error: 'Type invalide. Valeurs : rib, cartegrise, permis' });
if (!req.file)
return res.status(400).json({ error: 'Aucun fichier fourni' });
// Infos collaborateur (campus pour notifier la bonne Finance)
const collabResult = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`SELECT prenom, nom, campus FROM CollaborateurAD WHERE id = @id`);
if (!collabResult.recordset.length)
return res.status(404).json({ error: 'Collaborateur introuvable' });
const { prenom, nom, campus } = collabResult.recordset[0];
const nomDossier = `${prenom}${nom}`.replace(/[^a-zA-Z0-9]/g, '_');
const safeFileName = `${type}_${req.file.originalname.replace(/[^a-zA-Z0-9.\-]/g, '_')}`;
const folderPath = `Documents-Profil/${nomDossier}`;
const uploadPath = `${folderPath}/${safeFileName}`;
// Upload SharePoint
const accessToken = await getGraphToken();
if (!accessToken) throw new Error('Token Graph indisponible');
const spRes = await axios.put(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`,
req.file.buffer,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': req.file.mimetype || 'application/octet-stream'
},
maxBodyLength: Infinity
}
);
const sharepointUrl = spRes.data.webUrl;
// UPSERT — remet en attente si document remplacé
await pool.request()
.input('collabId', sql.Int, req.user.id)
.input('type', sql.NVarChar, type)
.input('fileName', sql.NVarChar, safeFileName)
.input('sharepointUrl', sql.NVarChar, sharepointUrl)
.query(`
IF EXISTS (SELECT 1 FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type)
UPDATE DocumentsCollaborateur
SET fileName = @fileName, sharepointUrl = @sharepointUrl,
DateModification = GETDATE(), dateUpload = GETDATE(),
statut = 'en_attente', validePar = NULL, dateValidation = NULL, commentaire = NULL
WHERE collaborateurId = @collabId AND type = @type
ELSE
INSERT INTO DocumentsCollaborateur (collaborateurId, type, fileName, sharepointUrl, dateUpload, DateModification, statut)
VALUES (@collabId, @type, @fileName, @sharepointUrl, GETDATE(), GETDATE(), 'en_attente')
`);
// Notifier les Finance du même campus
// Notifier les Finance du même campus
const typeLabels = { rib: 'RIB', cartegrise: 'carte grise', permis: 'permis de conduire' };
const typeLabelsAvecArticle = { rib: 'son RIB', cartegrise: 'sa carte grise', permis: 'son permis de conduire' };
const campusNorm = normalizeCampus(campus);
const financeResult = await pool.request()
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%')
.query(`
SELECT DISTINCT c.id, c.email, c.prenom, c.nom
FROM CollaborateurAD c
JOIN UtilisateurRoles r ON r.collaborateur_id = c.id
WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1
AND c.campus LIKE @campus AND c.Actif = 1
`);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
for (const finance of financeResult.recordset) {
try {
await creerNotification({
destinataireId: finance.id,
destinataireEmail: finance.email,
type: 'validationdoc',
titre: `Document à valider — ${prenom} ${nom}`,
message: `${prenom} ${nom} (${campus}) a soumis ${typeLabelsAvecArticle[type]} pour validation.`,
noteId: null
});
} catch (e) { console.error('Notif BDD Finance doc', e.message); }
try {
await sendMailGraph(
finance.email,
`Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`,
`
Document à valider
Bonjour ${finance.prenom} ${finance.nom},
${prenom} ${nom} (${campus}) a soumis ${typeLabelsAvecArticle[type]} en attente de votre validation.
`
);
} catch (e) { console.error('Email Finance doc', e.message); }
}
res.json({ success: true, type, fileName: safeFileName, sharepointUrl, statut: 'en_attente', updatedAt: new Date().toISOString() });
} catch (error) {
console.error(`POST /api/profil/documents/${req.params.type}`, error.message);
res.status(500).json({ error: error.message });
}
});
// DELETE /api/profil/documents/:type
app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => {
try {
const rawType = req.params.type;
const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
if (!DOCTYPES.includes(type))
return res.status(400).json({ error: 'Type invalide' });
await pool.request()
.input('collabId', sql.Int, req.user.id)
.input('type', sql.NVarChar, type)
.query(`DELETE FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type`);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/finance/documents-historique — Finance voit l'historique des docs traités (validés/refusés)
app.get('/api/finance/documents-historique', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const request = pool.request();
let campusFilter = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusFilter = 'AND c.campus LIKE @campus';
}
}
const result = await request.query(`
SELECT d.id, d.collaborateurId, d.type, d.fileName, d.sharepointUrl,
d.dateUpload, d.DateModification, d.statut, d.commentaire,
d.dateValidation, d.validePar,
c.prenom, c.nom, c.email, c.campus, c.departement,
v.prenom + ' ' + v.nom AS validateurNom
FROM DocumentsCollaborateur d
JOIN CollaborateurAD c ON c.id = d.collaborateurId
LEFT JOIN CollaborateurAD v ON v.id = d.validePar
WHERE d.statut IN ('valide', 'refuse') ${campusFilter}
ORDER BY d.dateValidation DESC, d.DateModification DESC
`);
res.json(result.recordset);
} catch (error) {
console.error('GET /api/finance/documents-historique:', error.message);
res.status(500).json({ error: error.message });
}
});
// GET /api/finance/documents-a-valider — Finance voit les docs en_attente de son campus
app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const request = pool.request();
let campusFilter = '';
if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) {
const campusCode = normalizeCampus(req.user.campus);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusFilter = 'AND c.campus LIKE @campus';
}
}
const result = await request.query(`
SELECT d.id, d.collaborateurId, d.type, d.fileName, d.sharepointUrl,
d.dateUpload, d.DateModification, d.statut, d.commentaire,
c.prenom, c.nom, c.email, c.campus, c.departement
FROM DocumentsCollaborateur d
JOIN CollaborateurAD c ON c.id = d.collaborateurId
WHERE d.statut = 'en_attente' ${campusFilter}
ORDER BY d.DateModification ASC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PUT /api/finance/documents/:id/valider — Finance valide ou refuse un document
// PUT /api/finance/documents/:id/valider
app.put('/api/finance/documents/:id/valider', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const docId = parseInt(req.params.id);
const { action, commentaire } = req.body;
if (!['valider', 'refuser'].includes(action)) return res.status(400).json({ error: 'Action invalide' });
const newStatut = action === 'valider' ? 'valide' : 'refuse';
const docResult = await pool.request()
.input('id', sql.Int, docId)
.query(`SELECT d.*, c.prenom, c.nom, c.email, c.campus FROM DocumentsCollaborateur d JOIN CollaborateurAD c ON c.id = d.collaborateurId WHERE d.id = @id`);
if (!docResult.recordset.length) return res.status(404).json({ error: 'Document introuvable' });
const doc = docResult.recordset[0];
await pool.request()
.input('id', sql.Int, docId)
.input('statut', sql.NVarChar, newStatut)
.input('validePar', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, commentaire || null)
.query(`
UPDATE DocumentsCollaborateur
SET statut = @statut, validePar = @validePar,
dateValidation = GETDATE(), commentaire = @commentaire, DateModification = GETDATE()
WHERE id = @id
`);
const typeLabels = { rib: 'RIB', carte_grise: 'Carte grise', cartegrise: 'Carte grise', permis: 'Permis de conduire' };
const isValide = newStatut === 'valide';
const titre = isValide ? `${typeLabels[doc.type] || doc.type} validé ✅` : `${typeLabels[doc.type] || doc.type} refusé ❌`;
const message = isValide
? `Votre ${typeLabels[doc.type] || doc.type} a été validé par la Finance.`
: `Votre ${typeLabels[doc.type] || doc.type} a été refusé.${commentaire ? ' Motif : ' + commentaire : ''}`;
try {
await creerNotification({ destinataireId: doc.collaborateurId, destinataireEmail: doc.email, type: isValide ? 'doc_valide' : 'doc_refuse', titre, message, noteId: null });
} catch (e) { console.error('Notif doc validé', e.message); }
try {
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
await sendMailGraph(doc.email, titre,
`
${titre}
Bonjour ${doc.prenom} ${doc.nom},
${message}
${!isValide ? '
Veuillez soumettre un nouveau document corrigé depuis votre profil.
' : ''}
`
);
} catch (e) { console.error('Email doc validé', e.message); }
res.json({ success: true, statut: newStatut });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/profil/docs-statut — vérifie si le collaborateur peut soumettre (docs validés)
app.get('/api/profil/docs-statut', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('collaborateurId', sql.Int, req.user.id)
.query(`SELECT type, statut FROM DocumentsCollaborateur WHERE collaborateurId = @collaborateurId`);
const map = {};
for (const row of result.recordset) map[row.type] = row.statut;
res.json({
rib: map['rib'] || 'absent',
carte_grise: map['carte_grise'] || 'absent',
permis: map['permis'] || 'absent',
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ── PUT /api/profil/iban ─────────────────────────────────────────
app.put('/api/profil/iban', authenticateToken, async (req, res) => {
try {
let { iban, bic } = req.body;
if (!iban) return res.status(400).json({ error: 'IBAN obligatoire' });
if (!bic) return res.status(400).json({ error: 'BIC obligatoire' });
iban = iban.replace(/\s+/g, '').toUpperCase();
bic = bic.replace(/\s+/g, '').toUpperCase();
if (!validateIban(iban))
return res.status(400).json({ error: 'IBAN invalide (format ou checksum incorrect)' });
const ibanChiffre = encryptIban(iban);
const ibanHash = hashIban(iban);
await pool.request()
.input('id', sql.Int, req.user.id)
.input('iban', sql.NVarChar, ibanChiffre)
.input('ibanHash', sql.NVarChar, ibanHash)
.input('bic', sql.NVarChar, bic)
.query(`
UPDATE CollaborateurAD
SET IBAN = @iban, IBAN_HASH = @ibanHash, BIC = @bic, DateModification = GETDATE()
WHERE id = @id
`);
// ← Le second bloc DocumentsCollaborateur est supprimé
res.json({ success: true, iban: maskIban(iban), bic, statut: 'valide' });
} catch (error) {
console.error('PUT /api/profil/iban :', error.message);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// ── GET /api/profil/iban ─────────────────────────────────────────
app.get('/api/profil/iban', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`SELECT IBAN, BIC FROM CollaborateurAD WHERE id = @id`);
const row = result.recordset[0];
if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' });
let ibanMasque = null;
if (row.IBAN) {
try {
const ibanClair = decryptIban(row.IBAN);
ibanMasque = maskIban(ibanClair);
} catch {
// IBAN encore en clair en base (avant migration) — on masque directement
ibanMasque = maskIban(row.IBAN);
}
}
res.json({
iban: ibanMasque,
ibanSaisi: !!row.IBAN,
bic: row.BIC || null,
});
} catch (error) {
console.error('GET /api/profil/iban :', error.message);
res.status(500).json({ error: 'Erreur serveur' });
}
});
app.post('/api/verificateur/notes/:id/non-conforme', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès refusé' });
const { fileName, motif } = req.body;
const noteId = parseInt(req.params.id);
try {
const noteResult = await pool.request()
.input('id', sql.Int, noteId)
.query(`
SELECT
n.id, n.reference, n.libelle, n.montant,n.lignesJson,
n.collaborateurId,
c.prenom, c.nom, c.email,
v1.id AS n1Id,
v1.email AS emailN1,
v1.prenom AS prenomN1,
v1.nom AS nomN1
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
WHERE n.id = @id
`);
if (!noteResult.recordset.length)
return res.status(404).json({ error: 'Note introuvable' });
const note = noteResult.recordset[0];
// ✅ NOUVEAU — sauvegarder en BDD
await pool.request()
.input('noteId', sql.Int, noteId)
.input('verificateurId', sql.Int, req.user.id)
.input('fileName', sql.NVarChar, fileName)
.input('motif', sql.NVarChar, motif)
.query(`
INSERT INTO JustificatifsNonConformes
(noteDeFraisId, verificateurId, fileName, motif, statut, dateSignalement)
VALUES
(@noteId, @verificateurId, @fileName, @motif, 'non_conforme', GETDATE())
`);
await pool.request()
.input('id', sql.Int, noteId)
.query(`
UPDATE NoteDeFrais SET
statut = 'non_conforme_verif',
DateModification = GETDATE()
WHERE id = @id AND statut = 'approuve'
`);
// ✅ NOUVEAU — tracer dans HistoriqueValidation
await pool.request()
.input('noteId', sql.Int, noteId)
.input('validateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, `Justificatif non conforme : "${fileName}" — ${motif}`)
.input('statut', sql.NVarChar, 'approuve')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, 'VERIF', 'non_conforme', @commentaire, @statut, GETDATE())
`);
const verificateurNom = `${req.user.prenom} ${req.user.nom}`;
const montantFormate = recalculerMontantNote(note).toFixed(2);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
// Notifications (votre code existant inchangé)
try {
await creerNotification({
destinataireId: note.collaborateurId,
destinataireEmail: note.email,
type: 'refus',
titre: `⚠️ Justificatif non conforme — ${note.reference}`,
message: `Le justificatif "${fileName}" de votre note ${note.reference} est non conforme. Motif : ${motif}`,
noteId
});
} catch (e) { console.error('Notif BDD collab non-conforme:', e.message); }
try {
await sendMailGraph(
note.email,
`⚠️ Justificatif non conforme — ${note.reference}`,
`
⚠️ Justificatif non conforme
Une correction est nécessaire
Bonjour ${note.prenom} ${note.nom},
Le vérificateur Finance ${verificateurNom} a signalé
un justificatif non conforme sur votre note ${note.reference}.
Justificatif concerné
📄 ${fileName}
Motif
${motif}
| Référence |
${note.reference} |
| Libellé |
${note.libelle} |
| Montant |
${montantFormate} € |
📝 Que faire ?
- Retrouvez le justificatif original corrigé
- Contactez votre responsable ou la Finance
- Soumettez un nouveau justificatif via la plateforme
`
);
} catch (e) { console.error('Email collab non-conforme:', e.message); }
// ── Notifier le validateur N1 ──
if (note.n1Id && note.emailN1) {
try {
await creerNotification({
destinataireId: note.n1Id,
destinataireEmail: note.emailN1,
type: 'refus',
titre: `⚠️ Justificatif non conforme — ${note.reference}`,
message: `La note ${note.reference} de ${note.prenom} ${note.nom} comporte un justificatif non conforme : "${fileName}". Motif : ${motif}`,
noteId
});
} catch (e) { console.error('Notif BDD N1 non-conforme:', e.message); }
try {
await sendMailGraph(
note.emailN1,
`⚠️ Note ${note.reference} — justificatif non conforme`,
`
⚠️ Justificatif non conforme signalé
Bonjour ${note.prenomN1} ${note.nomN1},
Le vérificateur Finance ${verificateurNom} a signalé
un justificatif non conforme sur la note
${note.reference} de
${note.prenom} ${note.nom}.
📄 ${fileName}
Motif : ${motif}
Le collaborateur a été notifié et doit fournir un justificatif corrigé.
`
);
} catch (e) { console.error('Email N1 non-conforme:', e.message); }
}
res.json({
success: true,
notifiedCollab: true,
notifiedN1: !!(note.n1Id && note.emailN1)
});
} catch (error) {
console.error('Erreur POST non-conforme:', error.message);
res.status(500).json({ error: error.message });
}
});
app.get('/api/paiements/filtres-disponibles', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance' });
try {
const result = await pool.request().query(`
SELECT DISTINCT
c.campus,
c.societe
FROM CollaborateurAD c
WHERE c.Actif = 1
AND c.campus IS NOT NULL
AND c.campus != ''
ORDER BY c.campus, c.societe
`);
const campusSet = new Set();
// Map : campusCode → Set de sociétés
const societeParCampus = {};
const societeSet = new Set();
for (const row of result.recordset) {
if (row.campus) {
const c = row.campus.toUpperCase();
const code =
c.includes('SQY') || c.includes('SAINT') ? 'SQY' :
c.includes('CGY') || c.includes('CERGY') ? 'CGY' :
c.includes('MRS') || c.includes('MARSEILLE') ? 'MRS' :
c.includes('NTE') || c.includes('NANTES') ? 'NTE' :
row.campus;
campusSet.add(code);
// Grouper les sociétés par campus normalisé
if (!societeParCampus[code]) societeParCampus[code] = new Set();
if (row.societe && row.societe.trim()) {
societeParCampus[code].add(row.societe.trim());
societeSet.add(row.societe.trim());
}
}
}
// Convertir les Sets en tableaux triés
const societeParCampusFinal = {};
for (const [campus, set] of Object.entries(societeParCampus)) {
societeParCampusFinal[campus] = [...set].sort();
}
res.json({
campus: [...campusSet].sort(),
societes: [...societeSet].sort(), // toutes sociétés (fallback)
societeParCampus: societeParCampusFinal, // sociétés par campus ← nouveau
});
} catch (error) {
console.error('GET /api/paiements/filtres-disponibles:', error.message);
res.status(500).json({ error: error.message });
}
});
app.get('/api/verificateur/notes/:id/non-conformes', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès refusé' });
try {
const result = await pool.request()
.input('noteId', sql.Int, parseInt(req.params.id))
.query(`
SELECT j.id, j.fileName, j.motif, j.statut, j.dateSignalement,
c.prenom + ' ' + c.nom AS verificateur
FROM JustificatifsNonConformes j
JOIN CollaborateurAD c ON c.id = j.verificateurId
WHERE j.noteDeFraisId = @noteId
ORDER BY j.dateSignalement DESC
`);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// =========================================================
// ROUTES À AJOUTER DANS server.js
// Coller après les routes /api/profil/iban existantes
// =========================================================
// ── GET /api/profil/vehicule ─────────────────────────────
app.get('/api/profil/vehicule', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.user.id)
.query(`
SELECT chevauxFiscaux, vehiculeMarque, vehiculeModele,
vehiculeImmat, vehiculeDateMaj
FROM CollaborateurAD
WHERE id = @id
`);
const row = result.recordset[0];
if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' });
// Retourner null si pas encore configuré
if (!row.chevauxFiscaux) {
return res.json({ configured: false, vehicule: null });
}
res.json({
configured: true,
vehicule: {
chevaux: row.chevauxFiscaux,
marque: row.vehiculeMarque || '',
modele: row.vehiculeModele || '',
immatriculation: row.vehiculeImmat || '',
dateMaj: row.vehiculeDateMaj,
}
});
} catch (error) {
console.error('GET /api/profil/vehicule :', error.message);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// ── PUT /api/profil/vehicule ─────────────────────────────
app.put('/api/profil/vehicule', authenticateToken, async (req, res) => {
try {
const { chevaux, marque, modele, immatriculation } = req.body;
// Validation
const cv = parseInt(chevaux);
if (!cv || cv < 3 || cv > 7) {
return res.status(400).json({
error: 'Cheval fiscal invalide (valeurs acceptées : 3, 4, 5, 6, 7)'
});
}
const immatClean = (immatriculation || '')
.replace(/\s+/g, '-')
.toUpperCase()
.slice(0, 20);
await pool.request()
.input('id', sql.Int, req.user.id)
.input('chevaux', sql.Int, cv)
.input('marque', sql.NVarChar, (marque || '').trim().slice(0, 100))
.input('modele', sql.NVarChar, (modele || '').trim().slice(0, 100))
.input('immat', sql.NVarChar, immatClean)
.query(`
UPDATE CollaborateurAD SET
chevauxFiscaux = @chevaux,
vehiculeMarque = @marque,
vehiculeModele = @modele,
vehiculeImmat = @immat,
vehiculeDateMaj = GETDATE(),
DateModification = GETDATE()
WHERE id = @id
`);
console.log(`✅ Profil véhicule mis à jour — user ${req.user.email} : ${cv} CV`);
res.json({
success: true,
vehicule: { chevaux: cv, marque: marque || '', modele: modele || '', immatriculation: immatClean }
});
} catch (error) {
console.error('PUT /api/profil/vehicule :', error.message);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// ── DELETE /api/profil/vehicule ──────────────────────────
app.delete('/api/profil/vehicule', authenticateToken, async (req, res) => {
try {
await pool.request()
.input('id', sql.Int, req.user.id)
.query(`
UPDATE CollaborateurAD SET
chevauxFiscaux = NULL,
vehiculeMarque = NULL,
vehiculeModele = NULL,
vehiculeImmat = NULL,
vehiculeDateMaj = NULL,
DateModification = GETDATE()
WHERE id = @id
`);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/paiements/soumettre-president', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé Finance / ValidateurFinance' });
const { noteIds } = req.body;
if (!Array.isArray(noteIds) || noteIds.length === 0)
return res.status(400).json({ error: 'Aucune note sélectionnée' });
try {
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
// Vérifier que toutes les notes sont bien au statut 'verifie'
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, n.libelle, n.statut, n.lignesJson,c.prenom,c.nom,c.email,c.campus,c.id AS collabId
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
AND n.statut IN ('approuve', 'verifie')
`);
if (!notes.recordset.length)
return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' });
// Passer en 'en_attente_president'
await pool.request().query(`
UPDATE NoteDeFrais
SET presidentValidation = 'en_attente_president',
statut = 'en_attente_president',
DateModification = GETDATE()
WHERE id IN (${idList})
AND statut IN ('approuve', 'verifie')
`);
// Historique
for (const note of notes.recordset) {
try {
await pool.request()
.input('noteId', sql.Int, note.id)
.input('validateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, `Soumis au Président par ${req.user.prenom} ${req.user.nom} (${notes.recordset.length} note(s))`)
.input('statut', sql.NVarChar, 'en_attente_president')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, 'PRESIDENT', 'soumettre', @commentaire, @statut, GETDATE())
`);
} catch (e) { console.error('Histo President soumettre:', e.message); }
}
// Trouver le(s) Président(s) → notifier
const presidents = await pool.request().query(`
SELECT c.id, c.email, c.prenom, c.nom
FROM CollaborateurAD c
JOIN UtilisateurRoles r ON r.collaborateur_id = c.id
WHERE r.role = 'President' AND r.actif = 1 AND c.Actif = 1
`);
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net';
const validateurNom = `${req.user.prenom} ${req.user.nom}`;
for (const president of presidents.recordset) {
try {
await creerNotification({
destinataireId: president.id,
destinataireEmail: president.email,
type: 'validation',
titre: `💼 ${notes.recordset.length} note(s) en attente de votre validation`,
message: `${validateurNom} vous soumet ${notes.recordset.length} note(s) de frais pour un total de ${total} € en attente de votre validation avant virement.`,
noteId: null
});
} catch (e) { console.error('Notif President BDD:', e.message); }
try {
const lignesNotes = notes.recordset.map(n =>
`
| ${n.reference} |
${n.prenom} ${n.nom} |
${recalculerMontantNote(n).toFixed(2)} € |
`
).join('');
await sendMailGraph(
president.email,
`💼 ${notes.recordset.length} note(s) de frais en attente de votre validation`,
`
💼 Notes de frais — Validation Président
${notes.recordset.length} note(s) soumises par ${validateurNom}
Bonjour ${president.prenom} ${president.nom},
${validateurNom} vous soumet les notes de frais suivantes pour validation avant génération du virement bancaire :
| Référence |
Collaborateur |
Montant |
${lignesNotes}
| Total à virer |
${total} € |
Cette validation est définitive. Le fichier XML de virement sera généré automatiquement.
`
);
} catch (e) { console.error('Email President:', e.message); }
}
res.json({
success: true,
nbNotes: notes.recordset.length,
total: parseFloat(total),
presidentsNotifies: presidents.recordset.length
});
} catch (error) {
console.error('Erreur POST /api/paiements/soumettre-president:', error.message);
res.status(500).json({ error: error.message });
}
});
// ══════════════════════════════════════════════════════════════════════════
// 2. Président → Récupérer les notes en attente de sa validation
// GET /api/president/notes
// ══════════════════════════════════════════════════════════════════════════
app.get('/api/president/notes', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'President', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé au Président' });
try {
const result = await pool.request().query(`
SELECT n.*,
c.nom + ' ' + c.prenom AS collaborateur,
c.email AS collaborateurEmail,
c.departement, c.campus, c.societe,
v1.nom + ' ' + v1.prenom AS nomN1,
vf.nom + ' ' + vf.prenom AS nomVerificateur,
n.dateVerification, n.commentaireVerification
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId
WHERE n.statut = 'en_attente_president'
AND n.presidentValidation = 'en_attente_president'
ORDER BY n.DateModification DESC
`);
// ✅ Recalculer le montant (km, etc.) au lieu d'afficher n.montant brut
const notes = result.recordset.map(n => ({
...n,
montant: recalculerMontantNote(n),
}));
res.json(notes);
} catch (error) {
console.error('GET /api/president/notes:', error.message);
res.status(500).json({ error: error.message });
}
});
// ══════════════════════════════════════════════════════════════════════════
// 3. Président → Générer le XML + valider les notes
// POST /api/president/generer-xml
// Body: { noteIds: number[], commentaire?: string }
// ══════════════════════════════════════════════════════════════════════════
app.post('/api/president/generer-xml', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'President', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé au Président' });
const { noteIds, commentaire } = req.body;
if (!Array.isArray(noteIds) || noteIds.length === 0)
return res.status(400).json({ error: 'Aucune note sélectionnée' });
try {
const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(',');
const notes = await pool.request().query(`
SELECT n.id, n.reference, n.montant, n.libelle, n.date,
n.fichiers, n.lignesJson, n.categorie, n.DateCreation,
c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe,
c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays,
c.id AS collabId, c.email
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.id IN (${idList})
AND n.statut = 'en_attente_president'
`);
if (!notes.recordset.length)
return res.status(404).json({ error: 'Aucune note en attente de validation Président trouvée' });
// ── Validation IBAN / adresse ─────────────────────────────────────
const erreurs = [];
for (const n of notes.recordset) {
if (!n.iban) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`);
if (!n.bic) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`);
if (!n.adresse_rue || !n.adresse_cp || !n.adresse_ville || !n.adresse_pays)
erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`);
}
if (erreurs.length > 0)
return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs });
// ── Construction XML PAIN.001 ─────────────────────────────────────
const now = new Date();
const annee = now.getFullYear();
const mois = String(now.getMonth() + 1).padStart(2, '0');
const todayISO = now.toISOString().split('T')[0];
const creDtTm = now.toISOString().slice(0, 19);
const presidentNom = `${req.user.prenom} ${req.user.nom}`;
const msgId = `NDF-PRES-${annee}${mois}-${Date.now().toString().slice(-7)}`;
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 0);
const totalFormate = total.toFixed(2);
// Campus dominant pour le compte débiteur
const campusDominant = (() => {
const campusCounts = {};
for (const n of notes.recordset) {
const code = normalizeCampus(n.campus || '') || n.campus || '';
if (code) campusCounts[code] = (campusCounts[code] || 0) + 1;
}
return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
})();
const cfg = await getConfigDebiteur(campusDominant);
const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic,
companyAddress: dbtrAdrLine, companyCp: dbtrCp,
companyVille: dbtrVille, companyPays: dbtrPays } = cfg;
let transactions = '';
for (const n of notes.recordset) {
let ibanClair = 'FR0000000000000000000000000';
try {
if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban);
else if (n.iban) ibanClair = n.iban;
} catch (e) {
console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message);
}
const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`;
const adrLine = (n.adresse_rue || '').toUpperCase();
const cp = n.adresse_cp || '';
const ville = (n.adresse_ville || '').toUpperCase();
const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase();
const benefBicBlock = n.bic
? `${n.bic}`
: `NOTPROVIDED`;
transactions += `
VIREMENT NUM:${n.reference}
${n.reference}
${recalculerMontantNote(n).toFixed(2) }
${benefBicBlock}
${benefNom}${adrLine || cp || ville ? `
${cp ? `
${cp}` : ''}${ville ? `
${ville}` : ''}
${pays}${adrLine ? `
${adrLine}` : ''}
` : ''}
${pays}
${ibanClair}
`;
}
// Mention Président dans le message du fichier
const xml = `
${msgId}
${creDtTm}
${notes.recordset.length}
${totalFormate}
${dbtrNom}
${msgId}
TRF
true
${notes.recordset.length}
${totalFormate}
SEPA
${todayISO}
${dbtrNom}
${dbtrCp}
${dbtrVille}
${dbtrPays}
${dbtrAdrLine}
${dbtrIban}
NOTPROVIDED
SLEV${transactions}
`;
// ── Mettre à jour les notes : 'paiementenattente' + validation Président ──
await pool.request()
.input('presidentId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, commentaire || null)
.input('dateXml', sql.DateTime, now)
.query(`
UPDATE NoteDeFrais
SET statut = 'paiementenattente',
presidentValidation = 'valide_president',
presidentId = @presidentId,
dateValidationPresident = GETDATE(),
commentairePresident = @commentaire,
dateXml = @dateXml,
DateModification = GETDATE()
WHERE id IN (${idList})
AND statut = 'en_attente_president'
`);
// ── Historique ────────────────────────────────────────────────────
for (const note of notes.recordset) {
try {
await pool.request()
.input('noteId', sql.Int, note.id)
.input('presidentId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, `Validé par le Président ${presidentNom}${commentaire ? ' — ' + commentaire : ''}`)
.input('statut', sql.NVarChar, 'paiementenattente')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @presidentId, 'PRESIDENT', 'valider_xml', @commentaire, @statut, GETDATE())
`);
} catch (e) { console.error('Histo President valider:', e.message); }
}
// ── Envoyer le XML immédiatement ──────────────────────────────────
const xmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${todayISO}.xml`;
res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1');
res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`);
res.send(xml);
// ── Traitement asynchrone : SharePoint + emails ValidateurFinance ──
setImmediate(async () => {
console.log(`🔄 [ASYNC PRESIDENT] Post-XML pour ${notes.recordset.length} note(s)...`);
// Upload XML sur SharePoint
try {
const spXmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`;
const xmlUploadPath = `Virements/President/${annee}/${mois}/${spXmlFileName}`;
const accessToken = await getGraphToken();
if (accessToken) {
await require('axios').put(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`,
Buffer.from(xml, 'utf-8'),
{ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml' }, maxBodyLength: Infinity }
);
console.log(`✅ [ASYNC PRESIDENT] XML uploadé : ${xmlUploadPath}`);
}
} catch (e) {
console.error('⚠️ [ASYNC PRESIDENT] Upload XML SharePoint:', e.message);
}
// ── Retrouver le ValidateurFinance qui a soumis au président ──────────
try {
// On prend la première note du batch pour retrouver qui a soumis
// APRÈS (cherche sur TOUTES les notes du batch)
const allNoteIds = notes.recordset.map(n => n.id).join(',');
const histResult = await pool.request()
.query(`
SELECT TOP 1 h.ValidateurId, c.email, c.prenom, c.nom
FROM HistoriqueValidation h
JOIN CollaborateurAD c ON c.id = h.ValidateurId
WHERE h.NoteDeFraisId IN (${allNoteIds})
AND h.Niveau = 'PRESIDENT'
AND h.Action = 'soumettre'
ORDER BY h.DateAction DESC
`);
const soumetteur = histResult.recordset[0];
if (soumetteur) {
const dateLabel = now.toLocaleDateString('fr-FR', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric'
});
const heureLabel = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const lignesNotes = notes.recordset.map(n =>
`
| ${n.reference} |
${n.prenom} ${n.nom} |
${recalculerMontantNote(n).toFixed(2) } € |
`
).join('');
// Notification BDD
await creerNotification({
destinataireId: soumetteur.ValidateurId,
destinataireEmail: soumetteur.email,
type: 'paiement',
titre: `✅ XML virement validé par le Président — ${notes.recordset.length} note(s)`,
message: `Le Président ${presidentNom} a validé et généré le XML de virement le ${dateLabel} à ${heureLabel} pour ${notes.recordset.length} note(s) — ${totalFormate} €.`,
noteId: null
});
// Email
await sendMailGraph(
soumetteur.email,
`✅ XML virement validé par le Président ${presidentNom}`,
`
✅ Virement validé par le Président
Le fichier XML a été généré et est prêt pour votre banque
Bonjour ${soumetteur.prenom} ${soumetteur.nom},
Le Président ${presidentNom} a validé et généré le fichier XML de virement bancaire pour les notes que vous lui avez soumises.
Date : ${dateLabel} à ${heureLabel}
Validé par : ${presidentNom}
Nombre de virements : ${notes.recordset.length}
Montant total : ${totalFormate} €
${commentaire ? `
💬 ${commentaire}
` : ''}
Détail des virements (${notes.recordset.length})
| Référence |
Collaborateur |
Montant |
${lignesNotes}
| Total |
${totalFormate} € |
Vous pouvez re-télécharger ce fichier XML depuis la rubrique "XML virements" de la plateforme.
`
);
console.log(`✅ [ASYNC PRESIDENT] ValidateurFinance notifié : ${soumetteur.email}`);
} else {
console.warn('⚠️ [ASYNC PRESIDENT] Soumetteur introuvable dans HistoriqueValidation');
}
} catch (e) {
console.error('❌ [ASYNC PRESIDENT] Notification soumetteur:', e.message);
}
console.log(`✅ [ASYNC PRESIDENT] Traitement terminé pour ${notes.recordset.length} note(s)`);
});
} catch (error) {
console.error('Erreur POST /api/president/generer-xml:', error.message);
res.status(500).json({ error: error.message });
}
});
// ══════════════════════════════════════════════════════════════════════════
// 4. Président → Historique de ses validations
// GET /api/president/historique
// ══════════════════════════════════════════════════════════════════════════
app.get('/api/president/historique', authenticateToken, async (req, res) => {
if (!hasAnyRole(req.user, 'President', 'superUtilisateur'))
return res.status(403).json({ error: 'Accès réservé au Président' });
try {
const result = await pool.request()
.input('presidentId', sql.Int, req.user.id)
.query(`
SELECT
CAST(n.dateXml AS DATE) AS dateXmlJour,
MIN(n.dateXml) AS dateXmlExacte,
n.dateValidationPresident,
n.commentairePresident,
COUNT(*) AS nbNotes,
SUM(n.montant) AS totalMontant,
STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds,
STRING_AGG(n.reference, ', ') AS listeReferences
FROM NoteDeFrais n
WHERE n.presidentId = @presidentId
AND n.presidentValidation = 'valide_president'
GROUP BY CAST(n.dateXml AS DATE), n.dateValidationPresident, n.commentairePresident
ORDER BY CAST(n.dateXml AS DATE) DESC
`);
res.json(result.recordset);
} catch (error) {
console.error('GET /api/president/historique:', error.message);
res.status(500).json({ error: error.message });
}
});
// ================================================
// GESTION DES ERREURS
// ================================================
app.use((err, req, res, next) => {
console.error('❌ Erreur middleware:', err.stack);
res.status(500).json({ error: 'Une erreur est survenue', details: process.env.NODE_ENV === 'development' ? err.message : undefined });
});
// ================================================
// DÉMARRAGE DU SERVEUR
// ================================================
const server = app.listen(PORT, '0.0.0.0', () => {
console.log('\n================================================');
console.log(`✅ SERVEUR DÉMARRÉ sur http://0.0.0.0:${PORT}`);
console.log('================================================');
setTimeout(async () => {
console.log('\n🚀 Lancement synchronisation automatique Entra ID...');
await syncEntraIdUsers();
setInterval(async () => {
console.log('\n🔁 Synchronisation périodique Entra ID...');
await syncEntraIdUsers();
}, 6 * 60 * 60 * 1000);
}, 5000);
});
server.on('error', (error) => {
console.error('\n❌ ERREUR SERVEUR:', error);
if (error.code === 'EADDRINUSE') console.error(`⚠️ Le port ${PORT} est déjà utilisé`);
process.exit(1);
});
setInterval(() => { }, 60000);