464 lines
13 KiB
JavaScript
464 lines
13 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;
|
|
|
|
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
|
|
if (status === "NOT_TAKEN") {
|
|
continue;
|
|
}
|
|
|
|
// 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
|
|
const hasExpectedQuantity = typeof expectedQuantity === "number";
|
|
const hasActualQuantity = typeof quantity === "number";
|
|
if (hasExpectedQuantity && hasActualQuantity && 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) {
|
|
const rawDate =
|
|
event?.StartDateTime ||
|
|
event?.startDateTime ||
|
|
event?.startDate ||
|
|
event?.eventDate;
|
|
|
|
const parsedDate = parseFirestoreDate(rawDate);
|
|
const safeDate = parsedDate || new Date();
|
|
|
|
return safeDate.toLocaleDateString("fr-FR", {
|
|
day: "numeric",
|
|
month: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function parseFirestoreDate(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof value.toDate === "function") {
|
|
return value.toDate();
|
|
}
|
|
|
|
if (value instanceof Date) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
if (typeof value === "object" && typeof value.seconds === "number") {
|
|
return new Date(value.seconds * 1000);
|
|
}
|
|
|
|
if (typeof value === "object" && typeof value._seconds === "number") {
|
|
return new Date(value._seconds * 1000);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|