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;
}
// ── 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,
v2.nom + ' ' + v2.prenom AS nomN2
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
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.collabPrenom}_${nd.collabNom}`.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,
v2.id AS n2Id, v2.email AS emailN2, v2.prenom AS prenomN2, v2.nom AS nomN2
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
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 = parseFloat(note.montant).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 annee = now.getFullYear();
const mois = String(now.getMonth() + 1).padStart(2, '0');
// Normaliser le campus
const campusCode = normalizeCampus(campus) || 'XXX';
// Construire la partie nom : NOM.P (première lettre du prénom)
const nomClean = (nom || '').toUpperCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // supprimer accents
.replace(/[^A-Z]/g, ''); // garder uniquement lettres
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 {
await new sql.Request(tx).query(`
IF NOT EXISTS (SELECT 1 FROM NDFSequence WHERE annee = ${annee})
INSERT INTO NDFSequence (annee, compteur) VALUES (${annee}, 0)
`);
const result = await new sql.Request(tx).query(`
UPDATE NDFSequence SET compteur = compteur + 1 OUTPUT INSERTED.compteur WHERE annee = ${annee}
`);
await tx.commit();
const num = String(result.recordset[0].compteur).padStart(3, '0');
return `NDF-${annee}-${mois}-${campusCode}-${nomPart}`;
} catch (e) { await tx.rollback(); 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}`;
const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${moisDossier}/${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 indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0);
const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 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 (2 requêtes SQL, inchangé) ────────
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, h.[SuperieurIdn+2],
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1,
s2.email AS emailN2
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2]
WHERE h.CollaborateurId = @collabId
`);
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? 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.prenom}_${collaborateur.nom}`.replace(/\s+/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);
// ✅ Téléchargements QR globaux en parallèle
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 = [];
// téléchargement + upload SharePoint en parallèle pour chaque fichier de la ligne
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);
}
})
);
// ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles
const lignesJsonFinal = JSON.stringify(lignesParsed);
// ── Upload justificatifs en parallèle ────────────────────────────
const fichiersUploades = (await Promise.all(
allFiles.map(file =>
uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier)
.catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; })
)
)).filter(Boolean);
// ── Préparer noteDataPDF (utilisé en sync ET en async) ───────────
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('validateurN2Id', sql.Int, n2Id)
.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, validateurN2Id,
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, @validateurN2Id,
@montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20,
@km, @indemniteKm, @lignesJson)
`);
const noteCreee = insertResult.recordset[0];
// ── Insérer les lignes (séquentiel, rapide car SQL local) ────────
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}`);
// ✅ Répondre immédiatement — le client n'attend plus le PDF
res.status(201).json({
success: true,
id: noteCreee.id,
reference: noteCreee.reference,
fichiers: fichiersUploades,
recapUrl: null, // sera mis à jour en BDD en arrière-plan
pending: true,
});
// ── Traitement lourd en arrière-plan (non bloquant) ──────────────
setImmediate(async () => {
const fichiersAsync = [...fichiersUploades]; // copie locale pour l'async
try {
console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`);
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
// Fiche PDF soumission
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); }
// Récap PDF complet
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); }
// Mettre à jour BDD avec PDF final
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); }
// Notifications BDD (parallèle)
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(),
]);
// Emails (parallèle)
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];
// ✅ Détecter si correction (refusée ou non-conforme) → nouvelle note
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.prenom}_${collaborateur.nom}`.replace(/\s+/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) {
// 1. Archiver l'ancienne note
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
`);
// 2. Nouvelle référence
const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
// 3. Upload fichiers joints
const allFiles = [...(req.files || [])];
const fichiersUploades = [];
for (const file of allFiles) {
try {
const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
fichiersUploades.push(r);
} catch (e) { console.error(`❌ Upload justif correction ${file.originalname}:`, e.message); }
}
// 4. Récupérer fichiers 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); }
}
// 5. Générer fiche PDF
const noteDataPDF = {
reference: nouvelleReference, 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 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); }
// 6. Hiérarchie
const hierarchie = await pool.request()
.input('collabId', sql.Int, userId)
.query(`
SELECT h.SuperieurId, h.[SuperieurIdn+2],
s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1,
s2.email AS emailN2
FROM HierarchieValidationNDF h
LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId
LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2]
WHERE h.CollaborateurId = @collabId
`);
const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null;
const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null;
const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null;
const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null;
const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null;
// 7. Insérer nouvelle note
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('validateurN2Id', sql.Int, n2Id)
.input('km', sql.Decimal, kmTotal || null)
.input('indemniteKm', sql.Decimal, indemKm || null)
.input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed))
.input('noteRefuseeId', sql.Int, noteId)
.query(`
INSERT INTO NoteDeFrais
(reference, collaborateurId, libelle, montant, date, categorie,
description, participants, nombreParticipants,
fichiers, statut, validateurN1Id, validateurN2Id,
km, indemniteKm, lignesJson, noteRefuseeId,
DateCreation, DateModification)
OUTPUT INSERTED.id, INSERTED.reference
VALUES
(@reference, @collaborateurId, @libelle, @montant, @date, @categorie,
@description, @participants, @nombreParticipants,
@fichiers, @statut, @validateurN1Id, @validateurN2Id,
@km, @indemniteKm, @lignesJson, @noteRefuseeId,
GETDATE(), GETDATE())
`);
const nouvelleNote = insertResult.recordset[0];
// 8. Insérer lignes
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); }
}
// 9. Notifier N1
if (n1Id && emailN1) {
try {
const titreNotif = noteExist.statut === 'non_conforme_verif'
? `📋 Note corrigée à valider — ${nouvelleReference}`
: `📋 Note corrigée à valider — ${nouvelleReference}`;
// Dans le PUT /api/notes/:id — CAS 1 correction, section "Notifier N1"
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 (comportement original)
// ════════════════════════════════════════════════════════════════════
const reference = noteExist.reference;
const allFiles = [...(req.files || [])];
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { }
for (const file of allFiles) {
try {
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
fichiersExistants.push(r);
} catch (e) { console.error(`❌ Upload justif modif ${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,
v2.nom + ' ' + v2.prenom as nomValidateurN2
FROM NoteDeFrais n
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
${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); }
}
}
res.json(notes);
} 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')
OR (n.validateurN2Id = @userId AND n.statut = 'validen1')
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);
}
}
}
res.json(notes);
} 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 });
}
});
// 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 n.validateurN2Id = ${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 = []; }
res.json(note);
} 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;
const { action, commentaire, motifRefus } = 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 n2Id = Number(note.validateurN2Id);
const statutNote = note.statut?.trim();
let nouveauStatut = null, niveauValidation = null;
if (n1Id === userId && statutNote === 'enattente') {
niveauValidation = 'N1';
nouveauStatut = action === 'valider'
? (note.validateurN2Id && n2Id !== userId ? 'validen1' : 'approuve')
: 'refuse';
} else if (n2Id === userId && statutNote === 'validen1') {
niveauValidation = 'N2';
nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
} else {
return res.status(403).json({ error: 'Non autorisé à valider cette note' });
}
const dateField = niveauValidation === 'N1' ? 'dateValidationN1' : 'dateValidationN2';
const commentaireField = niveauValidation === 'N1' ? 'commentaireN1' : 'commentaireN2';
await pool.request()
.input('id', sql.Int, id)
.input('statut', sql.NVarChar, nouveauStatut)
.input('commentaire', sql.NVarChar, commentaire ?? null)
.input('motifRefus', sql.NVarChar, motifRefus ?? null)
.query(`
UPDATE NoteDeFrais
SET statut = @statut,
${dateField} = GETDATE(),
${commentaireField} = @commentaire,
motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END,
DateModification = GETDATE()
WHERE id = @id
`);
await pool.request()
.input('noteId', sql.Int, id)
.input('validateurId', sql.Int, userId)
.input('niveau', sql.NVarChar, niveauValidation)
.input('action', sql.NVarChar, action)
.input('commentaire', sql.NVarChar, commentaire ?? null)
.input('motifRefus', sql.NVarChar, motifRefus ?? null)
.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())
`);
res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation });
setImmediate(async () => {
try {
console.log(`🔄 [ASYNC] PDF + emails validation ${id} → ${nouveauStatut}`);
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.commentaireN2, n.dateValidationN1, n.dateValidationN2,
c.prenom + ' ' + c.nom AS nomPrenom,
c.prenom AS collabPrenom, c.nom AS collabNom, c.departement,
v1.prenom + ' ' + v1.nom AS nomValidateurN1,
v2.prenom + ' ' + v2.nom AS nomValidateurN2
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
WHERE n.id = @id
`)
]);
const c = collabResult.recordset[0];
const v = validateurResult.recordset[0];
const nd = noteCompleteResult.recordset[0];
if (!c || !nd) {
console.error(`❌ [ASYNC] Données manquantes pour note ${id}`);
return;
}
const nomValidateurActuel = v
? `${v.prenom} ${v.nom}`.trim()
: `${req.user.prenom} ${req.user.nom}`.trim();
// ── Construire les signatures ──────────────────────────────
const signatures = [
{ niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null }
];
if (niveauValidation === 'N1') {
signatures.push({ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null });
} else {
if (nd.nomValidateurN1 && nd.dateValidationN1)
signatures.push({ niveau: 'N1', nomPrenom: nd.nomValidateurN1, date: nd.dateValidationN1, action: 'valider', commentaire: nd.commentaireN1 ?? null });
signatures.push({ niveau: 'N2', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null });
}
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
};
let fichiersExistants = [];
try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { }
const existingFolder = fichiersExistants[0]?.folderPath;
const nomDossier = existingFolder
? existingFolder.split('/')[1]
: `${nd.collabPrenom}_${nd.collabNom}`.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ération PDF signé (fiche seule) ────────────────────
try {
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve'
: nouveauStatut === 'refuse' ? 'signe-refuse'
: `signe-${nouveauStatut}`;
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');
console.log(`✅ [ASYNC] PDF signé uploadé: ${signedResult.fileName}`);
} catch (pdfError) {
console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message);
}
// ── Régénérer le recap complet (fiche + justifs + signatures à jour) ──
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
);
// Remplacer l'ancien _recap.pdf (sauf recap-paiement)
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');
console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s): ${recapResult.fileName}`);
} catch (recapError) {
console.error('❌ [ASYNC] Régénération recap:', recapError.message);
}
// ── Notifications + emails ────────────────────────────────
const isApprouve = nouveauStatut === 'approuve';
const isValidn1 = nouveauStatut === 'validen1';
const isRefus = nouveauStatut === 'refuse';
const titreCollab = isApprouve ? `Note ${note.reference} approuvée`
: isValidn1 ? `Note ${note.reference} validée N1`
: `Note ${note.reference} refusée`;
const msgCollab = isApprouve
? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.`
: isValidn1
? `Votre note ${note.reference} a été validée N1 par ${nomValidateurActuel}.`
: `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`;
const motifAffiche = motifRefus || commentaire || 'Non précisé';
const emailCollabHtml = isRefus
? `
❌ Votre note de frais a été refusée
Une action de votre part est nécessaire
Bonjour ${c.prenom} ${c.nom},
Votre note ${note.reference} a été refusée par ${nomValidateurActuel}.
Motif du refus
${motifAffiche}
| Référence | ${note.reference} |
| Libellé | ${note.libelle} |
| Montant | ${montantFormate} € |
| Refusé par | ${nomValidateurActuel} |
| Date | ${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })} |
📝 Que faire maintenant ?
- Connectez-vous à la plateforme NDF
- Rendez-vous dans Mes notes
- Cliquez sur la note ${note.reference}
- Corrigez les informations demandées
- Resoumettez la note
`
: `
${titreCollab}
Bonjour ${c.prenom} ${c.nom},
${msgCollab}
`;
const taches = [
creerNotification({
destinataireId: c.id,
destinataireEmail: c.email,
type: isRefus ? 'refus' : 'validation',
titre: titreCollab,
message: msgCollab,
noteId: parseInt(id)
}).catch(e => console.error('❌ [ASYNC] Notif collab:', e.message)),
sendMailGraph(
c.email,
isRefus ? `❌ Note refusée — action requise : ${note.reference}` : titreCollab,
emailCollabHtml
).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)),
];
if (action === 'valider' && niveauValidation === 'N1' && note.validateurN2Id && n2Id !== userId) {
const n2Result = await pool.request()
.input('id', sql.Int, note.validateurN2Id)
.query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
if (n2Result.recordset.length) {
const n2 = n2Result.recordset[0];
taches.push(
creerNotification({
destinataireId: n2.id, destinataireEmail: n2.email,
type: 'validation',
titre: `Note à valider N2 : ${note.reference}`,
message: `La note ${note.reference} (${montantFormate}€) de ${c.prenom} ${c.nom} attend votre validation finale.`,
noteId: parseInt(id)
}).catch(e => console.error('❌ [ASYNC] Notif N2:', e.message)),
sendMailGraph(n2.email, `Note à valider N2 : ${note.reference}`,
`
Note à valider — Niveau N2
Bonjour ${n2.prenom} ${n2.nom},
La note ${note.reference} de ${c.prenom} ${c.nom} (${montantFormate}€) a été validée N1 et attend votre validation finale.
`
).catch(e => console.error('❌ [ASYNC] Email N2:', e.message))
);
}
}
await Promise.all(taches);
console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`);
} catch (e) {
console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message);
}
});
} catch (error) {
console.error('Erreur validation:', 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, v2.nom + ' ' + v2.prenom as nomN2
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id
LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id
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', { destinataireEmail, type, titre });
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 + parseFloat(n.montant), 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, 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', '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,
v2.nom + ' ' + v2.prenom AS nomN2,
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 v2 ON v2.id = n.validateurN2Id
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 + parseFloat(n.montant), 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}
${parseFloat(n.montant).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'))
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')) {
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
CAST(n.dateXml AS DATE) AS dateXmlJour,
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
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
${where} ${campusWhere}
GROUP BY CAST(n.dateXml AS DATE)
ORDER BY CAST(n.dateXml AS DATE) 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,
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 + parseFloat(n.montant), 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}
${parseFloat(n.montant).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,
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 ${parseFloat(n.montant).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 ${parseFloat(n.montant).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 + parseFloat(n.montant), 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, 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)) : (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', 'permis'];
// 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' -- ← exclure le rib de cette table
`);
// Vérifier si IBAN saisi directement dans CollaborateurAD
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) {
docs[row.type] = {
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 type = req.params.type;
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
const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' };
const campusNorm = normalizeCampus(campus);
const financeResult = 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 = 'Finance' 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 son ${typeLabels[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 son ${typeLabels[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 type = req.params.type;
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-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', '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', '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.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 = parseFloat(note.montant).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 });
}
});
// ================================================
// 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);