/** * EM2RP Cloud Functions * Architecture backend sécurisée avec authentification et permissions */ const { onRequest, onCall } = require("firebase-functions/v2/https"); const logger = require("firebase-functions/logger"); const admin = require('firebase-admin'); const { Storage } = require('@google-cloud/storage'); // Utilitaires const auth = require('./utils/auth'); const helpers = require('./utils/helpers'); // Initialisation admin.initializeApp(); const storage = new Storage(); const db = admin.firestore(); // Configuration commune pour toutes les fonctions HTTP const httpOptions = { cors: true, invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase) // Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS }; // ============================================================================ // CORS Middleware // ============================================================================ const setCorsHeaders = (res, req) => { // Permettre toutes les origines en développement/production const origin = req.headers.origin || req.headers.referer || '*'; res.set('Access-Control-Allow-Origin', 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'); }; // Wrapper pour les fonctions avec CORS const withCors = (handler) => { return async (req, res) => { // Définir les headers CORS pour toutes les requêtes setCorsHeaders(res, req); // Gérer les requêtes preflight OPTIONS if (req.method === 'OPTIONS') { res.status(204).send(''); return; } // Exécuter le handler try { await handler(req, res); } catch (error) { logger.error("Unhandled error:", error); if (!res.headersSent) { res.status(500).json({ error: error.message }); } } }; }; // ============================================================================ // STORAGE - Move Event File // ============================================================================ exports.moveEventFileV2 = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // EQUIPMENT - CRUD // ============================================================================ // Créer un équipement (admin ou manage_equipment) exports.createEquipment = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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; } // 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 = onRequest(httpOptions, withCors(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 } = 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 actifs const eventsSnapshot = await db.collection('events') .where('status', '!=', 'CANCELLED') .get(); for (const eventDoc of eventsSnapshot.docs) { const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { res.status(409).json({ error: 'Cannot delete equipment: it is assigned to active events', eventId: eventDoc.id }); 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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // CONTAINERS - CRUD // ============================================================================ // Créer un container exports.createContainer = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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; } // Limiter à 100 conteneurs max par requête if (containerIds.length > 100) { res.status(400).json({ error: 'Maximum 100 container IDs per request' }); return; } // Récupérer tous les documents en parallèle 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 = onRequest(httpOptions, withCors(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; } // Récupérer le container 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 || []; // Vérifier si l'équipement n'est pas déjà dans ce container if (equipmentIds.includes(equipmentId)) { res.status(400).json({ success: false, message: 'Cet équipement est déjà dans ce container' }); return; } // Récupérer l'équipement 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 || []; // Vérifier les autres containers 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(", ")}`); } } // Mettre à jour le container await db.collection('containers').doc(containerId).update({ equipmentIds: [...equipmentIds, equipmentId], updatedAt: admin.firestore.Timestamp.now(), }); // Mettre à jour l'équipement await db.collection('equipments').doc(equipmentId).update({ parentBoxIds: [...parentBoxIds, containerId], updatedAt: admin.firestore.Timestamp.now(), }); // Ajouter une entrée dans l'historique 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); // Garder les 100 dernières entrées 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 = onRequest(httpOptions, withCors(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; } // Récupérer le container 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 || []; // Retirer l'équipement du container const updatedEquipmentIds = equipmentIds.filter(id => id !== equipmentId); await db.collection('containers').doc(containerId).update({ equipmentIds: updatedEquipmentIds, updatedAt: admin.firestore.Timestamp.now(), }); // Mettre à jour l'équipement 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(), }); } // Ajouter une entrée dans l'historique 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 }); } })); // ============================================================================ // EVENTS - CRUD // ============================================================================ // Créer un événement exports.createEvent = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const hasAccess = await auth.hasPermission(decodedToken.uid, 'edit_event'); if (!hasAccess) { res.status(403).json({ error: 'Forbidden: Requires edit_event permission' }); return; } const eventData = req.body.data; const dataToSave = helpers.deserializeTimestamps(eventData, [ 'StartDateTime', 'EndDateTime', 'createdAt', 'updatedAt' ]); const docRef = await db.collection('events').add(dataToSave); res.status(201).json({ id: docRef.id, message: 'Event created successfully' }); } catch (error) { logger.error("Error creating event:", error); res.status(500).json({ error: error.message }); } })); // Mettre à jour un événement exports.updateEvent = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const hasAccess = await auth.hasPermission(decodedToken.uid, 'edit_event'); if (!hasAccess) { res.status(403).json({ error: 'Forbidden: Requires edit_event permission' }); return; } const requestData = req.body.data; logger.info(`Update event - requestData keys: ${Object.keys(requestData || {}).join(', ')}`); const eventId = requestData.eventId; logger.info(`Update event - eventId: ${eventId}`); if (!eventId) { logger.error('Event ID is missing from request'); res.status(400).json({ error: 'Event ID is required' }); return; } // Extraire eventId et préparer les données à sauvegarder const { eventId: _, ...data } = requestData; if (!data || Object.keys(data).length === 0) { res.status(400).json({ error: 'No data to update' }); return; } delete data.id; data.updatedAt = admin.firestore.Timestamp.now(); const dataToSave = helpers.deserializeTimestamps(data, [ 'StartDateTime', 'EndDateTime' ]); await db.collection('events').doc(eventId).update(dataToSave); res.status(200).json({ message: 'Event updated successfully' }); } catch (error) { logger.error("Error updating event:", error); res.status(500).json({ error: error.message }); } })); // Supprimer un événement exports.deleteEvent = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const hasAccess = await auth.hasPermission(decodedToken.uid, 'delete_event'); if (!hasAccess) { res.status(403).json({ error: 'Forbidden: Requires delete_event permission' }); return; } const { eventId } = req.body.data; if (!eventId) { res.status(400).json({ error: 'Event ID is required' }); return; } await db.collection('events').doc(eventId).delete(); res.status(200).json({ message: 'Event deleted successfully' }); } catch (error) { logger.error("Error deleting event:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // MAINTENANCES - CRUD // ============================================================================ // Créer une maintenance exports.createMaintenance = onRequest(httpOptions, withCors(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; // Mettre à jour les équipements concernés 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, }); } } // Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte 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) { // Vérifier si une alerte existe déjà 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 = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // OPTIONS - CRUD // ============================================================================ // Créer une option exports.createOption = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // USERS - CRUD // ============================================================================ // Créer un utilisateur exports.createUser = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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; } // Générer un mot de passe temporaire aléatoire const tempPassword = Math.random().toString(36).slice(-12) + 'Aa1!'; // Créer l'utilisateur dans Firebase Auth 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; } // Créer le document utilisateur dans Firestore 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) { // Si la création Firestore échoue, supprimer l'utilisateur Auth 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; } // Envoyer l'email de réinitialisation du mot de passe // Utilisation de l'API REST de Firebase Auth pour déclencher l'envoi automatique try { const axios = require('axios'); const firebaseApiKey = 'AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U'; // Web API Key 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}`); // Ne pas faire échouer la requête si l'email ne peut pas être envoyé } 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 = onRequest(httpOptions, withCors(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; } // Vérifier si l'utilisateur met à jour son propre profil ou est admin const isOwnProfile = decodedToken.uid === userId; const isAdminUser = await auth.isAdmin(decodedToken.uid); const hasEditPermission = await auth.hasPermission(decodedToken.uid, 'edit_user'); if (!isOwnProfile && !isAdminUser && !hasEditPermission) { res.status(403).json({ error: 'Forbidden: Cannot edit other users' }); return; } // Si mise à jour propre profil, limiter les champs modifiables if (isOwnProfile && !isAdminUser) { const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl']; const filteredData = {}; for (const field of allowedFields) { if (data[field] !== undefined) { filteredData[field] = data[field]; } } await db.collection('users').doc(userId).update(filteredData); } else { delete data.uid; // Convertir le role string en DocumentReference si présent 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 = onRequest(httpOptions, withCors(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; } // Empêcher un admin de se supprimer lui-même if (decodedToken.uid === userId) { res.status(400).json({ error: 'Cannot delete your own account' }); return; } // Supprimer le document utilisateur dans Firestore await db.collection('users').doc(userId).delete(); // Optionnel: Supprimer l'utilisateur de Firebase Auth // Note: Cela nécessite le SDK Admin et des privilèges élevés try { await admin.auth().deleteUser(userId); } catch (authError) { logger.warn(`Could not delete user from Auth: ${authError.message}`); // On continue même si la suppression Auth échoue } res.status(200).json({ message: 'User deleted successfully' }); } catch (error) { logger.error("Error deleting user:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // EQUIPMENT STATUS - Batch Update // ============================================================================ // Mettre à jour le statut de plusieurs équipements (pour préparation/retour) exports.updateEquipmentStatus = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // OPTIONS - Read (public pour utilisateurs authentifiés) // ============================================================================ exports.getOptions = onRequest(httpOptions, withCors(async (req, res) => { try { await auth.authenticateUser(req); // Juste vérifier l'auth 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 }); } })); // ============================================================================ // EVENT TYPES - Read (public pour utilisateurs authentifiés) // ============================================================================ exports.getEventTypes = onRequest(httpOptions, withCors(async (req, res) => { try { await auth.authenticateUser(req); // Juste vérifier l'auth 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 }); } })); // ============================================================================ // ROLES - Read (public pour utilisateurs authentifiés) // ============================================================================ exports.getRoles = onRequest(httpOptions, withCors(async (req, res) => { try { await auth.authenticateUser(req); // Juste vérifier l'auth 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 }); } })); // ============================================================================ // EVENT EQUIPMENT - Update equipment status and quantities // ============================================================================ exports.updateEventEquipment = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const { eventId, assignedEquipment, preparationStatus, loadingStatus, unloadingStatus, returnStatus } = req.body.data; if (!eventId) { res.status(400).json({ error: 'Event ID is required' }); return; } // Vérifier les permissions const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const isAdminUser = await auth.hasPermission(decodedToken.uid, 'edit_event'); // Vérifier si l'utilisateur est assigné en vérifiant workforce de manière sécurisée let isAssigned = false; if (eventData.workforce && Array.isArray(eventData.workforce)) { isAssigned = eventData.workforce.some(ref => { if (!ref || !ref.path) return false; return ref.path.endsWith(decodedToken.uid) || ref.path === `/users/${decodedToken.uid}`; }); } if (!isAssigned && !isAdminUser) { res.status(403).json({ error: 'Forbidden: Not assigned to this event' }); return; } // Préparer les données à mettre à jour const updateData = {}; if (assignedEquipment) { // Convertir les timestamps dans assignedEquipment updateData.assignedEquipment = assignedEquipment.map(eq => helpers.deserializeTimestamps(eq, []) ); } if (preparationStatus) updateData.preparationStatus = preparationStatus; if (loadingStatus) updateData.loadingStatus = loadingStatus; if (unloadingStatus) updateData.unloadingStatus = unloadingStatus; if (returnStatus) updateData.returnStatus = returnStatus; // Mettre à jour l'événement await db.collection('events').doc(eventId).update(updateData); res.status(200).json({ message: 'Event equipment updated successfully' }); } catch (error) { logger.error("Error updating event equipment:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // EQUIPMENT STATUS - Update individual equipment status // ============================================================================ exports.updateEquipmentStatusOnly = onRequest(httpOptions, withCors(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 }); } })); // ============================================================================ // EVENT TYPES - CRUD Operations // ============================================================================ // Récupérer les événements utilisant un type d'événement exports.getEventsByEventType = onRequest(httpOptions, withCors(async (req, res) => { try { await auth.authenticateUser(req); 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 events = eventsSnapshot.docs.map(doc => ({ id: doc.id, name: doc.data().name, startDateTime: doc.data().StartDateTime, })); res.status(200).json({ events }); } catch (error) { logger.error("Error fetching events by type:", error); res.status(500).json({ error: error.message }); } })); // Créer un type d'événement (admin uniquement) exports.createEventType = onRequest(httpOptions, withCors(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; } // Vérifier l'unicité du nom 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 (admin uniquement) exports.updateEventType = onRequest(httpOptions, withCors(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; } // Vérifier que le document existe 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; } // Vérifier l'unicité du nom (sauf pour le document actuel) 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 (admin uniquement) exports.deleteEventType = onRequest(httpOptions, withCors(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; } // Vérifier qu'aucun événement futur n'utilise ce type 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 }); } })); // ============================================================================ // EVENTS - Read with permissions // ============================================================================ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const { userId } = req.body.data || {}; // Vérifier si l'utilisateur peut voir tous les événements const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events'); let eventsSnapshot; if (canViewAll) { // Admin : tous les événements eventsSnapshot = await db.collection('events').get(); } else { // Utilisateur normal : seulement ses événements assignés const userRef = db.collection('users').doc(userId || decodedToken.uid); eventsSnapshot = await db.collection('events') .where('workforce', 'array-contains', userRef) .get(); } // Collecter tous les UIDs utilisateurs uniques const userIdsSet = new Set(); eventsSnapshot.docs.forEach(doc => { const data = doc.data(); if (data.workforce && Array.isArray(data.workforce)) { data.workforce.forEach(userRef => { if (userRef && userRef.id) { userIdsSet.add(userRef.id); } else if (typeof userRef === 'string' && userRef.startsWith('users/')) { userIdsSet.add(userRef.split('/')[1]); } }); } }); // Récupérer tous les utilisateurs en une seule fois const usersMap = {}; if (userIdsSet.size > 0) { const userIds = Array.from(userIdsSet); // Récupérer par batch (Firestore limite à 10 par requête 'in') const batchSize = 10; for (let i = 0; i < userIds.length; i += batchSize) { const batch = userIds.slice(i, i + batchSize); const usersSnapshot = await db.collection('users') .where(admin.firestore.FieldPath.documentId(), 'in', batch) .get(); usersSnapshot.docs.forEach(userDoc => { const userData = userDoc.data(); // Stocker uniquement les données publiques usersMap[userDoc.id] = { uid: userDoc.id, firstName: userData.firstName || '', lastName: userData.lastName || '', email: userData.email || '', phoneNumber: userData.phoneNumber || '', profilePhotoUrl: userData.profilePhotoUrl || '', }; }); } } // Sérialiser les événements avec workforce comme liste d'UIDs const events = eventsSnapshot.docs.map(doc => { const data = doc.data(); // Convertir workforce en liste d'UIDs let workforceUids = []; if (data.workforce && Array.isArray(data.workforce)) { workforceUids = data.workforce.map(userRef => { if (userRef && userRef.id) { return userRef.id; } else if (typeof userRef === 'string' && userRef.startsWith('users/')) { return userRef.split('/')[1]; } return null; }).filter(uid => uid !== null); } return { id: doc.id, ...helpers.serializeTimestamps(data), workforce: workforceUids, // Liste d'UIDs au lieu de DocumentReference }; }); // Retourner events + users map res.status(200).json({ events, users: usersMap // Map UID -> données utilisateur }); } catch (error) { logger.error("Error fetching events:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // EQUIPMENTS - Read with permissions // ============================================================================ exports.getEquipments = onRequest(httpOptions, withCors(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; } const snapshot = await db.collection('equipments').get(); const equipments = snapshot.docs.map(doc => { const data = doc.data(); // Masquer les prix si l'utilisateur n'a pas manage_equipment if (!canManage) { delete data.purchasePrice; delete data.rentalPrice; } return { id: doc.id, ...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']) }; }); res.status(200).json({ equipments }); } catch (error) { logger.error("Error fetching equipments:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // CONTAINERS - Read with permissions // ============================================================================ exports.getContainers = onRequest(httpOptions, withCors(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; } const snapshot = await db.collection('containers').get(); const containers = snapshot.docs.map(doc => { const data = doc.data(); return { id: doc.id, ...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt']) }; }); res.status(200).json({ containers }); } catch (error) { logger.error("Error fetching containers:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // MAINTENANCES - Read with permissions // ============================================================================ exports.getMaintenances = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const { equipmentId } = req.body.data || {}; // 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; } let query = db.collection('maintenances'); // Filtrer par équipement si spécifié 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 }); } })); // ============================================================================ // ALERTS - Read with permissions // ============================================================================ exports.getAlerts = onRequest(httpOptions, withCors(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 }); } })); exports.markAlertAsRead = onRequest(httpOptions, withCors(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 }); } })); exports.deleteAlert = onRequest(httpOptions, withCors(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 }); } })); /** * Créer une nouvelle alerte */ exports.createAlert = onRequest(httpOptions, withCors(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 { type, title, message, severity, equipmentId } = req.body.data; if (!type || !message) { res.status(400).json({ error: 'type and message are required' }); return; } // Vérifier si une alerte similaire existe déjà (éviter les doublons) const existingAlertsQuery = await db.collection('alerts') .where('type', '==', type) .where('isRead', '==', false) .get(); let alertExists = false; if (equipmentId) { // Pour les alertes liées à un équipement, vérifier aussi l'equipmentId alertExists = existingAlertsQuery.docs.some(doc => doc.data().equipmentId === equipmentId ); } else { // Pour les autres alertes, vérifier le message alertExists = existingAlertsQuery.docs.some(doc => doc.data().message === message ); } if (alertExists) { res.status(200).json({ success: true, message: 'Alert already exists', skipped: true }); return; } // Créer la nouvelle alerte const alertData = { type: type, title: title || 'Alerte', message: message, severity: severity || 'MEDIUM', isRead: false, createdAt: admin.firestore.Timestamp.now(), }; if (equipmentId) { alertData.equipmentId = equipmentId; } const alertRef = await db.collection('alerts').add(alertData); res.status(200).json({ success: true, alertId: alertRef.id }); } catch (error) { logger.error("Error creating alert:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // USERS - Read with permissions // ============================================================================ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); // Vérifier les permissions const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_users'); if (!canViewAll) { // Si pas admin, ne retourner que l'utilisateur lui-même 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; } // Admin : tous les utilisateurs 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 }); } })); // ============================================================================ // USER - Récupération individuelle // ============================================================================ /** * Récupère un utilisateur spécifique par son ID * Tout utilisateur authentifié peut accéder aux données publiques */ exports.getUser = onCall(async (request) => { try { await authenticateUser(request); const db = getFirestore(); const { userId } = request.data; if (!userId) { throw new Error("userId is required"); } const userDoc = await db.collection("users").doc(userId).get(); if (!userDoc.exists) { throw new Error("User not found"); } const user = userDoc.data(); // Données publiques accessibles à tous 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 || "", }; // Inclure le rôle si disponible if (user.role) { const roleDoc = await user.role.get(); if (roleDoc.exists) { userData.role = { id: roleDoc.id, ...roleDoc.data(), }; } } return { user: userData }; } catch (error) { logger.error("Error fetching user:", error); throw new Error(error.message || "Failed to fetch user"); } }); // ============================================================================ // EQUIPMENT AVAILABILITY - Vérification de disponibilité // ============================================================================ exports.checkEquipmentAvailability = onRequest(httpOptions, withCors(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; } // Gérer les dates qui peuvent être des Timestamps ou des objets Date let eventStart, 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; } // Vérifier si l'équipement est assigné à cet événement (directement ou via une boîte) const assignedEquipment = event.assignedEquipment || []; const assignedContainers = event.assignedContainers || []; // Vérifier si l'équipement est directement assigné const isEquipmentDirectlyAssigned = assignedEquipment.some(eq => eq.equipmentId === equipmentId); // Vérifier si l'équipement est dans une boîte assignée let isEquipmentInAssignedContainer = false; if (assignedContainers.length > 0) { logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`); // Récupérer les conteneurs assignés et vérifier si l'équipement y est 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; } // Vérifier le chevauchement de dates const requestStart = startTimestamp.toDate(); const requestEnd = endTimestamp.toDate(); // Inclure les temps d'installation et de démontage 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); // Il y a conflit si les périodes se chevauchent const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup; if (hasOverlap) { // Calculer les jours de chevauchement 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})`); // Retourner les détails complets de l'événement const eventData = helpers.serializeTimestamps(event); conflicts.push({ eventId: eventDoc.id, eventName: event.Name, eventData: eventData, // Ajouter toutes les données de l'événement 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" }); } })); exports.checkContainerAvailability = onRequest(httpOptions, withCors(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; } // Récupérer le container et ses équipements 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; } // Gérer les dates let eventStart, 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; } // Vérifier si le container est assigné const assignedContainers = event.assignedContainers || []; const isContainerAssigned = assignedContainers.includes(containerId); // Vérifier si des équipements du container sont assignés const assignedEquipment = event.assignedEquipment || []; const conflictingEquipmentIds = equipmentIds.filter(eqId => assignedEquipment.some(eq => eq.equipmentId === eqId) ); if (!isContainerAssigned && conflictingEquipmentIds.length === 0) { continue; } // Vérifier le chevauchement de dates 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" }); } })); // ============================================================================ // AVAILABILITY - Optimized batch check // ============================================================================ /** * Récupère tous les équipements et conteneurs en conflit pour une période donnée * Optimisé : une seule requête au lieu d'une par équipement */ exports.getConflictingEquipmentIds = onRequest(httpOptions, withCors(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}`); // Calculer la période effective avec temps de montage/démontage const requestStartDate = new Date(startDate); requestStartDate.setHours(requestStartDate.getHours() - installationTime); const requestEndDate = new Date(endDate); requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime); // Récupérer tous les événements non annulés const eventsSnapshot = await db.collection('events') .where('status', '!=', 'CANCELLED') .get(); logger.info(`Found ${eventsSnapshot.docs.length} events to check`); // Récupérer tous les équipements pour savoir lesquels sont quantifiables 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' }; }); // Maps pour stocker les conflits const conflictingEquipmentIds = new Set(); const conflictingContainerIds = new Set(); const conflictDetails = {}; // { equipmentId/containerId: [{ eventId, eventName, startDate, endDate, quantity }] } const equipmentQuantities = {}; // { equipmentId: { totalQuantity, reservedQuantity, availableQuantity, reservations: [...] } } for (const eventDoc of eventsSnapshot.docs) { // Exclure l'événement en cours d'édition if (excludeEventId && eventDoc.id === excludeEventId) { continue; } const event = eventDoc.data(); // Gérer les dates let eventStart, 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; } // Ajouter temps de montage/démontage de cet événement 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); // Vérifier le chevauchement de dates const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup; if (!hasOverlap) { continue; } // Il y a chevauchement ! Récupérer les équipements et conteneurs assignés const assignedEquipment = event.assignedEquipment || []; const assignedContainers = event.assignedContainers || []; const conflictInfo = { eventId: eventDoc.id, eventName: event.Name, startDate: eventStart.toISOString(), endDate: eventEnd.toISOString(), }; // Ajouter les équipements directement assignés for (const eq of assignedEquipment) { const equipmentId = eq.equipmentId; const quantity = eq.quantity || 1; const equipInfo = equipmentsInfo[equipmentId]; // Pour les équipements quantifiables, on ne les marque pas forcément comme "en conflit" // On calcule juste les quantités réservées if (equipInfo && equipInfo.hasQuantity) { // Initialiser les infos de quantité si nécessaire if (!equipmentQuantities[equipmentId]) { equipmentQuantities[equipmentId] = { totalQuantity: equipInfo.totalQuantity, reservedQuantity: 0, availableQuantity: equipInfo.totalQuantity, reservations: [] }; } // Ajouter la réservation equipmentQuantities[equipmentId].reservedQuantity += quantity; equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity; equipmentQuantities[equipmentId].reservations.push({ ...conflictInfo, quantity: quantity }); // Ne marquer comme "en conflit" que si la quantité totale est épuisée if (equipmentQuantities[equipmentId].availableQuantity <= 0) { conflictingEquipmentIds.add(equipmentId); } } else { // Pour les équipements non quantifiables, comportement classique conflictingEquipmentIds.add(equipmentId); } if (!conflictDetails[equipmentId]) { conflictDetails[equipmentId] = []; } conflictDetails[equipmentId].push({ ...conflictInfo, quantity: quantity }); } // Ajouter les conteneurs assignés for (const containerId of assignedContainers) { conflictingContainerIds.add(containerId); if (!conflictDetails[containerId]) { conflictDetails[containerId] = []; } conflictDetails[containerId].push(conflictInfo); // Récupérer les équipements dans ce conteneur const containerDoc = await db.collection('containers').doc(containerId).get(); if (containerDoc.exists) { const containerData = containerDoc.data(); const equipmentIds = containerData.equipmentIds || []; // Marquer tous les équipements du conteneur comme en conflit for (const equipmentId of equipmentIds) { conflictingEquipmentIds.add(equipmentId); if (!conflictDetails[equipmentId]) { conflictDetails[equipmentId] = []; } // Ajouter une note indiquant que c'est via le conteneur 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, // NOUVEAU : Informations sur les quantités }); } catch (error) { logger.error("Error getting conflicting equipment IDs:", error); res.status(500).json({ error: error.message || "Failed to get conflicting equipment IDs" }); } })); // ============================================================================ // USER - Get current authenticated user // ============================================================================ exports.getCurrentUser = onRequest(httpOptions, withCors(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(); // Récupérer le rôle 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 }); } })); // ============================================================================ // MAINTENANCE - Delete // ============================================================================ exports.deleteMaintenance = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); // Vérifier permission 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; } // Récupérer la maintenance pour connaître les équipements const maintenanceDoc = await db.collection('maintenances').doc(maintenanceId).get(); if (maintenanceDoc.exists) { const maintenance = maintenanceDoc.data(); // Retirer la maintenance des équipements 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 }); } })); // ============================================================================ // EVENT PREPARATION - Validation des étapes de préparation // ============================================================================ // Helper: Mettre à jour le statut d'un équipement async function updateEquipmentStatus(equipmentId, status) { try { const doc = await db.collection('equipments').doc(equipmentId).get(); if (!doc.exists) { logger.warn(`Equipment ${equipmentId} does not exist, skipping status update`); return; } await db.collection('equipments').doc(equipmentId).update({ status: status, updatedAt: admin.firestore.Timestamp.now(), }); } catch (error) { logger.error(`Error updating equipment status for ${equipmentId}:`, error); } } // Valider un équipement individuel en préparation exports.validateEquipmentPreparation = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId, equipmentId } = req.body.data; if (!eventId || !equipmentId) { res.status(400).json({ error: 'eventId and equipmentId are required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; // Mettre à jour le statut de l'équipement const updatedEquipment = assignedEquipment.map(eq => { if (eq.equipmentId === equipmentId) { return { ...eq, isPrepared: true }; } return eq; }); // Vérifier si tous sont préparés const allPrepared = updatedEquipment.every(eq => eq.isPrepared); const updateData = { assignedEquipment: updatedEquipment, preparationStatus: allPrepared ? 'completed' : 'inProgress', }; await db.collection('events').doc(eventId).update(updateData); res.status(200).json({ success: true, allPrepared }); } catch (error) { logger.error("Error validating equipment preparation:", error); res.status(500).json({ error: error.message }); } })); // Valider tous les équipements en préparation exports.validateAllPreparation = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId } = req.body.data; if (!eventId) { res.status(400).json({ error: 'eventId is required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; // Marquer tous comme préparés const updatedEquipment = assignedEquipment.map(eq => ({ ...eq, isPrepared: true, })); await db.collection('events').doc(eventId).update({ assignedEquipment: updatedEquipment, preparationStatus: 'completed', }); // Mettre à jour le statut des équipements à "inUse" for (const equipment of assignedEquipment) { await updateEquipmentStatus(equipment.equipmentId, 'inUse'); } res.status(200).json({ success: true }); } catch (error) { logger.error("Error validating all preparation:", error); res.status(500).json({ error: error.message }); } })); // Valider un équipement individuel pour le chargement exports.validateEquipmentLoading = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId, equipmentId } = req.body.data; if (!eventId || !equipmentId) { res.status(400).json({ error: 'eventId and equipmentId are required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => { if (eq.equipmentId === equipmentId) { return { ...eq, isLoaded: true }; } return eq; }); const allLoaded = updatedEquipment.every(eq => eq.isLoaded); const updateData = { assignedEquipment: updatedEquipment, loadingStatus: allLoaded ? 'completed' : 'inProgress', }; await db.collection('events').doc(eventId).update(updateData); res.status(200).json({ success: true, allLoaded }); } catch (error) { logger.error("Error validating equipment loading:", error); res.status(500).json({ error: error.message }); } })); // Valider tous les équipements pour le chargement exports.validateAllLoading = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId } = req.body.data; if (!eventId) { res.status(400).json({ error: 'eventId is required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => ({ ...eq, isLoaded: true, })); await db.collection('events').doc(eventId).update({ assignedEquipment: updatedEquipment, loadingStatus: 'completed', }); res.status(200).json({ success: true }); } catch (error) { logger.error("Error validating all loading:", error); res.status(500).json({ error: error.message }); } })); // Valider un équipement individuel pour le déchargement exports.validateEquipmentUnloading = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId, equipmentId } = req.body.data; if (!eventId || !equipmentId) { res.status(400).json({ error: 'eventId and equipmentId are required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => { if (eq.equipmentId === equipmentId) { return { ...eq, isUnloaded: true }; } return eq; }); const allUnloaded = updatedEquipment.every(eq => eq.isUnloaded); const updateData = { assignedEquipment: updatedEquipment, unloadingStatus: allUnloaded ? 'completed' : 'inProgress', }; await db.collection('events').doc(eventId).update(updateData); res.status(200).json({ success: true, allUnloaded }); } catch (error) { logger.error("Error validating equipment unloading:", error); res.status(500).json({ error: error.message }); } })); // Valider tous les équipements pour le déchargement exports.validateAllUnloading = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId } = req.body.data; if (!eventId) { res.status(400).json({ error: 'eventId is required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => ({ ...eq, isUnloaded: true, })); await db.collection('events').doc(eventId).update({ assignedEquipment: updatedEquipment, unloadingStatus: 'completed', }); res.status(200).json({ success: true }); } catch (error) { logger.error("Error validating all unloading:", error); res.status(500).json({ error: error.message }); } })); // Valider un équipement individuel pour le retour exports.validateEquipmentReturn = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId, equipmentId, returnedQuantity } = req.body.data; if (!eventId || !equipmentId) { res.status(400).json({ error: 'eventId and equipmentId are required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => { if (eq.equipmentId === equipmentId) { return { ...eq, isReturned: true, returnedQuantity: returnedQuantity !== undefined ? returnedQuantity : eq.returnedQuantity, }; } return eq; }); const allReturned = updatedEquipment.every(eq => eq.isReturned); const updateData = { assignedEquipment: updatedEquipment, returnStatus: allReturned ? 'completed' : 'inProgress', }; await db.collection('events').doc(eventId).update(updateData); // Mettre à jour le stock si c'est un consommable if (returnedQuantity !== undefined) { const equipmentDoc = await db.collection('equipments').doc(equipmentId).get(); if (equipmentDoc.exists) { const equipmentData = equipmentDoc.data(); if (equipmentData.hasQuantity) { const currentAvailable = equipmentData.availableQuantity || 0; await db.collection('equipments').doc(equipmentId).update({ availableQuantity: currentAvailable + returnedQuantity, }); } } } res.status(200).json({ success: true, allReturned }); } catch (error) { logger.error("Error validating equipment return:", error); res.status(500).json({ error: error.message }); } })); // Valider tous les retours exports.validateAllReturn = onRequest(httpOptions, withCors(async (req, res) => { try { const decodedToken = await auth.authenticateUser(req); const canManage = await auth.hasPermission(decodedToken.uid, 'manage_events'); if (!canManage) { res.status(403).json({ error: 'Forbidden: Requires manage_events permission' }); return; } const { eventId, returnedQuantities } = req.body.data; if (!eventId) { res.status(400).json({ error: 'eventId is required' }); return; } const eventDoc = await db.collection('events').doc(eventId).get(); if (!eventDoc.exists) { res.status(404).json({ error: 'Event not found' }); return; } const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; const updatedEquipment = assignedEquipment.map(eq => { const returnedQty = returnedQuantities?.[eq.equipmentId] || eq.returnedQuantity || eq.quantity; return { ...eq, isReturned: true, returnedQuantity: returnedQty, }; }); await db.collection('events').doc(eventId).update({ assignedEquipment: updatedEquipment, returnStatus: 'completed', }); // Mettre à jour le statut des équipements à "available" et gérer les stocks for (const equipment of updatedEquipment) { const equipmentDoc = await db.collection('equipments').doc(equipment.equipmentId).get(); if (equipmentDoc.exists) { const equipmentData = equipmentDoc.data(); // Mettre à jour le statut uniquement pour les équipements non quantifiables if (!equipmentData.hasQuantity) { await updateEquipmentStatus(equipment.equipmentId, 'available'); } // Restaurer le stock pour les consommables if (equipmentData.hasQuantity && equipment.returnedQuantity) { const currentAvailable = equipmentData.availableQuantity || 0; await db.collection('equipments').doc(equipment.equipmentId).update({ availableQuantity: currentAvailable + equipment.returnedQuantity, updatedAt: admin.firestore.Timestamp.now(), }); } } } res.status(200).json({ success: true }); } catch (error) { logger.error("Error validating all return:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // AVAILABILITY & STOCK CHECK // ============================================================================ /** * Vérifier la disponibilité d'un équipement pour une période donnée */ exports.checkEquipmentAvailability = onRequest(httpOptions, withCors(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 } = req.body.data; if (!equipmentId || !startDate || !endDate) { res.status(400).json({ error: 'equipmentId, 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 les événements qui chevauchent la période const eventsSnapshot = await db.collection('events') .where('StartDateTime', '<=', end) .where('EndDateTime', '>=', start) .where('status', '!=', 'CANCELLED') .get(); const conflicts = []; eventsSnapshot.docs.forEach(doc => { const eventData = doc.data(); const assignedEquipment = eventData.assignedEquipment || []; for (const eq of assignedEquipment) { if (eq.equipmentId === equipmentId) { conflicts.push({ eventId: doc.id, eventName: eventData.Name || 'Sans nom', startDate: eventData.StartDateTime.toDate().toISOString(), endDate: eventData.EndDateTime.toDate().toISOString(), }); break; } } }); res.status(200).json({ conflicts }); } catch (error) { logger.error("Error checking equipment availability:", error); res.status(500).json({ error: error.message }); } })); /** * Trouver des alternatives (même modèle) disponibles pour une période donnée */ exports.findAlternativeEquipment = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 }); } })); /** * Vérifier les maintenances à venir et créer des alertes */ exports.checkUpcomingMaintenances = onRequest(httpOptions, withCors(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 = onRequest(httpOptions, withCors(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 }); } }));