/** * EM2RP Cloud Functions * Architecture backend sécurisée et modulaire avec dynamic imports (lazy loading) */ const path = require("path"); require("dotenv").config({path: path.join(__dirname, ".env.local")}); require("dotenv").config({path: path.join(__dirname, ".env")}); const {onRequest, onCall} = require("firebase-functions/v2/https"); const {onSchedule} = require("firebase-functions/v2/scheduler"); const {onDocumentCreated, onDocumentUpdated} = require("firebase-functions/v2/firestore"); const logger = require("firebase-functions/logger"); const admin = require("firebase-admin"); // Initialisation sécurisée de Firebase Admin au démarrage if (!admin.apps.length) { admin.initializeApp(); } const db = admin.firestore(); // Configuration commune pour toutes les fonctions HTTP const httpOptions = { cors: false, invoker: "public", region: "europe-west9", }; // Options dédiées pour les traitements IA potentiellement longs. const aiHttpOptions = { ...httpOptions, timeoutSeconds: 300, memory: "1GiB", }; // Options HTTP spécifiques pour TTS avec CORS activé const ttsHttpOptions = { cors: true, invoker: "public", region: "europe-west9", }; // CORS Middleware const setCorsHeaders = (res, req) => { const origin = req.headers.origin || "*"; res.set("Access-Control-Allow-Origin", origin); if (origin !== "*") { res.set("Access-Control-Allow-Credentials", "true"); } res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With"); res.set("Access-Control-Max-Age", "3600"); }; const withCors = (handler) => { return async (req, res) => { setCorsHeaders(res, req); if (req.method === "OPTIONS") { res.status(204).send(""); return; } try { await handler(req, res); } catch (error) { logger.error("Unhandled error:", error); if (!res.headersSent) { res.status(500).json({error: error.message}); } } }; }; // ============================================================================ // STORAGE // ============================================================================ exports.moveEventFileV2 = onRequest(httpOptions, withCors((req, res) => { return require("./src/storage").moveEventFileV2(req, res); })); // ============================================================================ // EQUIPMENT // ============================================================================ exports.createEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").createEquipment(req, res); })); exports.updateEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").updateEquipment(req, res); })); exports.deleteEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").deleteEquipment(req, res); })); exports.getEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").getEquipment(req, res); })); exports.getEquipmentsByIds = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").getEquipmentsByIds(req, res); })); exports.updateEquipmentStatusOnly = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").updateEquipmentStatusOnly(req, res); })); exports.updateEquipmentStatus = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").updateEquipmentStatus(req, res); })); exports.getEquipmentsPaginated = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").getEquipmentsPaginated(req, res); })); exports.quickSearch = onRequest(httpOptions, withCors((req, res) => { return require("./src/equipments").quickSearch(req, res); })); // ============================================================================ // CONTAINERS // ============================================================================ exports.createContainer = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").createContainer(req, res); })); exports.updateContainer = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").updateContainer(req, res); })); exports.deleteContainer = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").deleteContainer(req, res); })); exports.getContainersByEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").getContainersByEquipment(req, res); })); exports.getContainersByIds = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").getContainersByIds(req, res); })); exports.addEquipmentToContainer = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").addEquipmentToContainer(req, res); })); exports.removeEquipmentFromContainer = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").removeEquipmentFromContainer(req, res); })); exports.getContainersPaginated = onRequest(httpOptions, withCors((req, res) => { return require("./src/containers").getContainersPaginated(req, res); })); // ============================================================================ // EVENTS // ============================================================================ exports.createEvent = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").createEvent(req, res); })); exports.updateEvent = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").updateEvent(req, res); })); exports.deleteEvent = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").deleteEvent(req, res); })); exports.updateEventEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").updateEventEquipment(req, res); })); exports.getEventsByEventType = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").getEventsByEventType(req, res); })); exports.getEvents = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").getEvents(req, res); })); exports.getEventsByMonth = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").getEventsByMonth(req, res); })); exports.searchEvents = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").searchEvents(req, res); })); exports.getEventWithDetails = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").getEventWithDetails(req, res); })); exports.validateEquipmentLoading = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").validateEquipmentLoading(req, res); })); exports.validateAllLoading = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").validateAllLoading(req, res); })); exports.validateEquipmentUnloading = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").validateEquipmentUnloading(req, res); })); exports.validateAllUnloading = onRequest(httpOptions, withCors((req, res) => { return require("./src/events").validateAllUnloading(req, res); })); // ============================================================================ // MAINTENANCES // ============================================================================ exports.createMaintenance = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").createMaintenance(req, res); })); exports.updateMaintenance = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").updateMaintenance(req, res); })); exports.getMaintenances = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").getMaintenances(req, res); })); exports.deleteMaintenance = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").deleteMaintenance(req, res); })); exports.checkUpcomingMaintenances = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").checkUpcomingMaintenances(req, res); })); exports.completeMaintenance = onRequest(httpOptions, withCors((req, res) => { return require("./src/maintenances").completeMaintenance(req, res); })); // ============================================================================ // OPTIONS & EVENT TYPES // ============================================================================ exports.createOption = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").createOption(req, res); })); exports.updateOption = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").updateOption(req, res); })); exports.deleteOption = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").deleteOption(req, res); })); exports.getOptions = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").getOptions(req, res); })); exports.getEventTypes = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").getEventTypes(req, res); })); exports.createEventType = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").createEventType(req, res); })); exports.updateEventType = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").updateEventType(req, res); })); exports.deleteEventType = onRequest(httpOptions, withCors((req, res) => { return require("./src/options").deleteEventType(req, res); })); // ============================================================================ // USERS // ============================================================================ exports.createUser = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").createUser(req, res); })); exports.createUserWithInvite = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").createUserWithInvite(req, res); })); exports.updateUser = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").updateUser(req, res); })); exports.deleteUser = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").deleteUser(req, res); })); exports.getUsers = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").getUsers(req, res); })); exports.getUser = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").getUser(req, res); })); exports.getCurrentUser = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").getCurrentUser(req, res); })); exports.getRoles = onRequest(httpOptions, withCors((req, res) => { return require("./src/users").getRoles(req, res); })); // ============================================================================ // ALERTS // ============================================================================ exports.getAlerts = onRequest(httpOptions, withCors((req, res) => { return require("./src/alerts").getAlerts(req, res); })); exports.markAlertAsRead = onRequest(httpOptions, withCors((req, res) => { return require("./src/alerts").markAlertAsRead(req, res); })); exports.deleteAlert = onRequest(httpOptions, withCors((req, res) => { return require("./src/alerts").deleteAlert(req, res); })); // ============================================================================ // AVAILABILITY // ============================================================================ exports.checkEquipmentAvailability = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").checkEquipmentAvailability(req, res); })); exports.checkContainerAvailability = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").checkContainerAvailability(req, res); })); exports.getConflictingEquipmentIds = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").getConflictingEquipmentIds(req, res); })); exports.findAlternativeEquipment = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").findAlternativeEquipment(req, res); })); exports.calculateEquipmentStatuses = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").calculateEquipmentStatuses(req, res); })); exports.getActiveEvents = onRequest(httpOptions, withCors((req, res) => { return require("./src/availability").getActiveEvents(req, res); })); // ============================================================================ // TEXT-TO-SPEECH // ============================================================================ exports.generateTTSV2 = onRequest(ttsHttpOptions, (req, res) => { return require("./src/tts").generateTTSV2(req, res); }); // ============================================================================ // AI // ============================================================================ exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors((req, res) => { return require("./aiEquipmentProposal").handleAiEquipmentProposal(req, res); })); // ============================================================================ // CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS) // ============================================================================ exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => { return require("./sendAlertEmail").handler(request); }); exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => { return require("./createAlert").handler(request); }); exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => { return require("./processEquipmentValidation").handler(request); }); exports.rollbackEventStep = onCall({region: "europe-west9", cors: true}, (request) => { return require("./rollbackEventStep").handler(request); }); // ============================================================================ // SCHEDULED FUNCTIONS // ============================================================================ exports.sendDailyDigest = onSchedule({ schedule: "0 8 * * *", timeZone: "Europe/Paris", region: "europe-west9", retryCount: 2, memory: "512MiB", }, async (context) => { logger.info("[Scheduler] Démarrage sendDailyDigest"); try { const {sendDailyDigest} = require("./sendDailyDigest"); await sendDailyDigest(); logger.info("[Scheduler] sendDailyDigest terminé avec succès"); } catch (error) { logger.error("[Scheduler] Erreur sendDailyDigest:", error); throw error; } }); // ============================================================================ // FIRESTORE TRIGGERS // ============================================================================ exports.onEventCreated = onDocumentCreated({ document: "events/{eventId}", region: "europe-west9", }, async (event) => { logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`); try { const eventData = event.data.data(); const eventId = event.params.eventId; await db.collection("alerts").add({ type: "EVENT_CREATED", severity: "INFO", message: `Nouvel événement créé : "${eventData.name}" le ${new Date(eventData.startDate?.toDate ? eventData.startDate.toDate() : eventData.startDate).toLocaleDateString("fr-FR")}`, eventId: eventId, eventName: eventData.name, eventDate: eventData.startDate, createdAt: admin.firestore.FieldValue.serverTimestamp(), isRead: false, metadata: { eventId: eventId, eventName: eventData.name, eventDate: eventData.startDate, }, assignedTo: [], }); logger.info(`[onEventCreated] Alerte créée pour événement ${eventId}`); } catch (error) { logger.error("[onEventCreated] Erreur:", error); } }); exports.onEventUpdated = onDocumentUpdated({ document: "events/{eventId}", region: "europe-west9", }, async (event) => { const before = event.data.before.data(); const after = event.data.after.data(); const eventId = event.params.eventId; try { const workforceBefore = before.workforce || []; const workforceAfter = after.workforce || []; const newMembers = workforceAfter.filter((afterMember) => { return !workforceBefore.some((beforeMember) => beforeMember.userId === afterMember.userId, ); }); if (newMembers.length > 0) { logger.info(`[onEventUpdated] ${newMembers.length} nouveaux membres ajoutés à ${eventId}`); for (const member of newMembers) { await db.collection("alerts").add({ type: "WORKFORCE_ADDED", severity: "INFO", message: `Vous avez été ajouté(e) à l'événement "${after.name}" le ${new Date(after.startDate?.toDate ? after.startDate.toDate() : after.startDate).toLocaleDateString("fr-FR")}`, eventId: eventId, eventName: after.name, eventDate: after.startDate, createdAt: admin.firestore.FieldValue.serverTimestamp(), isRead: false, metadata: { eventId: eventId, eventName: after.name, eventDate: after.startDate, }, assignedTo: [member.userId], }); logger.info(`[onEventUpdated] Alerte créée pour ${member.userId}`); } } } catch (error) { logger.error("[onEventUpdated] Erreur:", error); } }); exports.onAlertCreated = onDocumentCreated({ document: "alerts/{alertId}", region: "europe-west9", }, async (event) => { const alertId = event.params.alertId; const alertData = event.data.data(); logger.info(`[onAlertCreated] Nouvelle alerte: ${alertId} (${alertData.severity})`); try { if (alertData.severity === "CRITICAL" && !alertData.emailSent) { const userIds = alertData.assignedTo || []; if (userIds.length > 0) { logger.info(`[onAlertCreated] Envoi email immédiat à ${userIds.length} utilisateurs`); await db.collection("alerts").doc(alertId).update({ pendingEmailSend: true, }); logger.info(`[onAlertCreated] Alerte marquée pour envoi email`); } } } catch (error) { logger.error("[onAlertCreated] Erreur:", error); } }); exports.onEventReturnCompleted = onDocumentUpdated({ document: "events/{eventId}", region: "europe-west9", }, async (event) => { const before = event.data.before.data(); const after = event.data.after.data(); const eventId = event.params.eventId; try { const beforeReturnStatus = (before.returnStatus || "").toString().toUpperCase(); const afterReturnStatus = (after.returnStatus || "").toString().toUpperCase(); if (afterReturnStatus === "COMPLETED" && beforeReturnStatus !== "COMPLETED") { logger.info(`[onEventReturnCompleted] Event ${eventId} returnStatus completed. Resetting assigned equipments...`); const eventRef = db.collection("events").doc(eventId); await db.runTransaction(async (transaction) => { const currentEventDoc = await transaction.get(eventRef); if (!currentEventDoc.exists) { logger.warn(`[onEventReturnCompleted] Event doc ${eventId} not found during transaction.`); return; } const currentEventData = currentEventDoc.data(); if (currentEventData.stocksRestored === true) { logger.info(`[onEventReturnCompleted] Stocks already restored for event ${eventId}, skipping.`); return; } const assignedEquipment = currentEventData.assignedEquipment || []; if (assignedEquipment.length === 0) { logger.info(`[onEventReturnCompleted] No assigned equipment for event ${eventId}.`); transaction.update(eventRef, { stocksRestored: true, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); return; } // Fetch all unique equipment docs in the transaction const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean))); const equipmentDocsMap = {}; for (const eqId of equipmentIds) { const eqRef = db.collection("equipments").doc(eqId); const eqDoc = await transaction.get(eqRef); if (eqDoc.exists) { equipmentDocsMap[eqId] = eqDoc.data(); } } // Update equipment statuses and quantities for (const eq of assignedEquipment) { const eqId = eq.equipmentId; const equipmentData = equipmentDocsMap[eqId]; if (!equipmentData) continue; const hasQuantity = equipmentData.hasQuantity === true || equipmentData.category === "CABLE" || equipmentData.category === "CONSUMABLE"; const eqRef = db.collection("equipments").doc(eqId); if (!hasQuantity) { // Non-consumable: reset to AVAILABLE transaction.update(eqRef, { status: "AVAILABLE", updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); logger.info(`[onEventReturnCompleted] Set status to AVAILABLE for equipment ${eqId}`); } else if (hasQuantity && eq.quantityAtReturn !== undefined && eq.quantityAtReturn !== null) { // Consumable: increment availableQuantity const currentAvailable = Number(equipmentData.availableQuantity) || 0; const returnedQty = Number(eq.quantityAtReturn) || 0; transaction.update(eqRef, { availableQuantity: currentAvailable + returnedQty, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); logger.info(`[onEventReturnCompleted] Restored ${returnedQty} items for consumable ${eqId} (new available: ${currentAvailable + returnedQty})`); } } // Mark event as stocksRestored transaction.update(eventRef, { stocksRestored: true, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); }); logger.info(`[onEventReturnCompleted] Transaction completed successfully for event ${eventId}`); } } catch (error) { logger.error(`[onEventReturnCompleted] Error resetting equipment statuses for event ${eventId}:`, error); } }); // ============================================================================ // SEARCH - Recherche unifiée avec autocomplétion // ============================================================================ /** * Recherche rapide d'équipements et containers pour l'autocomplétion * Retourne un nombre limité de résultats pour des performances optimales */ exports.quickSearch = 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 view_equipment permission' }); return; } const params = req.method === 'GET' ? req.query : (req.body?.data || {}); const searchQuery = params.query?.toLowerCase() || ''; const limit = Math.min(parseInt(params.limit) || 10, 50); const includeEquipments = params.includeEquipments !== 'false'; const includeContainers = params.includeContainers !== 'false'; if (!searchQuery || searchQuery.length < 2) { res.status(200).json({ results: [] }); return; } const results = []; // Rechercher dans les équipements if (includeEquipments) { const equipmentSnapshot = await db.collection('equipments') .orderBy('id') .limit(limit * 2) // Récupérer plus pour filtrer ensuite .get(); equipmentSnapshot.docs.forEach(doc => { const data = doc.data(); const searchableText = [ data.name || '', doc.id || '', data.model || '', data.brand || '' ].join(' ').toLowerCase(); if (searchableText.includes(searchQuery)) { results.push({ type: 'equipment', id: doc.id, name: data.name, category: data.category, model: data.model, brand: data.brand }); } }); } // Rechercher dans les containers if (includeContainers) { const containerSnapshot = await db.collection('containers') .orderBy('id') .limit(limit * 2) .get(); containerSnapshot.docs.forEach(doc => { const data = doc.data(); const searchableText = [ data.name || '', doc.id || '' ].join(' ').toLowerCase(); if (searchableText.includes(searchQuery)) { results.push({ type: 'container', id: doc.id, name: data.name, containerType: data.type }); } }); } // Limiter et trier les résultats const limitedResults = results .sort((a, b) => { // Prioriser les correspondances exactes au début const aStarts = a.id.toLowerCase().startsWith(searchQuery); const bStarts = b.id.toLowerCase().startsWith(searchQuery); if (aStarts && !bStarts) return -1; if (!aStarts && bStarts) return 1; return 0; }) .slice(0, limit); res.status(200).json({ results: limitedResults }); } catch (error) { logger.error("Error in quick search:", error); res.status(500).json({ error: error.message }); } })); // ============================================================================ // GOOGLE MAPS & TRAVEL (Proxies CORS + Calcul d'itinéraires) // ============================================================================ const travel = require('./src/travel'); exports.googleMapsAutocomplete = onRequest(httpOptions, withCors(travel.googleMapsAutocomplete)); exports.googleMapsComputeRoute = onRequest(httpOptions, withCors(travel.googleMapsComputeRoute));