feat: implement comprehensive Firebase Functions backend for equipment management and migrate core repository services

This commit is contained in:
ElPoyo
2026-05-26 15:35:48 +02:00
parent 323df01afe
commit ea1e1335e3
37 changed files with 6315 additions and 6140 deletions
+9 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
node: true,
},
parserOptions: {
"ecmaVersion": 2018,
"ecmaVersion": 2020,
},
extends: [
"eslint:recommended",
@@ -14,6 +14,14 @@ module.exports = {
"no-restricted-globals": ["error", "name", "length"],
"prefer-arrow-callback": "error",
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
"max-len": "off",
"valid-jsdoc": "off",
"require-jsdoc": "off",
"guard-for-in": "off",
"no-unused-vars": "warn",
"brace-style": "off",
"object-curly-spacing": "off",
"arrow-parens": "off",
},
overrides: [
{
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,34 +1,34 @@
const {onRequest} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const logger = require('firebase-functions/logger');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
const auth = require('./utils/auth');
const {onRequest} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const logger = require("firebase-functions/logger");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
const auth = require("./utils/auth");
// Configuration CORS
const setCorsHeaders = (res, req) => {
// Utiliser l'origin de la requête pour permettre les credentials
const origin = req.headers.origin || '*';
const origin = req.headers.origin || "*";
res.set('Access-Control-Allow-Origin', origin);
res.set("Access-Control-Allow-Origin", origin);
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
if (origin !== '*') {
res.set('Access-Control-Allow-Credentials', 'true');
if (origin !== "*") {
res.set("Access-Control-Allow-Credentials", "true");
}
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.set('Access-Control-Max-Age', '3600');
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With");
res.set("Access-Control-Max-Age", "3600");
};
const withCors = (handler) => {
return async (req, res) => {
setCorsHeaders(res, req);
// Gérer les requêtes preflight OPTIONS immédiatement
if (req.method === 'OPTIONS') {
res.status(204).send('');
if (req.method === "OPTIONS") {
res.status(204).send("");
return;
}
try {
@@ -48,8 +48,8 @@ const withCors = (handler) => {
*/
exports.createAlert = onRequest({
cors: false,
invoker: 'public',
region: 'europe-west9'
invoker: "public",
region: "europe-west9",
}, withCors(async (req, res) => {
try {
// Vérifier l'authentification
@@ -70,7 +70,7 @@ exports.createAlert = onRequest({
// Validation des données
if (!type || !severity || !message) {
res.status(400).json({error: 'type, severity et message sont requis'});
res.status(400).json({error: "type, severity et message sont requis"});
return;
}
@@ -78,12 +78,12 @@ exports.createAlert = onRequest({
const userIds = await determineTargetUsers(type, severity, eventId);
if (userIds.length === 0) {
res.status(400).json({error: 'Aucun utilisateur à notifier'});
res.status(400).json({error: "Aucun utilisateur à notifier"});
return;
}
// 2. Créer l'alerte dans Firestore
const alertRef = admin.firestore().collection('alerts').doc();
const alertRef = admin.firestore().collection("alerts").doc();
const alertData = {
id: alertRef.id,
type,
@@ -99,14 +99,14 @@ exports.createAlert = onRequest({
createdBy: decodedToken.uid,
isRead: false,
emailSent: false,
status: 'ACTIVE',
status: "ACTIVE",
};
await alertRef.set(alertData);
// 3. Envoyer les emails si alerte critique
let emailResults = {};
if (severity === 'CRITICAL') {
if (severity === "CRITICAL") {
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
// Mettre à jour le statut d'envoi
@@ -124,7 +124,7 @@ exports.createAlert = onRequest({
emailsSent: Object.values(emailResults).filter((v) => v).length,
});
} catch (error) {
logger.error('[createAlert] Erreur:', error);
logger.error("[createAlert] Erreur:", error);
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
}
}));
@@ -137,23 +137,23 @@ async function determineTargetUsers(alertType, severity, eventId) {
const targetUserIds = new Set();
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
const allUsersSnapshot = await db.collection('users').get();
const allUsersSnapshot = await db.collection("users").get();
allUsersSnapshot.forEach((doc) => {
const user = doc.data();
if (user.role) {
// Le rôle peut être une référence Firestore ou une string
let rolePath = '';
if (typeof user.role === 'string') {
let rolePath = "";
if (typeof user.role === "string") {
rolePath = user.role;
} else if (user.role.path) {
rolePath = user.role.path;
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
}
// Vérifier si c'est un admin (path = "roles/ADMIN")
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") {
targetUserIds.add(doc.id);
}
}
@@ -162,7 +162,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
// 2. Si un événement est lié, ajouter tous les membres de la workforce
if (eventId) {
try {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -177,7 +177,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
}
} catch (error) {
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
logger.error("[determineTargetUsers] Erreur récupération événement:", error);
}
}
@@ -222,7 +222,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const db = admin.firestore();
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -250,7 +250,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
const templateData = await prepareTemplateData(alertData, user);
// Rendre le template
const html = await renderTemplate('alert-individual', templateData);
const html = await renderTemplate("alert-individual", templateData);
// Envoyer l'email
await transporter.sendMail({
+24 -24
View File
@@ -4,9 +4,9 @@
* Avec système de cache dans Firebase Storage
*/
const textToSpeech = require('@google-cloud/text-to-speech');
const crypto = require('crypto');
const logger = require('firebase-functions/logger');
const textToSpeech = require("@google-cloud/text-to-speech");
const crypto = require("crypto");
const logger = require("firebase-functions/logger");
/**
* Génère un hash MD5 pour le texte (utilisé comme clé de cache)
@@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger');
function generateCacheKey(text, voiceConfig = {}) {
const cacheString = JSON.stringify({
text,
lang: voiceConfig.languageCode || 'fr-FR',
voice: voiceConfig.name || 'fr-FR-Standard-B',
lang: voiceConfig.languageCode || "fr-FR",
voice: voiceConfig.name || "fr-FR-Standard-B",
});
return crypto.createHash('md5').update(cacheString).digest('hex');
return crypto.createHash("md5").update(cacheString).digest("hex");
}
/**
@@ -34,21 +34,21 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
try {
// Validation du texte
if (!text || text.trim().length === 0) {
throw new Error('Text cannot be empty');
throw new Error("Text cannot be empty");
}
if (text.length > 5000) {
throw new Error('Text too long (max 5000 characters)');
throw new Error("Text too long (max 5000 characters)");
}
// Configuration par défaut de la voix
const defaultVoiceConfig = {
languageCode: 'fr-FR',
name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit)
ssmlGender: 'MALE',
languageCode: "fr-FR",
name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit)
ssmlGender: "MALE",
};
const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig };
const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig};
// Générer la clé de cache
const cacheKey = generateCacheKey(text, finalVoiceConfig);
@@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [exists] = await file.exists();
if (exists) {
logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) });
logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)});
// Générer une URL signée valide 7 jours
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
};
}
logger.info('[generateTTS] ○ Cache MISS - Generating audio', {
logger.info("[generateTTS] ○ Cache MISS - Generating audio", {
cacheKey,
text: text.substring(0, 50),
voice: finalVoiceConfig.name,
@@ -85,10 +85,10 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
// Configuration de la requête
const request = {
input: { text: text },
input: {text: text},
voice: finalVoiceConfig,
audioConfig: {
audioEncoding: 'MP3',
audioEncoding: "MP3",
speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension
pitch: -2.0, // Voix un peu plus grave
volumeGainDb: 0.0,
@@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
const [response] = await client.synthesizeSpeech(request);
if (!response.audioContent) {
throw new Error('No audio content returned from TTS API');
throw new Error("No audio content returned from TTS API");
}
logger.info('[generateTTS] ✓ Audio generated', {
logger.info("[generateTTS] ✓ Audio generated", {
size: response.audioContent.length,
});
// Sauvegarder dans Firebase Storage
await file.save(response.audioContent, {
metadata: {
contentType: 'audio/mpeg',
contentType: "audio/mpeg",
metadata: {
text: text.substring(0, 100), // Premier 100 caractères pour debug
voice: finalVoiceConfig.name,
@@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
},
});
logger.info('[generateTTS] ✓ Audio cached', { fileName });
logger.info("[generateTTS] ✓ Audio cached", {fileName});
// Générer une URL signée
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours
});
@@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
cacheKey,
};
} catch (error) {
logger.error('[generateTTS] ✗ Error', {
logger.error("[generateTTS] ✗ Error", {
error: error.message,
code: error.code,
text: text?.substring(0, 50),
@@ -142,5 +142,5 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) {
}
}
module.exports = { generateTTS, generateCacheKey };
module.exports = {generateTTS, generateCacheKey};
+276 -4333
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -2,17 +2,17 @@
* Script de migration : Active les emails pour tous les utilisateurs existants
* À exécuter une seule fois après le déploiement
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
// AJOUTER CECI : Charger le fichier de clé
const serviceAccount = require('./serviceAccountKey.json');
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin avec les credentials explicites
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
projectId: 'em2rp-951dc',
projectId: "em2rp-951dc",
});
}
@@ -22,11 +22,11 @@ const db = admin.firestore();
* Active les notifications par email pour tous les utilisateurs existants
*/
async function migrateEmailPreferences() {
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n");
try {
// 1. Récupérer tous les utilisateurs
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
console.log(`${usersSnapshot.size} utilisateurs trouvés\n`);
// 2. Préparer les updates
@@ -49,7 +49,7 @@ async function migrateEmailPreferences() {
updates.push({
ref: doc.ref,
data: {
'notificationPreferences.emailEnabled': true,
"notificationPreferences.emailEnabled": true,
},
});
}
@@ -83,7 +83,7 @@ async function migrateEmailPreferences() {
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
}
console.log('=== FIN MIGRATION ===');
console.log("=== FIN MIGRATION ===");
return {
success: true,
total: usersSnapshot.size,
@@ -91,7 +91,7 @@ async function migrateEmailPreferences() {
updated: toUpdate,
};
} catch (error) {
console.error('❌ ERREUR MIGRATION:', error);
console.error("❌ ERREUR MIGRATION:", error);
throw error;
}
}
@@ -99,15 +99,15 @@ async function migrateEmailPreferences() {
// Exécuter la migration si appelé directement
if (require.main === module) {
migrateEmailPreferences()
.then((result) => {
console.log('\n✓ Migration réussie:', result);
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Migration échouée:', error);
process.exit(1);
});
.then((result) => {
console.log("\n✓ Migration réussie:", result);
process.exit(0);
})
.catch((error) => {
console.error("\n❌ Migration échouée:", error);
process.exit(1);
});
}
module.exports = { migrateEmailPreferences };
module.exports = {migrateEmailPreferences};
+26 -27
View File
@@ -5,28 +5,28 @@
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
const admin = require("firebase-admin");
const serviceAccount = require("./serviceAccountKey.json");
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
credential: admin.credential.cert(serviceAccount),
});
const db = admin.firestore();
async function migrateEquipmentIds() {
console.log('🔧 Migration: Ajout du champ id aux équipements');
console.log('================================================\n');
console.log("🔧 Migration: Ajout du champ id aux équipements");
console.log("================================================\n");
try {
// Récupérer tous les équipements
const equipmentsSnapshot = await db.collection('equipments').get();
const equipmentsSnapshot = await db.collection("equipments").get();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const errorCount = 0;
const batch = db.batch();
let batchCount = 0;
@@ -34,12 +34,12 @@ async function migrateEquipmentIds() {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
if (!data.id || data.id === "") {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, { id: doc.id });
batch.update(doc.ref, {id: doc.id});
batchCount++;
updatedCount++;
@@ -58,36 +58,35 @@ async function migrateEquipmentIds() {
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
console.log('\n================================================');
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('================================================');
console.log("\n================================================");
console.log("📊 RÉSUMÉ DE LA MIGRATION");
console.log("================================================");
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
console.log(`Équipements mis à jour: ${updatedCount}`);
console.log(`Erreurs: ${errorCount}`);
console.log('================================================\n');
console.log("================================================\n");
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
console.log("✅ Tous les équipements ont déjà un champ id !");
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
console.log("✅ Migration terminée avec succès !");
} else {
console.log('⚠️ Migration terminée avec des erreurs');
console.log("⚠️ Migration terminée avec des erreurs");
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
console.error("❌ Erreur lors de la migration:", error);
throw error;
}
}
// Exécuter la migration
migrateEquipmentIds()
.then(() => {
console.log('\n✅ Script terminé');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script échoué:', error);
process.exit(1);
});
.then(() => {
console.log("\n✅ Script terminé");
process.exit(0);
})
.catch((error) => {
console.error("\n❌ Script échoué:", error);
process.exit(1);
});
+94 -94
View File
@@ -1,8 +1,8 @@
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');
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
@@ -10,14 +10,14 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
*/
exports.processEquipmentValidation = onCall({
cors: true,
region: 'europe-west9'
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é');
throw new Error("L'utilisateur doit être authentifié");
}
const {
@@ -28,22 +28,22 @@ exports.processEquipmentValidation = onCall({
// Validation
if (!eventId || !equipmentList || !validationType) {
throw new Error('eventId, equipmentList et validationType sont requis');
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 eventRef = db.collection("events").doc(eventId);
const eventDoc = await eventRef.get();
if (!eventDoc.exists) {
throw new Error('Événement introuvable');
throw new Error("Événement introuvable");
}
const event = eventDoc.data();
const eventName = event.Name || event.name || 'Événement inconnu';
const eventName = event.Name || event.name || "Événement inconnu";
const eventDate = formatEventDate(event);
// 2. Analyser les équipements et détecter les problèmes
@@ -51,16 +51,16 @@ exports.processEquipmentValidation = onCall({
const {equipmentId, status, quantity, expectedQuantity} = equipment;
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
if (status === 'NOT_TAKEN') {
if (status === "NOT_TAKEN") {
continue;
}
// Cas 1: Équipement PERDU
if (status === 'LOST') {
if (status === "LOST") {
const alertData = await createAlertInFirestore({
type: 'LOST',
severity: 'CRITICAL',
title: 'Équipement perdu',
type: "LOST",
severity: "CRITICAL",
title: "Équipement perdu",
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -76,11 +76,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 2: Équipement MANQUANT
if (status === 'MISSING') {
if (status === "MISSING") {
const alertData = await createAlertInFirestore({
type: 'EQUIPMENT_MISSING',
severity: 'WARNING',
title: 'Équipement manquant',
type: "EQUIPMENT_MISSING",
severity: "WARNING",
title: "Équipement manquant",
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -96,13 +96,13 @@ exports.processEquipmentValidation = onCall({
}
// Cas 3: Quantité incorrecte
const hasExpectedQuantity = typeof expectedQuantity === 'number';
const hasActualQuantity = typeof quantity === 'number';
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',
type: "QUANTITY_MISMATCH",
severity: "INFO",
title: "Quantité incorrecte",
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
equipmentId,
eventId,
@@ -120,11 +120,11 @@ exports.processEquipmentValidation = onCall({
}
// Cas 4: Équipement endommagé
if (status === 'DAMAGED') {
if (status === "DAMAGED") {
const alertData = await createAlertInFirestore({
type: 'DAMAGED',
severity: 'WARNING',
title: 'Équipement endommagé',
type: "DAMAGED",
severity: "WARNING",
title: "Équipement endommagé",
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
equipmentId,
eventId,
@@ -151,7 +151,7 @@ exports.processEquipmentValidation = onCall({
});
// 4. Envoyer les notifications pour les alertes critiques
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
if (criticalAlerts.length > 0) {
for (const alert of criticalAlerts) {
try {
@@ -169,7 +169,7 @@ exports.processEquipmentValidation = onCall({
alertIds: alerts.map((a) => a.id),
};
} catch (error) {
logger.error('[processEquipmentValidation] Erreur:', error);
logger.error("[processEquipmentValidation] Erreur:", error);
throw error;
}
});
@@ -179,14 +179,14 @@ exports.processEquipmentValidation = onCall({
*/
async function createAlertInFirestore(alertData) {
const db = admin.firestore();
const alertRef = db.collection('alerts').doc();
const alertRef = db.collection("alerts").doc();
const fullAlertData = {
id: alertRef.id,
...alertData,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
status: 'ACTIVE',
status: "ACTIVE",
emailSent: false,
assignedTo: [],
};
@@ -206,7 +206,7 @@ async function sendAlertNotifications(alert, eventId) {
try {
// 1. Récupérer TOUS les utilisateurs et leurs permissions
const allUsersSnapshot = await db.collection('users').get();
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();
@@ -219,17 +219,17 @@ async function sendAlertNotifications(alert, eventId) {
}
// Extraire le chemin du rôle
let rolePath = '';
let roleId = '';
let rolePath = "";
let roleId = "";
if (typeof user.role === 'string') {
if (typeof user.role === "string") {
rolePath = user.role;
roleId = user.role.split('/').pop();
roleId = user.role.split("/").pop();
} else if (user.role.path) {
rolePath = user.role.path;
roleId = user.role.path.split('/').pop();
roleId = user.role.path.split("/").pop();
} else if (user.role._path && user.role._path.segments) {
rolePath = user.role._path.segments.join('/');
rolePath = user.role._path.segments.join("/");
roleId = user.role._path.segments[user.role._path.segments.length - 1];
}
@@ -245,14 +245,14 @@ async function sendAlertNotifications(alert, eventId) {
// 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();
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')) {
if (permissions.includes("view_all_events")) {
users.forEach((userId) => {
usersWithPermission.add(userId);
targetUserIds.add(userId);
@@ -266,7 +266,7 @@ async function sendAlertNotifications(alert, eventId) {
// 3. Ajouter la workforce de l'événement
if (eventId) {
const eventDoc = await db.collection('events').doc(eventId).get();
const eventDoc = await db.collection("events").doc(eventId).get();
if (eventDoc.exists) {
const event = eventDoc.data();
@@ -276,14 +276,14 @@ async function sendAlertNotifications(alert, eventId) {
// Extraire l'userId selon différentes structures possibles
let userId = null;
if (typeof member === 'string') {
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') {
if (typeof member.user === "string") {
userId = member.user;
} else if (member.user.id) {
userId = member.user.id;
@@ -300,18 +300,18 @@ async function sendAlertNotifications(alert, eventId) {
const userIds = Array.from(targetUserIds);
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
await db.collection('alerts').doc(alert.id).update({
await db.collection("alerts").doc(alert.id).update({
assignedTo: userIds,
});
// 5. Envoyer les emails si alerte critique
if (alert.severity === 'CRITICAL') {
if (alert.severity === "CRITICAL") {
await sendAlertEmails(alert, userIds);
}
return userIds;
} catch (error) {
logger.error('[sendAlertNotifications] Erreur:', error);
logger.error("[sendAlertNotifications] Erreur:", error);
throw error;
}
}
@@ -321,12 +321,12 @@ async function sendAlertNotifications(alert, eventId) {
*/
async function sendAlertEmails(alert, userIds) {
try {
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
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é');
logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré");
return 0;
}
@@ -343,7 +343,7 @@ async function sendAlertEmails(alert, userIds) {
const promises = batch.map(async (userId) => {
try {
// Récupérer l'utilisateur
const userDoc = await db.collection('users').doc(userId).get();
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
return false;
@@ -351,55 +351,55 @@ async function sendAlertEmails(alert, userIds) {
const user = userDoc.data();
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
// Vérifier les préférences email
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
return false;
}
if (!user.email) {
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 = `
// 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>
<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;
}
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({
await db.collection("alerts").doc(alert.id).update({
emailSent: true,
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
emailsSentCount: successCount,
@@ -407,7 +407,7 @@ async function sendAlertEmails(alert, userIds) {
return successCount;
} catch (error) {
logger.error('[sendAlertEmails] Erreur globale:', error);
logger.error("[sendAlertEmails] Erreur globale:", error);
return 0;
}
}
@@ -425,10 +425,10 @@ function formatEventDate(event) {
const parsedDate = parseFirestoreDate(rawDate);
const safeDate = parsedDate || new Date();
return safeDate.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'numeric',
year: 'numeric',
return safeDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "numeric",
year: "numeric",
});
}
@@ -437,7 +437,7 @@ function parseFirestoreDate(value) {
return null;
}
if (typeof value.toDate === 'function') {
if (typeof value.toDate === "function") {
return value.toDate();
}
@@ -445,16 +445,16 @@ function parseFirestoreDate(value) {
return value;
}
if (typeof value === 'string' || typeof value === 'number') {
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') {
if (typeof value === "object" && typeof value.seconds === "number") {
return new Date(value.seconds * 1000);
}
if (typeof value === 'object' && typeof value._seconds === 'number') {
if (typeof value === "object" && typeof value._seconds === "number") {
return new Date(value._seconds * 1000);
}
+60 -60
View File
@@ -1,51 +1,51 @@
const {onCall} = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
/**
* Envoie un email d'alerte à un utilisateur
* Appelé par le client Dart via callable function
*/
exports.sendAlertEmail = onCall({
region: 'europe-west9',
cors: true
region: "europe-west9",
cors: true,
}, async (request) => {
// Vérifier l'authentification
if (!request.auth) {
throw new Error('L\'utilisateur doit être authentifié');
throw new Error("L'utilisateur doit être authentifié");
}
const {alertId, userId, templateType} = request.data;
if (!alertId || !userId) {
throw new Error('alertId et userId sont requis');
throw new Error("alertId et userId sont requis");
}
try {
// Récupérer l'alerte depuis Firestore
const alertDoc = await admin.firestore()
.collection('alerts')
.collection("alerts")
.doc(alertId)
.get();
if (!alertDoc.exists) {
throw new Error('Alerte introuvable');
throw new Error("Alerte introuvable");
}
const alert = alertDoc.data();
// Récupérer l'utilisateur
const userDoc = await admin.firestore()
.collection('users')
.collection("users")
.doc(userId)
.get();
if (!userDoc.exists) {
throw new Error('Utilisateur introuvable');
throw new Error("Utilisateur introuvable");
}
const user = userDoc.data();
@@ -54,7 +54,7 @@ exports.sendAlertEmail = onCall({
const prefs = user.notificationPreferences || {};
if (!prefs.emailEnabled) {
console.log(`Email désactivé pour l'utilisateur ${userId}`);
return {success: true, skipped: true, reason: 'email_disabled'};
return {success: true, skipped: true, reason: "email_disabled"};
}
// Vérifier la préférence pour ce type d'alerte
@@ -62,7 +62,7 @@ exports.sendAlertEmail = onCall({
const shouldSend = checkAlertPreference(alertType, prefs);
if (!shouldSend) {
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
return {success: true, skipped: true, reason: 'alert_type_disabled'};
return {success: true, skipped: true, reason: "alert_type_disabled"};
}
// Préparer les données pour le template
@@ -70,7 +70,7 @@ exports.sendAlertEmail = onCall({
// Rendre le template HTML
const html = await renderTemplate(
templateType || 'alert-individual',
templateType || "alert-individual",
templateData,
);
@@ -88,7 +88,7 @@ exports.sendAlertEmail = onCall({
text: alert.message,
});
console.log('Email envoyé:', info.messageId);
console.log("Email envoyé:", info.messageId);
// Marquer l'email comme envoyé dans l'alerte
await alertDoc.ref.update({
@@ -102,7 +102,7 @@ exports.sendAlertEmail = onCall({
skipped: false,
};
} catch (error) {
console.error('Erreur envoi email:', error);
console.error("Erreur envoi email:", error);
throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`);
}
});
@@ -112,13 +112,13 @@ exports.sendAlertEmail = onCall({
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -130,12 +130,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -146,7 +146,7 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
@@ -155,22 +155,22 @@ async function prepareTemplateData(alert, user) {
data.eventName = event.Name;
if (event.StartDateTime) {
const date = event.StartDateTime.toDate();
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
} catch (error) {
console.error('Erreur récupération événement:', error);
console.error("Erreur récupération événement:", error);
}
}
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -178,7 +178,7 @@ async function prepareTemplateData(alert, user) {
data.equipmentName = eqDoc.data().name;
}
} catch (error) {
console.error('Erreur récupération équipement:', error);
console.error("Erreur récupération équipement:", error);
}
}
@@ -190,16 +190,16 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -207,16 +207,16 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -225,16 +225,16 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'templates',
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
@@ -249,7 +249,7 @@ async function renderTemplate(templateName, data) {
content: renderedContent,
});
} catch (error) {
console.error('Erreur rendu template:', error);
console.error("Erreur rendu template:", error);
// Fallback vers un template simple
return `
<html>
+45 -45
View File
@@ -3,10 +3,10 @@
* S'exécute tous les jours à 8h00 (Europe/Paris)
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const nodemailer = require('nodemailer');
const { getSmtpConfig } = require('./utils/emailConfig');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const nodemailer = require("nodemailer");
const {getSmtpConfig} = require("./utils/emailConfig");
/**
* Fonction principale : envoie le digest quotidien
@@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig');
async function sendDailyDigest() {
const db = admin.firestore();
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====");
try {
// 1. Récupérer tous les utilisateurs avec email activé
const usersSnapshot = await db.collection('users').get();
const usersSnapshot = await db.collection("users").get();
const eligibleUsers = [];
usersSnapshot.forEach((doc) => {
@@ -30,8 +30,8 @@ async function sendDailyDigest() {
eligibleUsers.push({
uid: doc.id,
email: user.email,
firstName: user.firstName || 'Utilisateur',
lastName: user.lastName || '',
firstName: user.firstName || "Utilisateur",
lastName: user.lastName || "",
});
}
});
@@ -48,12 +48,12 @@ async function sendDailyDigest() {
for (const user of eligibleUsers) {
try {
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
const alertsSnapshot = await db.collection('alerts')
.where('assignedTo', 'array-contains', user.uid)
.where('isRead', '==', false)
.where('createdAt', '>=', yesterday)
.orderBy('createdAt', 'desc')
.get();
const alertsSnapshot = await db.collection("alerts")
.where("assignedTo", "array-contains", user.uid)
.where("isRead", "==", false)
.where("createdAt", ">=", yesterday)
.orderBy("createdAt", "desc")
.get();
if (alertsSnapshot.empty) {
continue; // Pas d'alertes non lues pour cet utilisateur
@@ -61,7 +61,7 @@ async function sendDailyDigest() {
const alerts = [];
alertsSnapshot.forEach((doc) => {
alerts.push({ id: doc.id, ...doc.data() });
alerts.push({id: doc.id, ...doc.data()});
});
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
@@ -77,11 +77,11 @@ async function sendDailyDigest() {
}
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====");
return { success: true, emailsSent };
return {success: true, emailsSent};
} catch (error) {
logger.error('[sendDailyDigest] Erreur globale:', error);
logger.error("[sendDailyDigest] Erreur globale:", error);
throw error;
}
}
@@ -92,9 +92,9 @@ async function sendDailyDigest() {
async function sendDigestEmail(transporter, user, alerts) {
try {
// Grouper les alertes par sévérité
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL");
const warningAlerts = alerts.filter((a) => a.severity === "WARNING");
const infoAlerts = alerts.filter((a) => a.severity === "INFO");
// Construire le HTML
const html = buildDigestHtml(user, {
@@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) {
function buildDigestHtml(user, alertsByType) {
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
let alertsHtml = '';
let alertsHtml = "";
// Alertes critiques
if (alertsByType.critical.length > 0) {
@@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
🔴 Alertes critiques (${alertsByType.critical.length})
</h3>
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
⚠️ Avertissements (${alertsByType.warning.length})
</h3>
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
️ Informations (${alertsByType.info.length})
</h3>
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")}
</div>
`;
}
@@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) {
*/
function formatAlertItem(alert) {
const date = alert.createdAt?.toDate ?
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) :
'Date inconnue';
"Date inconnue";
// Type d'alerte en français
const typeLabels = {
'EQUIPMENT_MISSING': 'Équipement manquant',
'LOST': 'Équipement perdu',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Écart de quantité',
'EVENT_CREATED': 'Événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'WORKFORCE_ADDED': 'Ajout à la workforce',
"EQUIPMENT_MISSING": "Équipement manquant",
"LOST": "Équipement perdu",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Écart de quantité",
"EVENT_CREATED": "Événement créé",
"EVENT_MODIFIED": "Événement modifié",
"WORKFORCE_ADDED": "Ajout à la workforce",
};
const typeLabel = typeLabels[alert.type] || alert.type;
@@ -245,7 +245,7 @@ function formatAlertItem(alert) {
<span style="color: #6b7280; font-size: 13px;">${date}</span>
</div>
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
${alert.message || 'Aucun message'}
${alert.message || "Aucun message"}
</p>
</div>
`;
@@ -256,12 +256,12 @@ function formatAlertItem(alert) {
*/
function getSeverityColor(severity) {
switch (severity) {
case 'CRITICAL': return '#dc2626';
case 'WARNING': return '#f59e0b';
case 'INFO': return '#3b82f6';
default: return '#6b7280';
case "CRITICAL": return "#dc2626";
case "WARNING": return "#f59e0b";
case "INFO": return "#3b82f6";
default: return "#6b7280";
}
}
module.exports = { sendDailyDigest };
module.exports = {sendDailyDigest};
+72
View File
@@ -0,0 +1,72 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupère toutes les alertes (filtrées et limitées)
exports.getAlerts = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("alerts")
.orderBy("createdAt", "desc")
.limit(100)
.get();
const alerts = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt"]),
};
});
res.status(200).json({alerts});
} catch (error) {
logger.error("Error fetching alerts:", error);
res.status(500).json({error: error.message});
}
};
// Marquer une alerte comme lue
exports.markAlertAsRead = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).update({
isRead: true,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error marking alert as read:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une alerte
exports.deleteAlert = async (req, res) => {
try {
await auth.authenticateUser(req);
const alertId = req.body.data?.alertId;
if (!alertId) {
res.status(400).json({error: "alertId is required"});
return;
}
await db.collection("alerts").doc(alertId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting alert:", error);
res.status(500).json({error: error.message});
}
};
+628
View File
@@ -0,0 +1,628 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Vérifie si un équipement est disponible pour une plage de dates
exports.checkEquipmentAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId, startDate, endDate, excludeEventId} = req.body.data;
if (!equipmentId || !startDate || !endDate) {
res.status(400).json({error: "equipmentId, startDate, and endDate are required"});
return;
}
logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`);
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const conflicts = [];
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId);
let isEquipmentInAssignedContainer = false;
if (assignedContainers.length > 0) {
logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`);
for (const containerId of assignedContainers) {
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`);
if (equipmentIds.includes(equipmentId)) {
isEquipmentInAssignedContainer = true;
logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`);
break;
}
}
}
}
if (isEquipmentDirectlyAssigned) {
logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`);
}
if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`);
const eventData = helpers.serializeTimestamps(event);
conflicts.push({
eventId: eventDoc.id,
eventName: event.Name,
eventData: eventData,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
});
}
}
logger.info(`Total conflicts found: ${conflicts.length}`);
res.status(200).json({conflicts, available: conflicts.length === 0});
} catch (error) {
logger.error("Error checking equipment availability:", error);
res.status(500).json({error: error.message || "Failed to check equipment availability"});
}
};
// Vérifie la disponibilité d'un container et de son contenu
exports.checkContainerAvailability = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerId, startDate, endDate, excludeEventId} = req.body.data;
if (!containerId || !startDate || !endDate) {
res.status(400).json({error: "containerId, startDate, and endDate are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
throw new Error("Container not found");
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate));
const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate));
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const containerConflicts = [];
const equipmentConflicts = {};
for (const eventDoc of eventsSnapshot.docs) {
const event = eventDoc.data();
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const assignedContainers = event.assignedContainers || [];
const isContainerAssigned = assignedContainers.includes(containerId);
const assignedEquipment = event.assignedEquipment || [];
const conflictingEquipmentIds = equipmentIds.filter((eqId) =>
assignedEquipment.some((eq) => eq.equipmentId === eqId),
);
if (!isContainerAssigned && conflictingEquipmentIds.length === 0) {
continue;
}
const requestStart = startTimestamp.toDate();
const requestEnd = endTimestamp.toDate();
const installationTime = event.InstallationTime || 0;
const disassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime);
const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup;
if (hasOverlap) {
const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup));
const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown));
const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24));
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
overlapDays: overlapDays,
};
if (isContainerAssigned) {
containerConflicts.push(conflictInfo);
}
conflictingEquipmentIds.forEach((eqId) => {
if (!equipmentConflicts[eqId]) {
equipmentConflicts[eqId] = [];
}
equipmentConflicts[eqId].push(conflictInfo);
});
}
}
const hasContainerConflict = containerConflicts.length > 0;
const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict;
const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none");
res.status(200).json({
conflictType,
containerConflicts,
equipmentConflicts,
isAvailable: conflictType === "none",
});
} catch (error) {
logger.error("Error checking container availability:", error);
res.status(500).json({error: error.message || "Failed to check container availability"});
}
};
// Récupère tous les équipements et conteneurs en conflit pour une période donnée
exports.getConflictingEquipmentIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data;
if (!startDate || !endDate) {
res.status(400).json({error: "startDate and endDate are required"});
return;
}
logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`);
const requestStartDate = new Date(startDate);
requestStartDate.setHours(requestStartDate.getHours() - installationTime);
const requestEndDate = new Date(endDate);
requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime);
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
logger.info(`Found ${eventsSnapshot.docs.length} events to check`);
const equipmentsSnapshot = await db.collection("equipments").get();
const equipmentsInfo = {};
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
equipmentsInfo[doc.id] = {
category: data.category,
totalQuantity: data.totalQuantity || 0,
hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE",
};
});
const conflictingEquipmentIds = new Set();
const conflictingContainerIds = new Set();
const conflictDetails = {};
const equipmentQuantities = {};
for (const eventDoc of eventsSnapshot.docs) {
if (excludeEventId && eventDoc.id === excludeEventId) {
continue;
}
const event = eventDoc.data();
let eventStart; let eventEnd;
if (event.StartDateTime) {
eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime);
}
if (event.EndDateTime) {
eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime);
}
if (!eventStart || !eventEnd) {
continue;
}
const eventInstallTime = event.InstallationTime || 0;
const eventDisassemblyTime = event.DisassemblyTime || 0;
const eventStartWithSetup = new Date(eventStart);
eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime);
const eventEndWithTeardown = new Date(eventEnd);
eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime);
const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup;
if (!hasOverlap) {
continue;
}
const assignedEquipment = event.assignedEquipment || [];
const assignedContainers = event.assignedContainers || [];
const conflictInfo = {
eventId: eventDoc.id,
eventName: event.Name,
startDate: eventStart.toISOString(),
endDate: eventEnd.toISOString(),
};
for (const eq of assignedEquipment) {
const equipmentId = eq.equipmentId;
const quantity = eq.quantity || 1;
const equipInfo = equipmentsInfo[equipmentId];
if (equipInfo && equipInfo.hasQuantity) {
if (!equipmentQuantities[equipmentId]) {
equipmentQuantities[equipmentId] = {
totalQuantity: equipInfo.totalQuantity,
reservedQuantity: 0,
availableQuantity: equipInfo.totalQuantity,
reservations: [],
};
}
equipmentQuantities[equipmentId].reservedQuantity += quantity;
equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity;
equipmentQuantities[equipmentId].reservations.push({
...conflictInfo,
quantity: quantity,
});
if (equipmentQuantities[equipmentId].availableQuantity <= 0) {
conflictingEquipmentIds.add(equipmentId);
}
} else {
conflictingEquipmentIds.add(equipmentId);
}
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
quantity: quantity,
});
}
for (const containerId of assignedContainers) {
conflictingContainerIds.add(containerId);
if (!conflictDetails[containerId]) {
conflictDetails[containerId] = [];
}
conflictDetails[containerId].push(conflictInfo);
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
for (const equipmentId of equipmentIds) {
conflictingEquipmentIds.add(equipmentId);
if (!conflictDetails[equipmentId]) {
conflictDetails[equipmentId] = [];
}
conflictDetails[equipmentId].push({
...conflictInfo,
viaContainer: containerId,
viaContainerName: containerData.name || "Conteneur inconnu",
});
}
}
}
}
logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`);
res.status(200).json({
conflictingEquipmentIds: Array.from(conflictingEquipmentIds),
conflictingContainerIds: Array.from(conflictingContainerIds),
conflictDetails: conflictDetails,
equipmentQuantities: equipmentQuantities,
});
} catch (error) {
logger.error("Error getting conflicting equipment IDs:", error);
res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"});
}
};
/**
* Trouver des alternatives (même modèle) disponibles pour une période donnée
*/
exports.findAlternativeEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {model, startDate, endDate} = req.body.data;
if (!model || !startDate || !endDate) {
res.status(400).json({error: "model, startDate and endDate are required"});
return;
}
const start = admin.firestore.Timestamp.fromDate(new Date(startDate));
const end = admin.firestore.Timestamp.fromDate(new Date(endDate));
// Récupérer tous les équipements du même modèle
const equipmentsSnapshot = await db.collection("equipments")
.where("model", "==", model)
.get();
// Récupérer tous les événements qui chevauchent la période
const eventsSnapshot = await db.collection("events")
.where("StartDateTime", "<=", end)
.where("EndDateTime", ">=", start)
.where("status", "!=", "CANCELLED")
.get();
// Créer un set des équipements en conflit
const conflictingEquipmentIds = new Set();
eventsSnapshot.docs.forEach((doc) => {
const eventData = doc.data();
const assignedEquipment = eventData.assignedEquipment || [];
assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId));
});
// Filtrer les équipements disponibles
const alternatives = [];
equipmentsSnapshot.docs.forEach((doc) => {
const data = doc.data();
if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") {
alternatives.push({
id: doc.id,
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
});
}
});
res.status(200).json({alternatives});
} catch (error) {
logger.error("Error finding alternative equipment:", error);
res.status(500).json({error: error.message});
}
};
/**
* Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours
*/
exports.calculateEquipmentStatuses = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data;
if (!equipmentIds || !Array.isArray(equipmentIds)) {
res.status(400).json({error: "equipmentIds array is required"});
return;
}
// Récupérer tous les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const equipmentIdsInUse = new Set();
const containerIdsInUse = new Set();
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
// Ajouter les équipements directs
const assignedEquipment = event.assignedEquipment || [];
assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
// Ajouter les conteneurs
const assignedContainers = event.assignedContainers || [];
assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId));
}
});
// Récupérer les équipements dans les conteneurs en cours d'utilisation
if (containerIdsInUse.size > 0) {
const containersSnapshot = await db.collection("containers")
.where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse))
.get();
containersSnapshot.docs.forEach((doc) => {
const containerData = doc.data();
const equipmentList = containerData.equipment || [];
equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId));
});
}
// Récupérer les données des équipements demandés
const statuses = {};
for (const equipmentId of equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
statuses[equipmentId] = null;
continue;
}
const equipmentData = equipmentDoc.data();
let calculatedStatus = equipmentData.status;
// Si l'équipement est perdu ou HS, garder ce statut
if (equipmentData.status === "lost" || equipmentData.status === "outOfService") {
calculatedStatus = equipmentData.status;
} else if (equipmentIdsInUse.has(equipmentId)) {
calculatedStatus = "inUse";
} else if (equipmentData.status === "maintenance" ||
equipmentData.status === "rented") {
calculatedStatus = equipmentData.status;
} else {
calculatedStatus = "available";
}
statuses[equipmentId] = calculatedStatus;
}
res.status(200).json({statuses});
} catch (error) {
logger.error("Error calculating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
/**
* Récupérer tous les événements en cours (pour le calcul de statuts)
*/
exports.getActiveEvents = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires view_events permission"});
return;
}
// Récupérer les événements en cours (préparation complétée mais pas encore retournés)
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const activeEvents = [];
eventsSnapshot.docs.forEach((doc) => {
const event = doc.data();
const isPrepared = event.preparationStatus === "completed" ||
event.preparationStatus === "completedWithMissing";
const isReturned = event.returnStatus === "completed" ||
event.returnStatus === "completedWithMissing";
if (isPrepared && !isReturned) {
activeEvents.push({
id: doc.id,
assignedEquipment: event.assignedEquipment || [],
assignedContainers: event.assignedContainers || [],
preparationStatus: event.preparationStatus,
returnStatus: event.returnStatus,
});
}
});
res.status(200).json({events: activeEvents});
} catch (error) {
logger.error("Error fetching active events:", error);
res.status(500).json({error: error.message});
}
};
+504
View File
@@ -0,0 +1,504 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un container
exports.createContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const containerData = req.body.data;
const containerId = containerData.id;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
const existingDoc = await db.collection("containers").doc(containerId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Container ID already exists"});
return;
}
const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]);
await db.collection("containers").doc(containerId).set(dataToSave);
res.status(201).json({id: containerId, message: "Container created successfully"});
} catch (error) {
logger.error("Error creating container:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un container
exports.updateContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, data} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
await db.collection("containers").doc(containerId).update(data);
res.status(200).json({message: "Container updated successfully"});
} catch (error) {
logger.error("Error updating container:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un container
exports.deleteContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId} = req.body.data;
if (!containerId) {
res.status(400).json({error: "Container ID is required"});
return;
}
// Récupérer le container pour obtenir les équipements
const containerDoc = await db.collection("containers").doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Retirer le container des parentBoxIds de chaque équipement
for (const equipmentId of equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: parentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
}
}
}
await db.collection("containers").doc(containerId).delete();
res.status(200).json({message: "Container deleted successfully"});
} catch (error) {
logger.error("Error deleting container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers contenant un équipement
exports.getContainersByEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || {};
if (!equipmentId) {
res.status(400).json({error: "equipmentId is required"});
return;
}
const snapshot = await db.collection("containers")
.where("equipmentIds", "array-contains", equipmentId)
.get();
const containers = [];
snapshot.forEach((doc) => {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
});
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs containers par leurs IDs
exports.getContainersByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {containerIds} = req.body.data || {};
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
res.status(400).json({error: "containerIds array is required and must not be empty"});
return;
}
if (containerIds.length > 100) {
res.status(400).json({error: "Maximum 100 container IDs per request"});
return;
}
const promises = containerIds.map((id) => db.collection("containers").doc(id).get());
const docs = await Promise.all(promises);
const containers = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
}
}
res.status(200).json({containers});
} catch (error) {
logger.error("Error getting containers by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Ajouter un équipement à un container
exports.addEquipmentToContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({success: false, message: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
if (equipmentIds.includes(equipmentId)) {
res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"});
return;
}
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (!equipmentDoc.exists) {
res.status(404).json({success: false, message: "Équipement non trouvé"});
return;
}
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const warnings = [];
if (parentBoxIds.length > 0) {
const otherContainersPromises = parentBoxIds.map((boxId) =>
db.collection("containers").doc(boxId).get(),
);
const otherContainersDocs = await Promise.all(otherContainersPromises);
const otherNames = otherContainersDocs
.filter((doc) => doc.exists)
.map((doc) => doc.data().name);
if (otherNames.length > 0) {
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
}
}
await db.collection("containers").doc(containerId).update({
equipmentIds: [...equipmentIds, equipmentId],
updatedAt: admin.firestore.Timestamp.now(),
});
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: [...parentBoxIds, containerId],
updatedAt: admin.firestore.Timestamp.now(),
});
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_added",
equipmentId: equipmentId,
newValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({
success: true,
message: "Équipement ajouté avec succès",
warnings: warnings.length > 0 ? warnings[0] : null,
});
} catch (error) {
logger.error("Error adding equipment to container:", error);
res.status(500).json({success: false, message: error.message});
}
};
// Retirer un équipement d'un container
exports.removeEquipmentFromContainer = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {containerId, equipmentId, userId} = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({error: "containerId and equipmentId are required"});
return;
}
const containerDoc = await db.collection("containers").doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({error: "Container non trouvé"});
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId);
await db.collection("containers").doc(containerId).update({
equipmentIds: updatedEquipmentIds,
updatedAt: admin.firestore.Timestamp.now(),
});
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId);
await db.collection("equipments").doc(equipmentId).update({
parentBoxIds: updatedParentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: "equipment_removed",
equipmentId: equipmentId,
previousValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection("containers").doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({success: true});
} catch (error) {
logger.error("Error removing equipment from container:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer les containers avec pagination et filtrage côté serveur
exports.getContainersPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const type = params.type ? params.type.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const category = params.category ? params.category.toUpperCase() : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
let query = db.collection("containers");
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
if (type) {
query = query.where("type", "==", type);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("containers").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
let containers = docsToProcess.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]),
};
});
const allEquipmentIds = new Set();
containers.forEach((c) => {
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
c.equipmentIds.forEach((id) => allEquipmentIds.add(id));
}
});
const equipmentMap = new Map();
if (allEquipmentIds.size > 0) {
const equipmentIdArray = Array.from(allEquipmentIds);
const batchSize = 30;
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
const batch = equipmentIdArray.slice(i, i + batchSize);
const equipmentSnapshot = await db.collection("equipments")
.where(admin.firestore.FieldPath.documentId(), "in", batch)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const equipmentData = doc.data();
equipmentMap.set(doc.id, {
id: doc.id,
...helpers.serializeTimestamps(equipmentData),
});
});
}
}
containers = containers.map((container) => ({
...container,
equipment: (container.equipmentIds || [])
.map((eqId) => equipmentMap.get(eqId))
.filter((eq) => eq !== undefined),
}));
if (category) {
containers = containers.filter((c) => {
return c.equipment.some((eq) => eq.category === category);
});
}
if (searchQuery) {
containers = containers.filter((c) => {
const searchableText = [
c.name || "",
c.id || "",
...(c.equipment || []).map((eq) => eq.name || ""),
].join(" ").toLowerCase();
return searchableText.includes(searchQuery);
});
}
const limitedContainers = containers.slice(0, limit);
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
limitedContainers.forEach((c) => {
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
});
res.status(200).json({
containers: limitedContainers,
hasMore: containers.length > limit || hasMoreDocs,
lastVisible,
total: limitedContainers.length,
});
} catch (error) {
logger.error("Error fetching paginated containers:", error);
res.status(500).json({error: error.message});
}
};
+668
View File
@@ -0,0 +1,668 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un équipement (admin ou manage_equipment)
exports.createEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const equipmentData = req.body.data;
const equipmentId = equipmentData.id;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier unicité de l'ID
const existingDoc = await db.collection("equipments").doc(equipmentId).get();
if (existingDoc.exists) {
res.status(409).json({error: "Equipment ID already exists"});
return;
}
// Convertir les timestamps
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
"createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).set(dataToSave);
res.status(201).json({id: equipmentId, message: "Equipment created successfully"});
} catch (error) {
logger.error("Error creating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un équipement
exports.updateEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, data} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
res.status(400).json({error: "Update data is required and must be a non-empty object"});
return;
}
// Empêcher la modification de l'ID
delete data.id;
// Ajouter updatedAt
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate",
]);
await db.collection("equipments").doc(equipmentId).update(dataToSave);
res.status(200).json({message: "Equipment updated successfully"});
} catch (error) {
logger.error("Error updating equipment:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un équipement
exports.deleteEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {equipmentId, forceDelete = false} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier si l'équipement est utilisé dans des événements à venir
const eventsSnapshot = await db.collection("events")
.where("status", "!=", "CANCELLED")
.get();
const now = new Date();
const upcomingEvents = [];
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) {
continue;
}
let eventStart = null;
if (eventData.StartDateTime) {
eventStart = eventData.StartDateTime.toDate ?
eventData.StartDateTime.toDate() :
new Date(eventData.StartDateTime);
}
if (eventStart && eventStart > now) {
upcomingEvents.push({
eventId: eventDoc.id,
eventName: eventData.Name || "",
startDate: eventStart.toISOString(),
});
}
}
if (upcomingEvents.length > 0 && !forceDelete) {
res.status(409).json({
error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events",
upcomingEvents,
});
return;
}
await db.collection("equipments").doc(equipmentId).delete();
res.status(200).json({message: "Equipment deleted successfully"});
} catch (error) {
logger.error("Error deleting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer un équipement par ID
exports.getEquipment = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentId} = req.body.data || req.query;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
const doc = await db.collection("equipments").doc(equipmentId).get();
if (!doc.exists) {
res.status(404).json({error: "Equipment not found"});
return;
}
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
res.status(200).json({equipment: data});
} catch (error) {
logger.error("Error getting equipment:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment");
const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const {equipmentIds} = req.body.data || {};
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
res.status(400).json({error: "equipmentIds array is required and must not be empty"});
return;
}
// Limiter à 100 équipements max par requête
if (equipmentIds.length > 100) {
res.status(400).json({error: "Maximum 100 equipment IDs per request"});
return;
}
// Récupérer tous les documents en parallèle
const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get());
const docs = await Promise.all(promises);
const equipments = [];
for (const doc of docs) {
if (doc.exists) {
let data = {id: doc.id, ...doc.data()};
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
equipments.push(data);
}
}
res.status(200).json({equipments});
} catch (error) {
logger.error("Error getting equipments by IDs:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour uniquement le statut d'un équipement
exports.updateEquipmentStatusOnly = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId, status, availableQuantity} = req.body.data;
if (!equipmentId) {
res.status(400).json({error: "Equipment ID is required"});
return;
}
// Vérifier les permissions
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const updateData = {updatedAt: admin.firestore.Timestamp.now()};
if (status) updateData.status = status;
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
await db.collection("equipments").doc(equipmentId).update(updateData);
res.status(200).json({message: "Equipment status updated successfully"});
} catch (error) {
logger.error("Error updating equipment status:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
exports.updateEquipmentStatus = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {eventId, updates} = req.body.data;
if (!eventId || !updates || !Array.isArray(updates)) {
res.status(400).json({error: "Event ID and updates array are required"});
return;
}
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAssigned && !isAdminUser) {
res.status(403).json({error: "Forbidden: Not assigned to this event"});
return;
}
// Batch update
const batch = db.batch();
for (const update of updates) {
const {equipmentId, status} = update;
if (equipmentId && status) {
const equipmentRef = db.collection("equipments").doc(equipmentId);
batch.update(equipmentRef, {status});
}
}
await batch.commit();
res.status(200).json({message: "Equipment statuses updated successfully"});
} catch (error) {
logger.error("Error updating equipment statuses:", error);
res.status(500).json({error: error.message});
}
};
// Récupère les équipements avec pagination et filtrage côté serveur
exports.getEquipmentsPaginated = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canManage && !canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
// Récupérer les paramètres de la requête
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : "";
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null;
const sortBy = params.sortBy || "id";
const sortOrder = params.sortOrder === "desc" ? "desc" : "asc";
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Fast-path pour une recherche d'ID exact
if (searchQuery && !startAfterId) {
const exactIdCandidates = Array.from(new Set([
rawSearchQuery,
rawSearchQuery.toUpperCase(),
rawSearchQuery.toLowerCase(),
].filter(Boolean)));
for (const candidateId of exactIdCandidates) {
const exactDoc = await db.collection("equipments").doc(candidateId).get();
if (!exactDoc.exists) {
continue;
}
const exactData = exactDoc.data() || {};
const matchesCategory = !category || exactData.category === category;
const matchesStatus = !status || exactData.status === status;
if (!matchesCategory || !matchesStatus) {
continue;
}
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
// Compatibilité legacy
for (const legacyId of exactIdCandidates) {
let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId);
if (category) {
legacyIdQuery = legacyIdQuery.where("category", "==", category);
}
if (status) {
legacyIdQuery = legacyIdQuery.where("status", "==", status);
}
const legacySnapshot = await legacyIdQuery.limit(1).get();
if (legacySnapshot.empty) {
continue;
}
const exactDoc = legacySnapshot.docs[0];
const exactData = exactDoc.data() || {};
if (!canManage) {
delete exactData.purchasePrice;
delete exactData.rentalPrice;
}
const exactEquipment = {
...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
id: exactDoc.id,
};
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
res.status(200).json({
equipments: [exactEquipment],
hasMore: false,
lastVisible: exactDoc.id,
total: 1,
});
return;
}
}
// Construire la requête Firestore
let query = db.collection("equipments");
if (category) {
query = query.where("category", "==", category);
}
if (status) {
query = query.where("status", "==", status);
}
if (sortBy === "id") {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
if (startAfterId) {
const startAfterDoc = await db.collection("equipments").doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"];
const mapEquipmentDoc = (doc) => {
const data = {...(doc.data() || {})};
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
const legacyId = typeof data.id === "string" ? data.id : "";
return {
...helpers.serializeTimestamps(data, timestampFields),
id: doc.id,
_legacyId: legacyId,
};
};
const matchesSearchQuery = (equipment) => {
const searchableText = [
equipment.name || "",
equipment.id || "",
equipment._legacyId || "",
equipment.model || "",
equipment.brand || "",
equipment.subCategory || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
return true;
}
if (!compactSearchQuery) {
return false;
}
const compactSearchableText = searchableText.replace(/[\s_-]+/g, "");
return compactSearchableText.includes(compactSearchQuery);
};
if (!searchQuery) {
const snapshot = await query.limit(limit + 1).get();
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > limit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
const limitedEquipments = docsToProcess
.map(mapEquipmentDoc)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length,
});
return;
}
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
const matchedEquipments = [];
let scannedDocuments = 0;
let searchQueryRef = query;
let hasMoreMatches = false;
let hasMoreDocsToScan = true;
while (hasMoreDocsToScan && !hasMoreMatches) {
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
if (snapshot.empty) {
hasMoreDocsToScan = false;
break;
}
scannedDocuments += snapshot.docs.length;
for (const doc of snapshot.docs) {
const equipment = mapEquipmentDoc(doc);
if (!matchesSearchQuery(equipment)) {
continue;
}
matchedEquipments.push(equipment);
if (matchedEquipments.length > limit) {
hasMoreMatches = true;
break;
}
}
if (hasMoreMatches) {
break;
}
if (snapshot.docs.length < searchBatchSize) {
hasMoreDocsToScan = false;
break;
}
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
searchQueryRef = query.startAfter(lastDocInBatch);
}
const limitedEquipments = matchedEquipments
.slice(0, limit)
.map(({_legacyId, ...equipment}) => equipment);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreMatches,
lastVisible,
total: limitedEquipments.length,
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
res.status(500).json({error: error.message});
}
};
// Recherche rapide d'équipements et containers pour l'autocomplétion
exports.quickSearch = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires view_equipment permission"});
return;
}
const params = req.method === "GET" ? req.query : (req.body?.data || {});
const searchQuery = params.query?.toLowerCase() || "";
const limit = Math.min(parseInt(params.limit) || 10, 50);
const includeEquipments = params.includeEquipments !== "false";
const includeContainers = params.includeContainers !== "false";
if (!searchQuery || searchQuery.length < 2) {
res.status(200).json({results: []});
return;
}
const results = [];
// Rechercher dans les équipements
if (includeEquipments) {
const equipmentSnapshot = await db.collection("equipments")
.orderBy("id")
.limit(limit * 2)
.get();
equipmentSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
data.model || "",
data.brand || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "equipment",
id: doc.id,
name: data.name,
category: data.category,
model: data.model,
brand: data.brand,
});
}
});
}
// Rechercher dans les containers
if (includeContainers) {
const containerSnapshot = await db.collection("containers")
.orderBy("id")
.limit(limit * 2)
.get();
containerSnapshot.docs.forEach((doc) => {
const data = doc.data();
const searchableText = [
data.name || "",
doc.id || "",
].join(" ").toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: "container",
id: doc.id,
name: data.name,
containerType: data.type,
});
}
});
}
const limitedResults = results
.sort((a, b) => {
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return 0;
})
.slice(0, limit);
res.status(200).json({results: limitedResults});
} catch (error) {
logger.error("Error in quick search:", error);
res.status(500).json({error: error.message});
}
};
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer une maintenance
exports.createMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const maintenanceData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
"scheduledDate", "completedDate", "createdAt", "updatedAt",
]);
const docRef = await db.collection("maintenances").add(dataToSave);
const maintenanceId = docRef.id;
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
for (const equipmentId of maintenanceData.equipmentIds) {
try {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = equipmentData.maintenanceIds || [];
if (!maintenanceIds.includes(maintenanceId)) {
maintenanceIds.push(maintenanceId);
await db.collection("equipments").doc(equipmentId).update({
maintenanceIds: maintenanceIds,
});
}
}
if (maintenanceData.scheduledDate) {
const scheduledDate = maintenanceData.scheduledDate.toDate ?
maintenanceData.scheduledDate.toDate() :
new Date(maintenanceData.scheduledDate);
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
if (scheduledDate <= sevenDaysFromNow) {
const existingAlerts = await db.collection("alerts")
.where("equipmentId", "==", equipmentId)
.where("type", "==", "maintenanceDue")
.where("isRead", "==", false)
.get();
let alertExists = false;
for (const alertDoc of existingAlerts.docs) {
const alertData = alertDoc.data();
if (alertData.message && alertData.message.includes(maintenanceData.name || "")) {
alertExists = true;
break;
}
}
if (!alertExists) {
const equipmentName = equipmentDoc.exists ?
(equipmentDoc.data().name || equipmentId) :
equipmentId;
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
await db.collection("alerts").add({
type: "maintenanceDue",
message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
equipmentId: equipmentId,
createdAt: admin.firestore.Timestamp.now(),
isRead: false,
});
}
}
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
}
}
}
res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"});
} catch (error) {
logger.error("Error creating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une maintenance
exports.updateMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"});
return;
}
const {maintenanceId, data} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "Maintenance ID is required"});
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
"scheduledDate", "completedDate",
]);
await db.collection("maintenances").doc(maintenanceId).update(dataToSave);
res.status(200).json({message: "Maintenance updated successfully"});
} catch (error) {
logger.error("Error updating maintenance:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer toutes les maintenances
exports.getMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {equipmentId} = req.body.data || {};
const canView = await auth.hasPermission(decodedToken.uid, "view_equipment");
if (!canView) {
res.status(403).json({error: "Forbidden: Requires equipment permissions"});
return;
}
let query = db.collection("maintenances");
if (equipmentId) {
query = query.where("equipmentIds", "array-contains", equipmentId);
}
const snapshot = await query.get();
const maintenances = snapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]),
};
});
res.status(200).json({maintenances});
} catch (error) {
logger.error("Error fetching maintenances:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une maintenance
exports.deleteMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!canManage) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const maintenanceId = req.body.data?.maintenanceId;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
if (maintenanceDoc.exists) {
const maintenance = maintenanceDoc.data();
if (maintenance.equipmentIds) {
for (const equipmentId of maintenance.equipmentIds) {
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId);
await db.collection("equipments").doc(equipmentId).update({maintenanceIds});
}
}
}
}
await db.collection("maintenances").doc(maintenanceId).delete();
res.status(200).json({success: true});
} catch (error) {
logger.error("Error deleting maintenance:", error);
res.status(500).json({error: error.message});
}
};
/**
* Vérifier les maintenances à venir et créer des alertes
*/
exports.checkUpcomingMaintenances = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = admin.firestore.Timestamp.now();
const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow);
// Récupérer les maintenances planifiées dans les 7 prochains jours
const maintenancesSnapshot = await db.collection("maintenances")
.where("scheduledDate", "<=", sevenDaysTimestamp)
.where("scheduledDate", ">=", now)
.get();
const alertsCreated = [];
for (const doc of maintenancesSnapshot.docs) {
const maintenance = doc.data();
// Vérifier si une alerte existe déjà pour cette maintenance
const existingAlertSnapshot = await db.collection("alerts")
.where("type", "==", "MAINTENANCE_DUE")
.where("relatedMaintenanceId", "==", doc.id)
.get();
if (existingAlertSnapshot.empty) {
// Créer une nouvelle alerte
const alertData = {
type: "MAINTENANCE_DUE",
title: `Maintenance à venir`,
message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`,
severity: "MEDIUM",
isRead: false,
relatedMaintenanceId: doc.id,
createdAt: admin.firestore.Timestamp.now(),
};
const alertRef = await db.collection("alerts").add(alertData);
alertsCreated.push({id: alertRef.id, ...alertData});
}
}
res.status(200).json({
success: true,
alertsCreated: alertsCreated.length,
alerts: alertsCreated,
});
} catch (error) {
logger.error("Error checking upcoming maintenances:", error);
res.status(500).json({error: error.message});
}
};
/**
* Compléter une maintenance
*/
exports.completeMaintenance = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment");
if (!hasAccess) {
res.status(403).json({error: "Forbidden: Requires manage_equipment permission"});
return;
}
const {maintenanceId, performedBy, cost} = req.body.data;
if (!maintenanceId) {
res.status(400).json({error: "maintenanceId is required"});
return;
}
const now = admin.firestore.Timestamp.now();
const updateData = {
completedDate: now,
updatedAt: now,
};
if (performedBy) {
updateData.performedBy = performedBy;
}
if (cost !== undefined && cost !== null) {
updateData.cost = cost;
}
// Mettre à jour la maintenance
await db.collection("maintenances").doc(maintenanceId).update(updateData);
// Récupérer la maintenance pour mettre à jour les équipements
const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get();
const maintenanceData = maintenanceDoc.data();
// Mettre à jour la date de dernière maintenance des équipements
if (maintenanceData && maintenanceData.equipmentIds) {
const updatePromises = maintenanceData.equipmentIds.map((equipmentId) =>
db.collection("equipments").doc(equipmentId).update({
lastMaintenanceDate: now,
updatedAt: now,
}),
);
await Promise.all(updatePromises);
}
res.status(200).json({success: true});
} catch (error) {
logger.error("Error completing maintenance:", error);
res.status(500).json({error: error.message});
}
};
+263
View File
@@ -0,0 +1,263 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Récupérer toutes les options (public pour utilisateurs authentifiés)
exports.getOptions = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("options").get();
const options = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({options});
} catch (error) {
logger.error("Error fetching options:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les types d'événements (public pour utilisateurs authentifiés)
exports.getEventTypes = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("eventTypes").get();
const eventTypes = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({eventTypes});
} catch (error) {
logger.error("Error fetching event types:", error);
res.status(500).json({error: error.message});
}
};
// Créer une option
exports.createOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const optionData = req.body.data;
const optionId = optionData.id;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).set(optionData);
res.status(201).json({id: optionId, message: "Option created successfully"});
} catch (error) {
logger.error("Error creating option:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour une option
exports.updateOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId, data} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
delete data.id;
await db.collection("options").doc(optionId).update(data);
res.status(200).json({message: "Option updated successfully"});
} catch (error) {
logger.error("Error updating option:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer une option
exports.deleteOption = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {optionId} = req.body.data;
if (!optionId) {
res.status(400).json({error: "Option ID is required"});
return;
}
await db.collection("options").doc(optionId).delete();
res.status(200).json({message: "Option deleted successfully"});
} catch (error) {
logger.error("Error deleting option:", error);
res.status(500).json({error: error.message});
}
};
// Créer un type d'événement
exports.createEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {name, defaultPrice} = req.body.data;
if (!name || defaultPrice === undefined) {
res.status(400).json({error: "Name and defaultPrice are required"});
return;
}
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
if (!existingSnapshot.empty) {
res.status(409).json({error: "Event type name already exists"});
return;
}
const eventTypeData = {
name,
defaultPrice,
createdAt: admin.firestore.Timestamp.now(),
};
const docRef = await db.collection("eventTypes").add(eventTypeData);
res.status(201).json({id: docRef.id, message: "Event type created successfully"});
} catch (error) {
logger.error("Error creating event type:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un type d'événement
exports.updateEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId, name, defaultPrice} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const docRef = db.collection("eventTypes").doc(eventTypeId);
const doc = await docRef.get();
if (!doc.exists) {
res.status(404).json({error: "Event type not found"});
return;
}
if (name) {
const existingSnapshot = await db.collection("eventTypes")
.where("name", "==", name)
.get();
const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId);
if (hasDuplicate) {
res.status(409).json({error: "Event type name already exists"});
return;
}
}
const updateData = {};
if (name) updateData.name = name;
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
await docRef.update(updateData);
res.status(200).json({message: "Event type updated successfully"});
} catch (error) {
logger.error("Error updating event type:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un type d'événement
exports.deleteEventType = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data");
if (!isAdmin) {
res.status(403).json({error: "Forbidden: Admin permission required"});
return;
}
const {eventTypeId} = req.body.data;
if (!eventTypeId) {
res.status(400).json({error: "Event type ID is required"});
return;
}
const eventsSnapshot = await db.collection("events")
.where("eventTypeId", "==", eventTypeId)
.get();
const now = admin.firestore.Timestamp.now();
const futureEvents = eventsSnapshot.docs.filter((doc) => {
const startDate = doc.data().StartDateTime;
return startDate && startDate > now;
});
if (futureEvents.length > 0) {
res.status(409).json({
error: "Cannot delete event type with future events",
futureEventsCount: futureEvents.length,
});
return;
}
await db.collection("eventTypes").doc(eventTypeId).delete();
res.status(200).json({message: "Event type deleted successfully"});
} catch (error) {
logger.error("Error deleting event type:", error);
res.status(500).json({error: error.message});
}
};
+33
View File
@@ -0,0 +1,33 @@
const admin = require("firebase-admin");
const {Storage} = require("@google-cloud/storage");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const storage = new Storage();
exports.moveEventFileV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {sourcePath, destinationPath} = req.body.data || {};
if (!sourcePath || !destinationPath) {
res.status(400).json({error: "Source and destination paths are required."});
return;
}
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
await bucket.file(sourcePath).delete();
const [url] = await bucket.file(destinationPath).getSignedUrl({
action: "read",
expires: "03-01-2500",
});
res.status(200).json({url});
} catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({error: error.message});
}
};
+58
View File
@@ -0,0 +1,58 @@
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
exports.generateTTSV2 = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
logger.info("[generateTTSV2] Request from user:", {
uid: decodedToken.uid,
email: decodedToken.email,
});
const {text, voiceConfig} = req.body.data || {};
if (!text) {
res.status(400).json({error: "Text parameter is required"});
return;
}
if (text.length > 5000) {
res.status(400).json({error: "Text too long (max 5000 characters)"});
return;
}
const {Storage} = require("@google-cloud/storage");
const storage = new Storage();
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
const {generateTTS} = require("../generateTTS");
const result = await generateTTS(text, storage, bucket, voiceConfig);
logger.info("[generateTTSV2] ✓ Success", {
cached: result.cached,
cacheKey: result.cacheKey,
});
res.status(200).json({
audioUrl: result.audioUrl,
cached: result.cached,
cacheKey: result.cacheKey,
});
} catch (error) {
logger.error("[generateTTSV2] ✗ Error:", {
error: error.message,
code: error.code,
});
if (error.code === "PERMISSION_DENIED") {
res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."});
} else if (error.code === "QUOTA_EXCEEDED") {
res.status(429).json({error: "TTS quota exceeded. Try again later."});
} else {
res.status(500).json({error: error.message});
}
}
};
+336
View File
@@ -0,0 +1,336 @@
const admin = require("firebase-admin");
const db = admin.firestore();
const logger = require("firebase-functions/logger");
const auth = require("../utils/auth");
const helpers = require("../utils/helpers");
// Créer un utilisateur
exports.createUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const userData = req.body.data;
const userId = userData.uid;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
await db.collection("users").doc(userId).set(userData);
res.status(201).json({id: userId, message: "User created successfully"});
} catch (error) {
logger.error("Error creating user:", error);
res.status(500).json({error: error.message});
}
};
// Créer un utilisateur avec invitation par email
exports.createUserWithInvite = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {email, firstName, lastName, phoneNumber, roleId} = req.body.data;
if (!email || !firstName || !lastName || !roleId) {
res.status(400).json({error: "email, firstName, lastName, and roleId are required"});
return;
}
const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!";
let userRecord;
try {
userRecord = await admin.auth().createUser({
email: email,
password: tempPassword,
emailVerified: false,
displayName: `${firstName} ${lastName}`,
});
} catch (authError) {
logger.error("Error creating user in Auth:", authError);
res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`});
return;
}
try {
await db.collection("users").doc(userRecord.uid).set({
firstName: firstName,
lastName: lastName,
email: email,
phoneNumber: phoneNumber || "",
profilePhotoUrl: "",
role: db.collection("roles").doc(roleId),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
});
} catch (firestoreError) {
logger.error("Error creating user in Firestore:", firestoreError);
try {
await admin.auth().deleteUser(userRecord.uid);
} catch (cleanupError) {
logger.error("Error cleaning up Auth user:", cleanupError);
}
res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`});
return;
}
try {
const axios = require("axios");
const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U";
await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
{
requestType: "PASSWORD_RESET",
email: email,
},
);
logger.info(`Password reset email sent to ${email}`);
} catch (emailError) {
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
}
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
res.status(201).json({
id: userRecord.uid,
message: "User created successfully. Password reset email sent.",
});
} catch (error) {
logger.error("Error in createUserWithInvite:", error);
res.status(500).json({error: error.message});
}
};
// Mettre à jour un utilisateur
exports.updateUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId, data} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
// Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (decodedToken.uid !== userId && !isAdminUser) {
res.status(403).json({error: "Forbidden: Cannot update other user accounts"});
return;
}
// Empêcher les non-admins de modifier le rôle
if (!isAdminUser && data.role) {
delete data.role;
}
// Si le rôle est fourni et est un string, le convertir en DocumentReference
if (data.role && typeof data.role === "string") {
data.role = db.collection("roles").doc(data.role);
}
await db.collection("users").doc(userId).update(data);
res.status(200).json({message: "User updated successfully"});
} catch (error) {
logger.error("Error updating user:", error);
res.status(500).json({error: error.message});
}
};
// Supprimer un utilisateur
exports.deleteUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
const {userId} = req.body.data;
if (!userId) {
res.status(400).json({error: "User ID is required"});
return;
}
if (decodedToken.uid === userId) {
res.status(400).json({error: "Cannot delete your own account"});
return;
}
await db.collection("users").doc(userId).delete();
try {
await admin.auth().deleteUser(userId);
} catch (authError) {
logger.warn(`Could not delete user from Auth: ${authError.message}`);
}
res.status(200).json({message: "User deleted successfully"});
} catch (error) {
logger.error("Error deleting user:", error);
res.status(500).json({error: error.message});
}
};
// Récupérer tous les utilisateurs (selon permissions)
exports.getUsers = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users");
if (!canViewAll) {
const userDoc = await db.collection("users").doc(decodedToken.uid).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
let userData = userDoc.data();
userData = helpers.serializeTimestamps(userData);
userData = helpers.serializeReferences(userData);
res.status(200).json({
users: [{
id: userDoc.id,
...userData,
}],
});
return;
}
const snapshot = await db.collection("users").get();
const users = snapshot.docs.map((doc) => {
let data = doc.data();
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
return {
id: doc.id,
...data,
};
});
res.status(200).json({users});
} catch (error) {
logger.error("Error fetching users:", error);
res.status(500).json({error: error.message});
}
};
// Récupère un utilisateur spécifique
exports.getUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const {userId} = req.body.data || req.body || {};
if (!userId) {
res.status(400).json({error: "userId is required"});
return;
}
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const user = userDoc.data();
const userData = {
id: userDoc.id,
uid: user.uid || userDoc.id,
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
phoneNumber: user.phoneNumber || "",
profilePhotoUrl: user.profilePhotoUrl || "",
};
if (user.role) {
const roleDoc = await user.role.get();
if (roleDoc.exists) {
userData.role = {
id: roleDoc.id,
...roleDoc.data(),
};
}
}
res.status(200).json({user: userData});
} catch (error) {
logger.error("Error fetching user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère l'utilisateur actuellement authentifié avec son rôle
exports.getCurrentUser = async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const userId = decodedToken.uid;
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
res.status(404).json({error: "User not found"});
return;
}
const userData = userDoc.data();
let roleData = null;
if (userData.role) {
const roleDoc = await userData.role.get();
if (roleDoc.exists) {
roleData = {id: roleDoc.id, ...roleDoc.data()};
}
}
res.status(200).json({
user: {
uid: userId,
...helpers.serializeTimestamps(userData),
role: roleData,
},
});
} catch (error) {
logger.error("Error getting current user:", error);
res.status(500).json({error: error.message});
}
};
// Récupère tous les rôles
exports.getRoles = async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection("roles").get();
const roles = snapshot.docs.map((doc) => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data()),
}));
res.status(200).json({roles});
} catch (error) {
logger.error("Error fetching roles:", error);
res.status(500).json({error: error.message});
}
};
+20 -20
View File
@@ -1,24 +1,24 @@
/**
* Utilitaires d'authentification et d'autorisation
*/
const admin = require('firebase-admin');
const logger = require('firebase-functions/logger');
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
/**
* Vérifie le token Firebase et retourne l'utilisateur
*/
async function authenticateUser(req) {
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) {
throw new Error('Unauthorized: No token provided');
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) {
throw new Error("Unauthorized: No token provided");
}
const idToken = req.headers.authorization.split('Bearer ')[1];
const idToken = req.headers.authorization.split("Bearer ")[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
return decodedToken;
} catch (e) {
logger.error("Error verifying Firebase ID token:", e);
throw new Error('Unauthorized: Invalid token');
throw new Error("Unauthorized: Invalid token");
}
}
@@ -26,11 +26,11 @@ async function authenticateUser(req) {
* Récupère les données utilisateur depuis Firestore
*/
async function getUserData(uid) {
const userDoc = await admin.firestore().collection('users').doc(uid).get();
const userDoc = await admin.firestore().collection("users").doc(uid).get();
if (!userDoc.exists) {
return null;
}
return { uid, ...userDoc.data() };
return {uid, ...userDoc.data()};
}
/**
@@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) {
if (!roleRef) return [];
let roleId;
if (typeof roleRef === 'string') {
if (typeof roleRef === "string") {
roleId = roleRef;
} else if (roleRef.id) {
roleId = roleRef.id;
@@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) {
return [];
}
const roleDoc = await admin.firestore().collection('roles').doc(roleId).get();
const roleDoc = await admin.firestore().collection("roles").doc(roleId).get();
if (!roleDoc.exists) return [];
return roleDoc.data().permissions || [];
@@ -74,7 +74,7 @@ async function isAdmin(uid) {
let roleId;
const roleField = userData.role;
if (typeof roleField === 'string') {
if (typeof roleField === "string") {
roleId = roleField;
} else if (roleField && roleField.id) {
roleId = roleField.id;
@@ -82,22 +82,22 @@ async function isAdmin(uid) {
return false;
}
return roleId === 'ADMIN';
return roleId === "ADMIN";
}
/**
* Vérifie si l'utilisateur est assigné à un événement
*/
async function isAssignedToEvent(uid, eventId) {
const eventDoc = await admin.firestore().collection('events').doc(eventId).get();
const eventDoc = await admin.firestore().collection("events").doc(eventId).get();
if (!eventDoc.exists) return false;
const eventData = eventDoc.data();
const workforce = eventData.workforce || [];
// workforce contient des références DocumentReference
return workforce.some(ref => {
if (typeof ref === 'string') return ref === uid;
return workforce.some((ref) => {
if (typeof ref === "string") return ref === uid;
if (ref && ref.id) return ref.id === uid;
return false;
});
@@ -113,7 +113,7 @@ async function authMiddleware(req, res, next) {
req.uid = decodedToken.uid;
next();
} catch (error) {
res.status(401).json({ error: error.message });
res.status(401).json({error: error.message});
}
}
@@ -125,12 +125,12 @@ function requirePermission(permission) {
try {
const hasAccess = await hasPermission(req.uid, permission);
if (!hasAccess) {
res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` });
res.status(403).json({error: `Forbidden: Requires permission '${permission}'`});
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
res.status(403).json({error: error.message});
}
};
}
@@ -142,12 +142,12 @@ async function requireAdmin(req, res, next) {
try {
const adminAccess = await isAdmin(req.uid);
if (!adminAccess) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
res.status(403).json({error: "Forbidden: Admin access required"});
return;
}
next();
} catch (error) {
res.status(403).json({ error: error.message });
res.status(403).json({error: error.message});
}
}
+8 -8
View File
@@ -7,12 +7,12 @@
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
const getSmtpConfig = () => {
return {
host: process.env.SMTP_HOST || 'mail.em2events.fr',
port: parseInt(process.env.SMTP_PORT || '465'),
host: process.env.SMTP_HOST || "mail.em2events.fr",
port: parseInt(process.env.SMTP_PORT || "465"),
secure: true, // true pour port 465, false pour autres ports
auth: {
user: process.env.SMTP_USER || 'notify@em2events.fr',
pass: process.env.SMTP_PASS || '',
user: process.env.SMTP_USER || "notify@em2events.fr",
pass: process.env.SMTP_PASS || "",
},
tls: {
// Ne pas échouer sur certificats invalides
@@ -24,12 +24,12 @@ const getSmtpConfig = () => {
// Configuration email par défaut
const EMAIL_CONFIG = {
from: {
name: 'EM2 Events',
address: 'notify@em2events.fr',
name: "EM2 Events",
address: "notify@em2events.fr",
},
replyTo: 'contact@em2events.fr',
replyTo: "contact@em2events.fr",
// URL de l'application pour les liens
appUrl: process.env.APP_URL || 'https://app.em2events.fr',
appUrl: process.env.APP_URL || "https://app.em2events.fr",
};
module.exports = {
+50 -50
View File
@@ -1,23 +1,23 @@
const admin = require('firebase-admin');
const handlebars = require('handlebars');
const fs = require('fs').promises;
const path = require('path');
const {EMAIL_CONFIG} = require('./emailConfig');
const admin = require("firebase-admin");
const handlebars = require("handlebars");
const fs = require("fs").promises;
const path = require("path");
const {EMAIL_CONFIG} = require("./emailConfig");
/**
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
*/
function checkAlertPreference(alertType, preferences) {
const typeMapping = {
'EVENT_CREATED': 'eventsNotifications',
'EVENT_MODIFIED': 'eventsNotifications',
'EVENT_CANCELLED': 'eventsNotifications',
'LOST': 'equipmentNotifications',
'EQUIPMENT_MISSING': 'equipmentNotifications',
'DAMAGED': 'equipmentNotifications',
'QUANTITY_MISMATCH': 'equipmentNotifications',
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
'STOCK_LOW': 'stockNotifications',
"EVENT_CREATED": "eventsNotifications",
"EVENT_MODIFIED": "eventsNotifications",
"EVENT_CANCELLED": "eventsNotifications",
"LOST": "equipmentNotifications",
"EQUIPMENT_MISSING": "equipmentNotifications",
"DAMAGED": "equipmentNotifications",
"QUANTITY_MISMATCH": "equipmentNotifications",
"MAINTENANCE_REMINDER": "maintenanceNotifications",
"STOCK_LOW": "stockNotifications",
};
const prefKey = typeMapping[alertType];
@@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) {
*/
async function prepareTemplateData(alert, user) {
const data = {
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'Utilisateur',
userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() ||
"Utilisateur",
alertTitle: getAlertTitle(alert.type),
alertMessage: alert.message,
isCritical: alert.severity === 'CRITICAL',
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
isCritical: alert.severity === "CRITICAL",
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`,
appUrl: EMAIL_CONFIG.appUrl,
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
year: new Date().getFullYear(),
@@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) {
if (alert.eventId) {
try {
const eventDoc = await admin.firestore()
.collection('events')
.collection("events")
.doc(alert.eventId)
.get();
if (eventDoc.exists) {
const event = eventDoc.data();
data.eventName = event.Name || event.name || 'Événement';
data.eventName = event.Name || event.name || "Événement";
if (event.StartDateTime || event.startDate) {
const dateField = event.StartDateTime || event.startDate;
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
data.eventDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
data.eventDate = date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}
@@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) {
if (alert.equipmentId) {
try {
const eqDoc = await admin.firestore()
.collection('equipments')
.collection("equipments")
.doc(alert.equipmentId)
.get();
@@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) {
*/
function getEmailSubject(alert) {
const subjects = {
'EVENT_CREATED': '📅 Nouvel événement créé',
'EVENT_MODIFIED': '📝 Événement modifié',
'EVENT_CANCELLED': '❌ Événement annulé',
'LOST': '🔴 Alerte critique : Équipement perdu',
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
'DAMAGED': '⚠️ Équipement endommagé',
'QUANTITY_MISMATCH': '️ Quantité incorrecte',
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
'STOCK_LOW': '📦 Stock faible',
"EVENT_CREATED": "📅 Nouvel événement créé",
"EVENT_MODIFIED": "📝 Événement modifié",
"EVENT_CANCELLED": "❌ Événement annulé",
"LOST": "🔴 Alerte critique : Équipement perdu",
"EQUIPMENT_MISSING": "⚠️ Équipement manquant",
"DAMAGED": "⚠️ Équipement endommagé",
"QUANTITY_MISMATCH": "️ Quantité incorrecte",
"MAINTENANCE_REMINDER": "🔧 Rappel de maintenance",
"STOCK_LOW": "📦 Stock faible",
};
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events";
}
/**
@@ -109,18 +109,18 @@ function getEmailSubject(alert) {
*/
function getAlertTitle(type) {
const titles = {
'EVENT_CREATED': 'Nouvel événement créé',
'EVENT_MODIFIED': 'Événement modifié',
'EVENT_CANCELLED': 'Événement annulé',
'LOST': 'Équipement perdu',
'EQUIPMENT_MISSING': 'Équipement manquant',
'DAMAGED': 'Équipement endommagé',
'QUANTITY_MISMATCH': 'Quantité incorrecte',
'MAINTENANCE_REMINDER': 'Maintenance requise',
'STOCK_LOW': 'Stock faible',
"EVENT_CREATED": "Nouvel événement créé",
"EVENT_MODIFIED": "Événement modifié",
"EVENT_CANCELLED": "Événement annulé",
"LOST": "Équipement perdu",
"EQUIPMENT_MISSING": "Équipement manquant",
"DAMAGED": "Équipement endommagé",
"QUANTITY_MISMATCH": "Quantité incorrecte",
"MAINTENANCE_REMINDER": "Maintenance requise",
"STOCK_LOW": "Stock faible",
};
return titles[type] || 'Nouvelle alerte';
return titles[type] || "Nouvelle alerte";
}
/**
@@ -129,17 +129,17 @@ function getAlertTitle(type) {
async function renderTemplate(templateName, data) {
try {
// Lire le template de base
const basePath = path.join(__dirname, '..', 'templates', 'base-template.html');
const baseTemplate = await fs.readFile(basePath, 'utf8');
const basePath = path.join(__dirname, "..", "templates", "base-template.html");
const baseTemplate = await fs.readFile(basePath, "utf8");
// Lire le template de contenu
const contentPath = path.join(
__dirname,
'..',
'templates',
"..",
"templates",
`${templateName}.html`,
);
const contentTemplate = await fs.readFile(contentPath, 'utf8');
const contentTemplate = await fs.readFile(contentPath, "utf8");
// Compiler les templates
const compileContent = handlebars.compile(contentTemplate);
+26 -26
View File
@@ -1,7 +1,7 @@
/**
* Helpers pour la manipulation de données Firestore
*/
const admin = require('firebase-admin');
const admin = require("firebase-admin");
/**
* Convertit les Timestamps Firestore en ISO strings pour JSON
@@ -19,7 +19,7 @@ function serializeTimestamps(data) {
return null;
}
const result = { ...data };
const result = {...data};
for (const key in result) {
const value = result[key];
@@ -29,31 +29,31 @@ function serializeTimestamps(data) {
}
// Gérer les Timestamps Firestore
if (value.toDate && typeof value.toDate === 'function') {
if (value.toDate && typeof value.toDate === "function") {
result[key] = value.toDate().toISOString();
}
// Gérer les DocumentReference
else if (value.path && value.id && typeof value.path === 'string') {
else if (value.path && value.id && typeof value.path === "string") {
result[key] = value.path;
}
// Gérer les GeoPoint
else if (value.latitude !== undefined && value.longitude !== undefined) {
result[key] = {
latitude: value.latitude,
longitude: value.longitude
longitude: value.longitude,
};
}
// Gérer les tableaux
else if (Array.isArray(value)) {
result[key] = value.map(item => {
if (!item || typeof item !== 'object') return item;
result[key] = value.map((item) => {
if (!item || typeof item !== "object") return item;
// DocumentReference dans un tableau
if (item.path && item.id) {
return item.path;
}
// Timestamp dans un tableau
if (item.toDate && typeof item.toDate === 'function') {
if (item.toDate && typeof item.toDate === "function") {
return item.toDate().toISOString();
}
// Objet normal
@@ -61,7 +61,7 @@ function serializeTimestamps(data) {
});
}
// Gérer les objets imbriqués (mais pas les objets Firestore)
else if (typeof value === 'object' && !value._firestore && !value._path) {
else if (typeof value === "object" && !value._firestore && !value._path) {
result[key] = serializeTimestamps(value);
}
}
@@ -75,10 +75,10 @@ function serializeTimestamps(data) {
function deserializeTimestamps(data, timestampFields = []) {
if (!data) return data;
const result = { ...data };
const result = {...data};
for (const field of timestampFields) {
if (result[field] && typeof result[field] === 'string') {
if (result[field] && typeof result[field] === "string") {
result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field]));
}
}
@@ -92,15 +92,15 @@ function deserializeTimestamps(data, timestampFields = []) {
function serializeReferences(data) {
if (!data) return data;
const result = { ...data };
const result = {...data};
for (const key in result) {
if (result[key] && result[key].path && typeof result[key].path === 'string') {
if (result[key] && result[key].path && typeof result[key].path === "string") {
// C'est une DocumentReference
result[key] = result[key].id;
} else if (Array.isArray(result[key])) {
result[key] = result[key].map(item => {
if (item && item.path && typeof item.path === 'string') {
result[key] = result[key].map((item) => {
if (item && item.path && typeof item.path === "string") {
return item.id;
}
return item;
@@ -117,7 +117,7 @@ function serializeReferences(data) {
function maskSensitiveFields(data, canViewSensitive) {
if (canViewSensitive) return data;
const masked = { ...data };
const masked = {...data};
// Masquer les prix si pas de permission manage_equipment
delete masked.purchasePrice;
@@ -143,34 +143,34 @@ function paginate(query, limit = 50, startAfter = null) {
* Filtre les événements annulés
*/
function filterCancelledEvents(events) {
return events.filter(event => event.status !== 'CANCELLED');
return events.filter((event) => event.status !== "CANCELLED");
}
/**
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
* @param {Object} data - Données de l'événement
* @returns {Object} - Données avec DocumentReference
* @return {Object} - Données avec DocumentReference
*/
function convertIdsToReferences(data) {
if (!data) return data;
const result = { ...data };
const result = {...data};
// Convertir EventType (ID → DocumentReference)
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) {
result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType);
}
// Convertir customer (ID → DocumentReference)
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
result.customer = admin.firestore().collection('customers').doc(result.customer);
if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) {
result.customer = admin.firestore().collection("customers").doc(result.customer);
}
// Convertir workforce (IDs → DocumentReference)
if (Array.isArray(result.workforce)) {
result.workforce = result.workforce.map(item => {
if (typeof item === 'string' && !item.includes('/')) {
return admin.firestore().collection('users').doc(item);
result.workforce = result.workforce.map((item) => {
if (typeof item === "string" && !item.includes("/")) {
return admin.firestore().collection("users").doc(item);
}
return item;
});