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 = 10 * 60 * 1000;
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 >= 50) {
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() {
try {
const result = await pool.request().query(`
SELECT TOP 1 companyName, companyIban, companyBic,
companyAddress, companyCp, companyVille, companyPays
FROM ConfigDebiteurXML
WHERE actif = 1
ORDER BY DateModification DESC
`);
if (result.recordset.length) return result.recordset[0];
} catch (e) {
console.warn('⚠️ getConfigDebiteur fallback .env:', e.message);
}
// Fallback .env si table inaccessible
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
// ================================================
async function getGraphToken() {
try {
console.log('🔑 Tentative d\'obtention du token...');
console.log(' Tenant ID:', AZURE_CONFIG.tenantId ? '✅' : '❌ MANQUANT');
console.log(' Client ID:', AZURE_CONFIG.clientId ? '✅' : '❌ MANQUANT');
console.log(' Client Secret:', AZURE_CONFIG.clientSecret ? '✅' : '❌ MANQUANT');
if (!AZURE_CONFIG.tenantId || !AZURE_CONFIG.clientId || !AZURE_CONFIG.clientSecret) {
throw new Error('Configuration Azure incomplète - vérifiez votre fichier .env');
}
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' } }
);
console.log('✅ Token obtenu avec succès');
return response.data.access_token;
} catch (error) {
console.error('❌ Erreur obtention token:', error.message);
if (error.response) {
console.error(' Status HTTP:', error.response.status);
console.error(' Erreur détaillée:', JSON.stringify(error.response.data, null, 2));
}
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,
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 n.statut = 'approuve'
${campusWhere}
ORDER BY n.DateCreation DESC
`);
const notes = result.recordset;
for (const note of notes) {
const ncResult = await pool.request()
.input('noteId', sql.Int, note.id)
.query(`
SELECT fileName, motif, statut, dateSignalement
FROM JustificatifsNonConformes
WHERE noteDeFraisId = @noteId
ORDER BY dateSignalement DESC
`);
note.nonConformes = ncResult.recordset;
}
res.json(notes);
} catch (error) {
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 } = req.body;
const noteId = parseInt(req.params.id);
try {
// Récupérer la note
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];
// Marquer comme vérifiée
await pool.request()
.input('id', sql.Int, noteId)
.input('verificateurId', sql.Int, req.user.id)
.input('commentaire', sql.NVarChar, commentaire || null)
.query(`
UPDATE NoteDeFrais SET
statut = 'verifie',
verificateurFinanceId = @verificateurId,
dateVerification = GETDATE(),
commentaireVerification = @commentaire,
DateModification = GETDATE()
WHERE id = @id
`);
// Historique
await pool.request()
.input('noteId', sql.Int, noteId)
.input('validateurId', sql.Int, req.user.id)
.input('action', sql.NVarChar, 'verifier')
.input('commentaire', sql.NVarChar, commentaire || null)
.input('statut', sql.NVarChar, 'verifie')
.query(`
INSERT INTO HistoriqueValidation
(NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction)
VALUES
(@noteId, @validateurId, 'VERIF', @action, @commentaire, @statut, GETDATE())
`);
// Trouver les ValidateurFinance du même campus pour les notifier
const campusNorm = normalizeCampus(note.campus);
const validRequest = pool.request()
.input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%');
const validateurs = await validRequest.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 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';
for (const val of validateurs.recordset) {
// Notification BDD
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} €) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`,
noteId: noteId
});
} catch (e) { console.error('Notif ValidateurFinance:', e.message); }
// Email
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} (${montantFormate} €)
a été vérifiée par ${verificateurNom} et est prête pour le paiement.
${commentaire ? `
💬 Commentaire vérificateur : ${commentaire}
` : ''}
| Référence | ${note.reference} |
| Collaborateur | ${note.prenom} ${note.nom} |
| Montant | ${montantFormate} € |
| Campus | ${note.campus || '—'} |
`
);
} catch (e) { console.error('Email ValidateurFinance:', e.message); }
}
// Notifier aussi le collaborateur
try {
await creerNotification({
destinataireId: note.collaborateurId,
destinataireEmail: note.email,
type: 'paiement',
titre: `Note ${note.reference} en cours de traitement`,
message: `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`,
noteId: noteId
});
} catch (e) { console.error('Notif collab vérification:', e.message); }
res.json({ success: true, statut: 'verifie', notifiesCount: validateurs.recordset.length });
} catch (error) {
console.error('Erreur PUT verificateur/notes/:id/verifier:', error.message);
res.status(500).json({ error: error.message });
}
});
// GET /api/verificateur/historique
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.dateVerification, n.commentaireVerification,
n.lignesJson, n.fichiers,
c.nom + ' ' + c.prenom AS collaborateur,
c.campus, c.departement,
(SELECT COUNT(*) FROM JustificatifsNonConformes j
WHERE j.noteDeFraisId = n.id) AS nbNonConformes
FROM NoteDeFrais n
JOIN CollaborateurAD c ON c.id = n.collaborateurId
WHERE n.verificateurFinanceId = @verificateurId
AND n.statut IN ('verifie', 'paiementenattente', 'payee')
${campusWhere}
ORDER BY n.dateVerification DESC
`);
const notesAvecNC = await Promise.all(result.recordset.map(async row => {
const ncResult = await pool.request()
.input('noteId', sql.Int, row.id)
.query(`
SELECT fileName, motif, statut, dateSignalement
FROM JustificatifsNonConformes
WHERE noteDeFraisId = @noteId
ORDER BY dateSignalement DESC
`);
const nonConformes = ncResult.recordset;
let nbJustifs = 0;
try {
const fichiers = JSON.parse(row.fichiers || '[]');
const SYSTEME = ['soumission', 'resoumission', 'recap-paiement', 'recap', 'signe-approuve', 'signeapprouve', 'signe_approuve'];
nbJustifs = fichiers.filter(f =>
!SYSTEME.some(kw => (f.fileName || '').toLowerCase().includes(kw))
).length;
} catch { }
const nbNonConformes = nonConformes.length;
const nbConformes = Math.max(0, nbJustifs - nbNonConformes);
return {
...row,
nbJustifs,
nbConformes,
nbNonConformes,
nonConformes,
dateVerification: row.dateVerification,
commentaire: row.commentaireVerification,
};
}));
res.json(notesAvecNC);
} catch (error) {
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 };
}
async function downloadFromSharePoint(webUrl) {
const accessToken = await getGraphToken();
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 res = await axios.get(
`https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}:/content`,
{ headers: { Authorization: `Bearer ${accessToken}` }, responseType: 'arraybuffer' }
);
return Buffer.from(res.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());
}
// ══════════════════════════════════════════════════════
// 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' });
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);
console.log('🔍 isKmOnly:', isKmOnly, 'kmTotal:', kmTotal, 'montantTTC:', montantTTC);
const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple';
// POST /api/notes — ligne ~420
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')}`;
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);
for (const f of qrFichiers) {
try {
const buf = await downloadFromSharePoint(f.uploadUrl);
allFiles.push({ 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); }
}
}
}
// ✅ QR par ligne — récupère et STOCKE les fichiers dans qrFiles de chaque 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);
// ✅ Initialiser qrFiles pour cette ligne
if (!lignesParsed[i].qrFiles) lignesParsed[i].qrFiles = [];
for (const f of qrFichiers) {
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
};
// Ajouter à allFiles pour le récap PDF global
allFiles.push(fileObj);
// ✅ Uploader vers SP avec la référence finale et stocker dans qrFiles
try {
const uploaded = await uploadToSharePointHierarchique(
fileObj, reference, nomDossier, moisDossier
);
// Éviter les doublons
const dejaSauve = lignesParsed[i].qrFiles.some(x => x.fileName === uploaded.fileName);
if (!dejaSauve) {
lignesParsed[i].qrFiles.push({
fileName: uploaded.fileName,
uploadUrl: uploaded.uploadUrl
});
}
console.log(`✅ QR ligne ${i} stocké dans qrFiles: ${uploaded.fileName}`);
} catch (uploadErr) {
console.warn(`⚠️ Upload SP ligne ${i}:`, uploadErr.message);
}
} catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); }
}
} else {
console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`);
}
} catch (e) {
console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message);
}
}
// ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles
const lignesJsonFinal = JSON.stringify(lignesParsed);
const fichiersUploades = [];
for (const file of allFiles) {
try {
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
fichiersUploades.push(r);
} catch (e) { console.error(`❌ Upload justif ${file.originalname}:`, e.message); }
}
const noteDataPDF = {
reference,
nomPrenom,
mois: moisCapitalized,
date,
categorie: categorieNote,
libelle,
montant: montantFinal,
lignes: lignesParsed,
lignesJson: JSON.stringify(lignesParsed),
tarifKm: await getTarifKm(),
statut: 'enattente',
departement: collaborateur.departement,
participants: participants || null,
};
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
);
fichiersUploades.push(ficheResult);
console.log('✅ Fiche soumission uploadée:', ficheResult.fileName);
} catch (e) { console.error('❌ Génération fiche PDF:', e.message, e.stack); }
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
);
fichiersUploades.push(recapResult);
recapUrl = recapResult.uploadUrl;
console.log('✅ Récap PDF uploadé:', recapResult.fileName);
} catch (e) { console.error('❌ Génération récap PDF:', e.message); }
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, recapUrl || ficheResult?.uploadUrl || 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];
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}`);
const dateFormatee = new Date(date).toLocaleDateString('fr-FR');
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
try {
await 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('❌ Notif BDD collab:', e.message); }
try {
await 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('❌ Email accusé collab:', e.message); }
if (n1Id && emailN1) {
try {
await creerNotification({
destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation',
titre: `📋 Note à valider — ${reference}`,
message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de frais de ${montantFormate} € en attente de votre validation.`,
noteId: noteCreee.id
});
} catch (e) { console.error('❌ Notif BDD N1:', e.message); }
try {
await 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('❌ Email N1:', e.message); }
}
res.status(201).json({
success: true, id: noteCreee.id, reference: noteCreee.reference,
fichiers: fichiersUploades, recapUrl,
});
} 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', 'non_conforme_verif')
`);
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 === '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'
: '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;
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 = []; }
if (note.statut === 'non_conforme_verif') {
try {
const ncResult = await pool.request()
.input('noteId', sql.Int, note.id)
.query(`
SELECT fileName, motif, dateSignalement
FROM JustificatifsNonConformes
WHERE noteDeFraisId = @noteId
ORDER BY dateSignalement DESC
`);
note.nonConformes = ncResult.recordset;
} catch (e) { note.nonConformes = []; }
}
// ✅ 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 (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================
// 🔑 TOKEN SHAREPOINT (scope différent de Graph)
// ================================================
async function getSharePointToken() {
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' // ← scope SharePoint
});
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' } }
);
return response.data.access_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 : ' + url);
// ✅ Headers cache navigateur
res.setHeader('Cache-Control', 'private, max-age=600');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Disposition', 'inline');
// ✅ Vérifier cache serveur
const cached = getCached(url);
if (cached) {
res.setHeader('Content-Type', cached.contentType);
res.setHeader('X-Cache', 'HIT');
return res.send(cached.buffer);
}
try {
const buffer = await downloadFromSharePoint(url);
const urlLower = url.toLowerCase();
let contentType = 'application/octet-stream';
if (urlLower.includes('.pdf')) contentType = 'application/pdf';
else if (urlLower.includes('.jpg') || urlLower.includes('.jpeg')) contentType = 'image/jpeg';
else if (urlLower.includes('.png')) contentType = 'image/png';
setCache(url, buffer, contentType);
res.setHeader('Content-Type', contentType);
res.setHeader('X-Cache', 'MISS');
res.send(buffer);
} catch (err) {
console.error('❌ proxy-pdf erreur:', err.message);
res.status(500).json({ error: err.message, url });
}
});
// ================================================
// 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
// ================================================
// 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);
console.log('Validation demande', id, action, userId);
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())
`);
// Génération PDF signé
let signedPdfUrl = null;
try {
const validateurSelfResult = await pool.request()
.input('id', sql.Int, userId)
.query('SELECT prenom, nom FROM CollaborateurAD WHERE id = @id');
const validateurSelf = validateurSelfResult.recordset[0];
const nomValidateurActuel = (validateurSelf
? `${validateurSelf.prenom} ${validateurSelf.nom}`
: `${req.user.prenom} ${req.user.nom}`).trim();
const noteComplete = await pool.request()
.input('id', sql.Int, id)
.query(`
SELECT n.reference, n.libelle, n.montant, n.date, n.categorie,
n.montantHT, n.tauxTVA, n.km, n.participants, n.description,
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
`);
if (noteComplete.recordset.length) {
const nd = noteComplete.recordset[0];
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 (niveauValidation === 'N2') {
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
};
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve'
: nouveauStatut === 'refuse' ? 'signe-refuse' : `signe-${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')}`;
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');
signedPdfUrl = signedResult.uploadUrl;
console.log('PDF signé uploadé:', signedResult.fileName, suffixe);
}
} catch (pdfError) {
console.error('Erreur génération PDF signé:', pdfError.message);
}
// Notifications collaborateur + validateur suivant
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
const montantFormate = parseFloat(note.montant).toFixed(2);
const collabResult = await pool.request().input('id', sql.Int, note.collaborateurId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
const validateurResult = await pool.request().input('id', sql.Int, userId).query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id');
if (collabResult.recordset.length) {
const c = collabResult.recordset[0];
const v = validateurResult.recordset[0];
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 ${v?.prenom} ${v?.nom}.`
: `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`;
try { await creerNotification({ destinataireId: c.id, destinataireEmail: c.email, type: isRefus ? 'refus' : 'validation', titre: titreCollab, message: msgCollab, noteId: parseInt(id) }); } catch { }
// ── Email collaborateur ──────────────────────────────────────────
try {
const motifAffiche = motifRefus || commentaire || 'Non précisé';
const nomValidateur = `${v?.prenom || ''} ${v?.nom || ''}`.trim();
await sendMailGraph(
c.email,
isRefus
? `❌ Note refusée — action requise : ${note.reference}`
: titreCollab,
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 ${nomValidateur}.
Motif du refus
${motifAffiche}
| Référence | ${note.reference} |
| Libellé | ${note.libelle} |
| Montant | ${montantFormate} € |
| Refusé par | ${nomValidateur} |
| 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
Vous pouvez modifier votre note tant qu'elle est au statut "Refusée".
`
: `
${titreCollab}
Bonjour ${c.prenom} ${c.nom},
${msgCollab}
`
);
} catch { }
// Notifier N2 si validation N1
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];
try { await 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 { }
try {
await 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 { }
}
}
}
res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation });
} 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);
if (campusCode) {
request.input('campus', sql.NVarChar, `%${campusCode}%`);
campusWhere = `AND c.campus LIKE @campus`;
}
}
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' });
const erreurs = [];
for (const n of notes.recordset) {
const checks = await pool.request()
.input('collabId', sql.Int, n.collabId)
.query(`
SELECT IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays
FROM CollaborateurAD
WHERE id = @collabId
`);
const c = checks.recordset[0];
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);
// ── Config débiteur depuis .env ──────────────────────────────────
const cfg = await getConfigDebiteur();
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;
// ── Générer les transactions ──────────────────────────────────────
let transactions = '';
let numTx = 1;
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();
// BIC bénéficiaire : si présent utiliser, sinon NOTPROVIDED
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}
`;
numTx++;
}
// ── XML final au format PAIN.001.001.03 ──────────────────────────
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}
`;
// ── Upload XML sur SharePoint dans Virements/{annee}/{mois}/ ─────
let xmlSharepointUrl = null;
try {
const xmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`;
const xmlFolderPath = `Virements/${annee}/${mois}`;
const xmlUploadPath = `${xmlFolderPath}/${xmlFileName}`;
const accessToken = await getGraphToken();
if (accessToken) {
const spRes = 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
}
);
xmlSharepointUrl = spRes.data.webUrl;
console.log(`✅ XML virement uploadé sur SharePoint : ${xmlUploadPath}`);
}
} catch (spErr) {
console.error('⚠️ Upload XML SharePoint échoué (XML quand même téléchargé) :', spErr.message);
}
// ── Générer les PDF récap pour chaque note ────────────────────────
for (const note of notes.recordset) {
try {
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(`⚠️ 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: await getTarifKm(),
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(`✅ PDF récap-paiement généré pour ${note.reference}`);
} catch (pdfErr) {
console.error(`❌ PDF récap ${note.reference}:`, pdfErr.message);
}
}
// ── Passer en 'paiementenattente' + enregistrer date XML ─────────
await pool.request()
.input('dateXml', sql.DateTime, now)
.input('xmlUrl', sql.NVarChar, xmlSharepointUrl || null)
.query(`
UPDATE NoteDeFrais
SET statut = 'paiementenattente',
dateXml = @dateXml,
DateModification = GETDATE()
WHERE id IN (${idList})
AND statut IN ('approuve', 'approuvé', 'verifie')
`);
// ── Notifier chaque collaborateur ─────────────────────────────────
for (const n of notes.recordset) {
try {
await creerNotification({
destinataireId: n.collabId,
destinataireEmail: n.email,
type: 'paiement',
titre: `Paiement en cours de traitement : ${n.reference}`,
message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)} € est en cours de traitement bancaire.`,
noteId: n.id
});
} catch (e) { console.error('Notif paiementenattente:', e.message); }
}
// ── Téléchargement du XML côté client ────────────────────────────
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);
} 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 cfg = await getConfigDebiteur();
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
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 TOP 1 id, companyName, companyIban, companyBic,
companyAddress, companyCp, companyVille, companyPays,
DateModification
FROM ConfigDebiteurXML WHERE actif = 1
ORDER BY DateModification DESC
`);
res.json(result.recordset[0] ?? null);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// PUT /api/paiements/config-debiteur
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 } = 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();
try {
// Désactiver l'ancienne config et insérer la nouvelle
await pool.request().query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1`);
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('modifiePar', sql.Int, req.user.id)
.query(`
INSERT INTO ConfigDebiteurXML
(companyName, companyIban, companyBic, companyAddress,
companyCp, companyVille, companyPays, actif, modifiePar,
DateCreation, DateModification)
VALUES
(@companyName, @companyIban, @companyBic, @companyAddress,
@companyCp, @companyVille, @companyPays, 1, @modifiePar,
GETDATE(), GETDATE())
`);
console.log(`✅ Config débiteur XML mise à jour par ${req.user.email}`);
res.json({ success: true, companyName, companyIban: ibanClean, companyBic: bicClean });
} catch (e) {
console.error('PUT /api/paiements/config-debiteur:', e.message);
res.status(500).json({ error: e.message });
}
});
// 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();
const societeSet = new Set();
for (const row of result.recordset) {
if (row.campus) {
const code = (() => {
const c = row.campus.toUpperCase();
if (c.includes('SQY') || c.includes('SAINT')) 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 row.campus;
})();
campusSet.add(code);
}
if (row.societe && row.societe.trim()) {
societeSet.add(row.societe.trim());
}
}
res.json({
campus: [...campusSet].sort(),
societes: [...societeSet].sort()
});
} 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);