Files
EM2_ERP/em2rp/functions/processEquipmentValidation.js
ElPoyo 8cd4854924 refactor: Amélioration des performances et migration des Cloud Functions
Cette mise à jour majeure vise à améliorer significativement les performances de l'application, en particulier au démarrage, et à standardiser l'infrastructure backend. Les principaux changements incluent la migration de toutes les Cloud Functions vers une région européenne (`europe-west9`), l'optimisation du chargement des données, et l'introduction d'un moniteur de performance pour le débogage.

**Changements Backend (Cloud Functions) :**

-   **Migration de la Région :**
    -   Toutes les Cloud Functions ont été déplacées de `us-central1` à `europe-west9` (Paris) pour réduire la latence pour les utilisateurs européens. Cela concerne les appels depuis le frontend (ex: `api_config.dart`, `email_service.dart`) et les définitions des fonctions elles-mêmes (`index.js`, etc.).
-   **Standardisation des Fonctions :**
    -   La plupart des fonctions `onCall` (v1) ont été migrées vers le format `onRequest` (v2) avec une gestion d'authentification et de CORS unifiée, améliorant la robustesse et la cohérence.
    -   Les triggers Firestore (`onDocumentCreated`, `onDocumentUpdated`) et les tâches planifiées (`onSchedule`) ont été mis à jour pour spécifier explicitement la région `europe-west9`.
-   **Mise à jour des Index Firestore :**
    -   Les index `firestore.indexes.json` ont été mis à jour pour supporter les nouvelles requêtes de l'application et optimiser les performances de filtrage.

**Améliorations des Performances Frontend :**

-   **Chargement Asynchrone et Mis en Cache :**
    -   Le chargement des données utilisateur (`LocalUserProvider`) et des événements (`EventProvider`) a été optimisé pour utiliser un cache local à court terme (5 minutes pour l'utilisateur, 30 secondes pour les événements).
    -   Les données ne sont rechargées que si le cache a expiré ou si un rechargement est forcé, évitant des appels réseau redondants et accélérant la navigation.
-   **Démarrage de l'Application Optimisé :**
    -   Le processus de connexion automatique (`main.dart`) a été revu. L'application navigue désormais immédiatement vers la page demandée sans attendre la fin du chargement des données utilisateur, qui s'effectue en arrière-plan.
    -   Un écran de chargement plus esthétique avec le logo de l'entreprise a été ajouté, remplaçant l'indicateur de chargement simple.
-   **Chargement de la Page Calendrier :**
    -   Le chargement et la sélection de l'événement par défaut sur la page `CalendarPage` sont maintenant entièrement asynchrones, rendant l'affichage de la page quasi instantané.

**Nouveaux Outils et Améliorations UX :**

-   **Moniteur de Performance :**
    -   Ajout d'un nouvel outil `PerformanceMonitor` (`lib/utils/performance_monitor.dart`) pour mesurer précisément le temps d'exécution des opérations critiques (appels API, parsing, etc.) en mode débogage. Il aide à identifier les goulots d'étranglement.
-   **Amélioration du Formulaire de Connexion :**
    -   Les champs "Email" et "Mot de passe" sur la page de connexion (`LoginPage`) supportent désormais l'autocomplétion du navigateur (`AutofillGroup`).
    -   Appuyer sur "Entrée" dans l'un des champs déclenche désormais la connexion, améliorant l'ergonomie.

**Mise à jour de la version :**

-   La version de l'application a été incrémentée à `1.0.9`.
2026-02-09 10:14:52 +01:00

419 lines
12 KiB
JavaScript

const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
/**
* Traite la validation du matériel d'un événement
* Appelée par le client lors du chargement/déchargement
* Crée automatiquement les alertes nécessaires
*/
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
}, async (request) => {
try {
// L'authentification est automatique avec onCall
const {auth, data} = request;
if (!auth) {
throw new Error('L\'utilisateur doit être authentifié');
}
const {
eventId,
equipmentList, // [{equipmentId, status, quantity, etc.}]
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
} = data;
// Validation
if (!eventId || !equipmentList || !validationType) {
throw new Error('eventId, equipmentList et validationType sont requis');
}
const db = admin.firestore();
const alerts = [];
// 1. Récupérer les détails de l'événement
const eventRef = db.collection('events').doc(eventId);
const eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
}
const event = eventDoc.data();
const eventName = event.Name || event.name || 'Événement inconnu';
const eventDate = formatEventDate(event);
// 2. Analyser les équipements et détecter les problèmes
for (const equipment of equipmentList) {
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Cas 1: Équipement PERDU
if (status === 'LOST') {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
// Cas 3: Quantité incorrecte
if (expectedQuantity && quantity !== expectedQuantity) {
const alertData = await createAlertInFirestore({
type: 'QUANTITY_MISMATCH',
severity: 'INFO',
title: 'Quantité incorrecte',
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
expected: expectedQuantity,
actual: quantity,
},
});
alerts.push(alertData);
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
eventName,
eventDate,
createdBy: auth.uid,
metadata: {
validationType,
equipment,
},
});
alerts.push(alertData);
}
}
// 3. Mettre à jour les équipements de l'événement
await eventRef.update({
equipment: equipmentList,
lastValidation: {
type: validationType,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
by: auth.uid,
},
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
await sendAlertNotifications(alert, eventId);
} catch (notificationError) {
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
}
}
}
return {
success: true,
alertsCreated: alerts.length,
criticalAlertsCount: criticalAlerts.length,
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
throw error;
}
});
/**
* Crée une alerte dans Firestore
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
emailSent: false,
assignedTo: [],
};
await alertRef.set(fullAlertData);
return {...fullAlertData, id: alertRef.id};
}
/**
* Détermine les utilisateurs à notifier et envoie les notifications
*/
async function sendAlertNotifications(alert, eventId) {
const db = admin.firestore();
const targetUserIds = new Set();
const usersWithPermission = new Set();
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
const allUsersSnapshot = await db.collection('users').get();
// Créer un map pour stocker les références de rôles à récupérer
const roleRefs = new Map();
for (const doc of allUsersSnapshot.docs) {
const user = doc.data();
if (!user.role) {
continue;
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
if (typeof user.role === 'string') {
rolePath = user.role;
roleId = user.role.split('/').pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
if (roleId && !roleRefs.has(roleId)) {
roleRefs.set(roleId, {users: [], rolePath});
}
if (roleId) {
roleRefs.get(roleId).users.push(doc.id);
}
}
// 2. Récupérer les permissions de chaque rôle unique
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
try {
const roleDoc = await db.collection('roles').doc(roleId).get();
if (roleDoc.exists) {
const roleData = roleDoc.data();
const permissions = roleData.permissions || [];
// Vérifier si le rôle a la permission view_all_events
if (permissions.includes('view_all_events')) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
});
}
}
} catch (error) {
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
}
}
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
const workforce = event.workforce || [];
workforce.forEach((member) => {
// Extraire l'userId selon différentes structures possibles
let userId = null;
if (typeof member === 'string') {
userId = member;
} else if (member.userId) {
userId = member.userId;
} else if (member.id) {
userId = member.id;
} else if (member.user) {
if (typeof member.user === 'string') {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
}
}
if (userId) {
targetUserIds.add(userId);
}
});
}
}
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
throw error;
}
}
/**
* Envoie les emails d'alerte
*/
async function sendAlertEmails(alert, userIds) {
try {
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
const db = admin.firestore();
// Vérifier que EMAIL_CONFIG est disponible
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
return 0;
}
const transporter = nodemailer.createTransport(getSmtpConfig());
let successCount = 0;
// Envoyer les emails par lots de 5
const batches = [];
for (let i = 0; i < userIds.length; i += 5) {
batches.push(userIds.slice(i, i + 5));
}
for (const batch of batches) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
if (!userDoc.exists) {
return false;
}
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
if (!user.email) {
return false;
}
// Préparer et envoyer l'email
let html;
try {
const templateData = await prepareTemplateData(alert, user);
html = await renderTemplate('alert-individual', templateData);
} catch (templateError) {
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
html = `
<html>
<body>
<h2>${alert.title || 'Nouvelle alerte'}</h2>
<p>${alert.message}</p>
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
</body>
</html>
`;
}
await transporter.sendMail({
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
to: user.email,
replyTo: EMAIL_CONFIG.replyTo,
subject: getEmailSubject(alert),
html: html,
text: alert.message,
});
return true;
} catch (error) {
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
return false;
}
});
const results = await Promise.all(promises);
successCount += results.filter((r) => r).length;
}
// Mettre à jour l'alerte
await db.collection('alerts').doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
});
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
return 0;
}
}
/**
* Formate la date d'un événement
*/
function formatEventDate(event) {
if (event.startDate) {
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
}
return 'Date inconnue';
}