From ea1e1335e3318245556651e8b059a90e342c60f9 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 26 May 2026 15:35:48 +0200 Subject: [PATCH] feat: implement comprehensive Firebase Functions backend for equipment management and migrate core repository services --- em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache | 26 +- em2rp/CHANGELOG.md | 3 + em2rp/functions/.eslintrc.js | 10 +- em2rp/functions/aiEquipmentProposal.js | 997 ++-- em2rp/functions/createAlert.js | 66 +- em2rp/functions/generateTTS.js | 48 +- em2rp/functions/index.js | 4609 +---------------- em2rp/functions/migrate_email_prefs.js | 36 +- em2rp/functions/migrate_equipment_ids.js | 53 +- em2rp/functions/processEquipmentValidation.js | 188 +- em2rp/functions/sendAlertEmail.js | 120 +- em2rp/functions/sendDailyDigest.js | 90 +- em2rp/functions/src/alerts.js | 72 + em2rp/functions/src/availability.js | 628 +++ em2rp/functions/src/containers.js | 504 ++ em2rp/functions/src/equipments.js | 668 +++ em2rp/functions/src/events.js | 1082 ++++ em2rp/functions/src/maintenances.js | 328 ++ em2rp/functions/src/options.js | 263 + em2rp/functions/src/storage.js | 33 + em2rp/functions/src/tts.js | 58 + em2rp/functions/src/users.js | 336 ++ em2rp/functions/utils/auth.js | 40 +- em2rp/functions/utils/emailConfig.js | 16 +- em2rp/functions/utils/emailTemplates.js | 100 +- em2rp/functions/utils/helpers.js | 52 +- em2rp/lib/config/app_version.dart | 2 +- em2rp/lib/config/env.dart | 7 +- em2rp/lib/repositories/alert_repository.dart | 38 + .../repositories/container_repository.dart | 128 + .../repositories/equipment_repository.dart | 350 ++ em2rp/lib/repositories/event_repository.dart | 179 + em2rp/lib/repositories/option_repository.dart | 109 + em2rp/lib/repositories/user_repository.dart | 99 + em2rp/lib/services/data_service.dart | 1066 +--- .../services/event_availability_service.dart | 45 +- em2rp/web/version.json | 6 +- 37 files changed, 6315 insertions(+), 6140 deletions(-) create mode 100644 em2rp/functions/src/alerts.js create mode 100644 em2rp/functions/src/availability.js create mode 100644 em2rp/functions/src/containers.js create mode 100644 em2rp/functions/src/equipments.js create mode 100644 em2rp/functions/src/events.js create mode 100644 em2rp/functions/src/maintenances.js create mode 100644 em2rp/functions/src/options.js create mode 100644 em2rp/functions/src/storage.js create mode 100644 em2rp/functions/src/tts.js create mode 100644 em2rp/functions/src/users.js create mode 100644 em2rp/lib/repositories/alert_repository.dart create mode 100644 em2rp/lib/repositories/container_repository.dart create mode 100644 em2rp/lib/repositories/equipment_repository.dart create mode 100644 em2rp/lib/repositories/event_repository.dart create mode 100644 em2rp/lib/repositories/option_repository.dart create mode 100644 em2rp/lib/repositories/user_repository.dart diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index bfb4e9c..57bf586 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42 -index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683 -flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae -assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 -assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 -assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 -assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6 -assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e -main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3 +version.json,1779800968600,00f600f01984c1e371af870e40f78fd44ba53d05596f2f92f9b4fc56a85f52b6 +index.html,1779800974065,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1779801065196,879f42a05578f24ea45dc23326fdda6246d38dc59de0824ef8d4edfa4715e571 +flutter_bootstrap.js,1779800974054,977a20d5caac8da21af648cae8fa7dba00a5cd959fd2fafa7ef538a012fe87c3 +assets/FontManifest.json,1779801061892,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.json,1779801061891,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 +assets/AssetManifest.bin.json,1779801061892,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 +assets/AssetManifest.bin,1779801061891,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779801064441,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1779801062097,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1779801064446,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6 +assets/NOTICES,1779801061893,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e +main.dart.js,1779801060964,e87fb4dfca93c3384b5cb63d627186e48b0c15d78ed63bc7f5e61544ef292dd9 diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index faee71a..d7d02ef 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,6 +2,9 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 26/05/2026 +Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements. + ## 25/05/2026 Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement. diff --git a/em2rp/functions/.eslintrc.js b/em2rp/functions/.eslintrc.js index f4cb76c..dd2ef8c 100644 --- a/em2rp/functions/.eslintrc.js +++ b/em2rp/functions/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { node: true, }, parserOptions: { - "ecmaVersion": 2018, + "ecmaVersion": 2020, }, extends: [ "eslint:recommended", @@ -14,6 +14,14 @@ module.exports = { "no-restricted-globals": ["error", "name", "length"], "prefer-arrow-callback": "error", "quotes": ["error", "double", {"allowTemplateLiterals": true}], + "max-len": "off", + "valid-jsdoc": "off", + "require-jsdoc": "off", + "guard-for-in": "off", + "no-unused-vars": "warn", + "brace-style": "off", + "object-curly-spacing": "off", + "arrow-parens": "off", }, overrides: [ { diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js index b6c068c..7bf2e66 100644 --- a/em2rp/functions/aiEquipmentProposal.js +++ b/em2rp/functions/aiEquipmentProposal.js @@ -1,23 +1,23 @@ -/** +/** * Cloud Function : Assistant IA Logisticien - * Utilise Gemini avec function calling côté serveur. - * Les tools accèdent directement à Firestore via Admin SDK. - * L'authentification Firebase est requise (pas de clé API côté client). + * Utilise Gemini avec function calling cote serveur. + * Les tools accedent directement a Firestore via Admin SDK. + * L'authentification Firebase est requise (pas de cle API cote client). */ -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const admin = require('firebase-admin'); -const logger = require('firebase-functions/logger'); +const {GoogleGenerativeAI} = require("@google/generative-ai"); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); -const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de version, celle ci existe -const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || ''; +const GEMINI_MODEL = "gemini-3.1-flash-lite"; // Ne pas changer de version, celle ci existe +const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || ""; // Log de démarrage pour debug if (GEMINI_API_KEY) { - const maskedKey = GEMINI_API_KEY.substring(0, 8) + '...' + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5); + const maskedKey = GEMINI_API_KEY.substring(0, 8) + "..." + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5); logger.info(`[AI] GEMINI_API_KEY chargée (masquée: ${maskedKey})`); } else { - logger.warn('[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d\'environnement Firebase'); + logger.warn("[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d'environnement Firebase"); } const MAX_TOOL_ITERATIONS = 20; const PAST_EVENTS_LIMIT = 5; @@ -37,157 +37,157 @@ const AI_TOOLS = [ { functionDeclarations: [ { - name: 'search_equipment', - description: 'Recherche du materiel par mot-cle dans la base de données.', + name: "search_equipment", + description: "Recherche du materiel par mot-cle dans la base de données.", parameters: { - type: 'object', + type: "object", properties: { query: { - type: 'string', - description: 'Texte de recherche (nom, catégorie, marque, modèle).', + type: "string", + description: "Texte de recherche (nom, catégorie, marque, modèle).", }, }, - required: ['query'], + required: ["query"], }, }, { - name: 'check_availability', - description: 'Vérifie si un équipement est disponible pour une période donnée.', + name: "check_availability", + description: "Vérifie si un équipement est disponible pour une période donnée.", parameters: { - type: 'object', + type: "object", properties: { equipmentId: { - type: 'string', - description: 'ID exact de l\'équipement à vérifier.', + type: "string", + description: "ID exact de l'équipement à vérifier.", }, startDate: { - type: 'string', - description: 'Date de début ISO-8601. Exemple: 2026-03-20T08:00:00.000Z', + type: "string", + description: "Date de début ISO-8601. Exemple: 2026-03-20T08:00:00.000Z", }, endDate: { - type: 'string', - description: 'Date de fin ISO-8601. Exemple: 2026-03-21T23:00:00.000Z', + type: "string", + description: "Date de fin ISO-8601. Exemple: 2026-03-21T23:00:00.000Z", }, }, - required: ['equipmentId', 'startDate', 'endDate'], + required: ["equipmentId", "startDate", "endDate"], }, }, { - name: 'check_availability_batch', - description: 'Vérifie la disponibilité d une liste d équipements pour la meme période en un seul appel.', + name: "check_availability_batch", + description: "Vérifie la disponibilité d une liste d équipements pour la meme période en un seul appel.", parameters: { - type: 'object', + type: "object", properties: { equipmentIds: { - type: 'array', - items: { type: 'string' }, - description: 'Liste des IDs à vérifier (max 50).', + type: "array", + items: {type: "string"}, + description: "Liste des IDs à vérifier (max 50).", }, startDate: { - type: 'string', - description: 'Date de début ISO-8601.', + type: "string", + description: "Date de début ISO-8601.", }, endDate: { - type: 'string', - description: 'Date de fin ISO-8601.', + type: "string", + description: "Date de fin ISO-8601.", }, }, - required: ['equipmentIds', 'startDate', 'endDate'], + required: ["equipmentIds", "startDate", "endDate"], }, }, { - name: 'get_past_events', + name: "get_past_events", description: `Retourne les ${PAST_EVENTS_LIMIT} événements passés les plus récents similaires, avec leur liste de matériel, pour s'en inspirer.`, parameters: { - type: 'object', + type: "object", properties: { eventTypeId: { - type: 'string', - description: 'ID du type d\'événement. Optionnel.', + type: "string", + description: "ID du type d'événement. Optionnel.", nullable: true, }, }, }, }, { - name: 'search_event_reference', - description: 'Recherche un evenement precise par nom (et optionnellement date) pour reutiliser son materiel et ses flight cases.', + name: "search_event_reference", + description: "Recherche un evenement precise par nom (et optionnellement date) pour reutiliser son materiel et ses flight cases.", parameters: { - type: 'object', + type: "object", properties: { query: { - type: 'string', - description: 'Texte du nom ou extrait du nom de l evenement recherche.', + type: "string", + description: "Texte du nom ou extrait du nom de l evenement recherche.", }, dateHint: { - type: 'string', - description: 'Date cible ISO-8601 ou YYYY-MM-DD si connue.', + type: "string", + description: "Date cible ISO-8601 ou YYYY-MM-DD si connue.", nullable: true, }, }, - required: ['query'], + required: ["query"], }, }, { - name: 'search_containers', - description: 'Recherche des flight cases / containers contenant certains equipements. Toujours appeler avant de proposer du materiel individuellement afin de privilegier les flight cases.', + name: "search_containers", + description: "Recherche des flight cases / containers contenant certains equipements. Toujours appeler avant de proposer du materiel individuellement afin de privilegier les flight cases.", parameters: { - type: 'object', + type: "object", properties: { equipmentIds: { - type: 'array', - items: { type: 'string' }, - description: 'Liste des IDs d equipements pour lesquels chercher des flight cases.', + type: "array", + items: {type: "string"}, + description: "Liste des IDs d equipements pour lesquels chercher des flight cases.", }, query: { - type: 'string', - description: 'Recherche textuelle optionnelle dans le nom du container.', + type: "string", + description: "Recherche textuelle optionnelle dans le nom du container.", nullable: true, }, }, - required: ['equipmentIds'], + required: ["equipmentIds"], }, }, { - name: 'check_container_availability', - description: 'Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.', + name: "check_container_availability", + description: "Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.", parameters: { - type: 'object', + type: "object", properties: { - containerId: { type: 'string', description: 'ID exact du container à vérifier.' }, - startDate: { type: 'string', description: 'Date de début ISO-8601.' }, - endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + containerId: {type: "string", description: "ID exact du container à vérifier."}, + startDate: {type: "string", description: "Date de début ISO-8601."}, + endDate: {type: "string", description: "Date de fin ISO-8601."}, }, - required: ['containerId', 'startDate', 'endDate'], + required: ["containerId", "startDate", "endDate"], }, }, { - name: 'check_container_availability_batch', - description: 'Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.', + name: "check_container_availability_batch", + description: "Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.", parameters: { - type: 'object', + type: "object", properties: { - containerIds: { type: 'array', items: { type: 'string' }, description: 'Liste des IDs de containers (max 50).' }, - startDate: { type: 'string', description: 'Date de début ISO-8601.' }, - endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + containerIds: {type: "array", items: {type: "string"}, description: "Liste des IDs de containers (max 50)."}, + startDate: {type: "string", description: "Date de début ISO-8601."}, + endDate: {type: "string", description: "Date de fin ISO-8601."}, }, - required: ['containerIds', 'startDate', 'endDate'], + required: ["containerIds", "startDate", "endDate"], }, }, { - name: 'list_equipment_by_category', - description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.', + name: "list_equipment_by_category", + description: "Liste le materiel d'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d'une faute de frappe ou pour explorer les alternatives.", parameters: { - type: 'object', + type: "object", properties: { category: { - type: 'string', - description: 'Nom de la categorie.', + type: "string", + description: "Nom de la categorie.", nullable: true, }, subCategory: { - type: 'string', - description: 'Nom de la sous-categorie.', + type: "string", + description: "Nom de la sous-categorie.", nullable: true, }, }, @@ -257,35 +257,35 @@ Reponse finale : du JSON valide strict, sans markdown, avec ce format exact : */ async function toolSearchEquipment(query) { if (!query || query.trim().length < 2) { - return { query, count: 0, results: [] }; + return {query, count: 0, results: []}; } // Normalisation pour comparer proprement const normalize = (str) => - (str || '') - .toString() - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9\s-]/g, '') - .trim(); + (str || "") + .toString() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9\s-]/g, "") + .trim(); const normalizedQuery = normalize(query); const tokens = extractQueryTokens(query); // Charger un snapshot raisonnable une seule fois - const snapshot = await getDb().collection('equipments').limit(500).get(); + const snapshot = await getDb().collection("equipments").limit(500).get(); const docs = snapshot.docs.map((doc) => { const data = doc.data(); return { id: doc.id, name: String(data.name || doc.id), - category: data.category || '', - subCategory: data.subCategory || '', + category: data.category || "", + subCategory: data.subCategory || "", brand: data.brand || null, model: data.model || null, - status: data.status || '', + status: data.status || "", availableQuantity: data.availableQuantity ?? null, totalQuantity: data.totalQuantity ?? null, }; @@ -295,11 +295,11 @@ async function toolSearchEquipment(query) { // PASS 1 : recherche stricte / exacte (id, nom complet, modele) for (const item of docs) { - const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + const candText = normalize([item.id, item.name, item.brand, item.model].join(" ")); if ( - candText === normalizedQuery - || candText.includes(normalizedQuery) - || normalizedQuery.includes(candText) + candText === normalizedQuery || + candText.includes(normalizedQuery) || + normalizedQuery.includes(candText) ) { results.push(item); } @@ -308,7 +308,7 @@ async function toolSearchEquipment(query) { // PASS 2 : recherche par tokens (mots-clés, marque, modèle partiel) if (results.length === 0) { for (const item of docs) { - const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + const candText = normalize([item.id, item.name, item.brand, item.model].join(" ")); const tokenMatch = tokens.some((t) => candText.includes(t)); if (tokenMatch) results.push(item); } @@ -338,7 +338,7 @@ async function toolSearchEquipment(query) { // essayer un fuzzy match dans la categorie const normQuery = normalizedQuery; for (const itm of catRes.results) { - const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(' ')); + const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(" ")); if (cand.includes(normQuery) || tokens.some((t) => cand.includes(t))) { results.push(itm); } @@ -365,22 +365,22 @@ async function toolSearchEquipment(query) { */ async function toolCheckAvailability(equipmentId, startDate, endDate, excludeEventId, sharedContext) { if (!equipmentId || !startDate || !endDate) { - return { error: 'equipmentId, startDate et endDate sont requis.' }; + return {error: "equipmentId, startDate et endDate sont requis."}; } const start = new Date(startDate); const end = new Date(endDate); if (isNaN(start.getTime()) || isNaN(end.getTime())) { - return { error: 'Dates invalides.' }; + return {error: "Dates invalides."}; } const batchResult = await toolCheckAvailabilityBatch( - [equipmentId], - startDate, - endDate, - excludeEventId, - sharedContext, + [equipmentId], + startDate, + endDate, + excludeEventId, + sharedContext, ); if (!batchResult || !Array.isArray(batchResult.results) || batchResult.results.length === 0) { @@ -406,18 +406,18 @@ async function loadAvailabilityCandidates(start, sharedContext) { let candidateDocs = []; try { - const filteredSnapshot = await getDb().collection('events') - .where('EndDateTime', '>=', start) - .orderBy('EndDateTime', 'asc') - .limit(AVAILABILITY_EVENTS_SCAN_LIMIT) - .get(); + const filteredSnapshot = await getDb().collection("events") + .where("EndDateTime", ">=", start) + .orderBy("EndDateTime", "asc") + .limit(AVAILABILITY_EVENTS_SCAN_LIMIT) + .get(); candidateDocs = filteredSnapshot.docs; } catch (error) { - logger.warn('[AI] Availability optimized query failed, fallback to full scan', { - message: error?.message || 'unknown', + logger.warn("[AI] Availability optimized query failed, fallback to full scan", { + message: error?.message || "unknown", }); - const fallbackSnapshot = await getDb().collection('events').get(); + const fallbackSnapshot = await getDb().collection("events").get(); candidateDocs = fallbackSnapshot.docs; } @@ -454,12 +454,12 @@ function buildAvailabilityResultForEquipment({ continue; } - // Vérifier si l'équipement est assigné à cet événement - const assignedEquipment = Array.isArray(event.assignedEquipment) - ? event.assignedEquipment - : []; + // Verifier si l'equipement est assigne a cet evenement + const assignedEquipment = Array.isArray(event.assignedEquipment) ? + event.assignedEquipment : + []; const isDirectlyAssigned = assignedEquipment.some( - (eq) => String(eq.equipmentId || '') === String(equipmentId), + (eq) => String(eq.equipmentId || "") === String(equipmentId), ); if (!isDirectlyAssigned) { @@ -478,7 +478,7 @@ function buildAvailabilityResultForEquipment({ conflicts.push({ eventId: eventDoc.id, - eventName: event.name || event.Name || 'Événement sans nom', + eventName: event.name || event.Name || "Événement sans nom", overlapDays: Math.max(overlapDays, 1), }); } @@ -522,7 +522,7 @@ function buildAvailabilityResultForContainer({ if (isAssigned) { conflicts.push({ eventId: eventDoc.id, - eventName: event.name || event.Name || 'Événement sans nom', + eventName: event.name || event.Name || "Événement sans nom", }); } } @@ -540,17 +540,17 @@ function buildAvailabilityResultForContainer({ */ async function toolCheckContainerAvailability(containerId, startDate, endDate, excludeEventId, sharedContext) { if (!containerId || !startDate || !endDate) { - return { error: 'containerId, startDate et endDate sont requis.' }; + return {error: "containerId, startDate et endDate sont requis."}; } const start = new Date(startDate); const end = new Date(endDate); - if (isNaN(start.getTime()) || isNaN(end.getTime())) return { error: 'Dates invalides.' }; + if (isNaN(start.getTime()) || isNaN(end.getTime())) return {error: "Dates invalides."}; // Charger le container - const containerDocRef = await getDb().collection('containers').doc(containerId).get(); + const containerDocRef = await getDb().collection("containers").doc(containerId).get(); if (!containerDocRef.exists) { - return { containerId, error: 'Container introuvable.' }; + return {containerId, error: "Container introuvable."}; } const containerData = containerDocRef.data() || {}; const equipmentIds = Array.isArray(containerData.equipmentIds) ? containerData.equipmentIds.map((id) => String(id)) : []; @@ -591,11 +591,11 @@ async function toolCheckContainerAvailability(containerId, startDate, endDate, e */ async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endDate, excludeEventId, sharedContext) { if (!Array.isArray(containerIds) || containerIds.length === 0 || !startDate || !endDate) { - return { error: 'containerIds (array), startDate et endDate sont requis.' }; + return {error: "containerIds (array), startDate et endDate sont requis."}; } - const normalized = Array.from(new Set(containerIds.map((c) => String(c || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); - if (normalized.length === 0) return { error: 'Aucun containerId valide.' }; + const normalized = Array.from(new Set(containerIds.map((c) => String(c || "").trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); + if (normalized.length === 0) return {error: "Aucun containerId valide."}; const results = []; for (const cid of normalized) { @@ -603,11 +603,11 @@ async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endD const r = await toolCheckContainerAvailability(cid, startDate, endDate, excludeEventId, sharedContext); results.push(r); } catch (e) { - results.push({ containerId: cid, error: e?.message || 'Erreur interne' }); + results.push({containerId: cid, error: e?.message || "Erreur interne"}); } } - return { startDate, endDate, count: results.length, results }; + return {startDate, endDate, count: results.length, results}; } /** @@ -615,24 +615,24 @@ async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endD */ async function toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext) { if (!Array.isArray(equipmentIds) || equipmentIds.length === 0 || !startDate || !endDate) { - return { error: 'equipmentIds (array), startDate et endDate sont requis.' }; + return {error: "equipmentIds (array), startDate et endDate sont requis."}; } const start = new Date(startDate); const end = new Date(endDate); if (isNaN(start.getTime()) || isNaN(end.getTime())) { - return { error: 'Dates invalides.' }; + return {error: "Dates invalides."}; } const normalizedIds = Array.from(new Set( - equipmentIds - .map((id) => String(id || '').trim()) - .filter((id) => id.length > 0), + equipmentIds + .map((id) => String(id || "").trim()) + .filter((id) => id.length > 0), )).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); if (normalizedIds.length === 0) { - return { error: 'Aucun equipmentId valide.' }; + return {error: "Aucun equipmentId valide."}; } const candidateDocs = await loadAvailabilityCandidates(start, sharedContext); @@ -658,45 +658,45 @@ async function toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excl */ async function toolGetPastEvents(eventTypeId) { const now = new Date(); - let query = getDb().collection('events').orderBy('StartDateTime', 'desc').limit(50); + let query = getDb().collection("events").orderBy("StartDateTime", "desc").limit(50); if (eventTypeId) { - query = getDb().collection('events') - .where('eventTypeId', '==', eventTypeId) - .orderBy('StartDateTime', 'desc') - .limit(50); + query = getDb().collection("events") + .where("eventTypeId", "==", eventTypeId) + .orderBy("StartDateTime", "desc") + .limit(50); } const snapshot = await query.get(); const pastEvents = snapshot.docs - .map((doc) => { - const data = doc.data(); - const startDate = data.StartDateTime?.toDate - ? data.StartDateTime.toDate() - : new Date(data.StartDateTime); - return { doc, data, startDate }; - }) - .filter(({ startDate }) => !isNaN(startDate.getTime()) && startDate < now) - .slice(0, PAST_EVENTS_LIMIT) - .map(({ doc, data }) => { - const assignedEquipment = data.assignedEquipment || []; - return { - id: doc.id, - name: data.name || data.Name || 'Sans nom', - startDate: data.StartDateTime?.toDate - ? data.StartDateTime.toDate().toISOString() - : data.StartDateTime, - endDate: data.EndDateTime?.toDate - ? data.EndDateTime.toDate().toISOString() - : data.EndDateTime, - assignedEquipment: assignedEquipment.map((eq) => ({ - equipmentId: eq.equipmentId, - quantity: eq.quantity || 1, - })), - assignedEquipmentCount: assignedEquipment.length, - }; - }); + .map((doc) => { + const data = doc.data(); + const startDate = data.StartDateTime?.toDate ? + data.StartDateTime.toDate() : + new Date(data.StartDateTime); + return {doc, data, startDate}; + }) + .filter(({startDate}) => !isNaN(startDate.getTime()) && startDate < now) + .slice(0, PAST_EVENTS_LIMIT) + .map(({doc, data}) => { + const assignedEquipment = data.assignedEquipment || []; + return { + id: doc.id, + name: data.name || data.Name || "Sans nom", + startDate: data.StartDateTime?.toDate ? + data.StartDateTime.toDate().toISOString() : + data.StartDateTime, + endDate: data.EndDateTime?.toDate ? + data.EndDateTime.toDate().toISOString() : + data.EndDateTime, + assignedEquipment: assignedEquipment.map((eq) => ({ + equipmentId: eq.equipmentId, + quantity: eq.quantity || 1, + })), + assignedEquipmentCount: assignedEquipment.length, + }; + }); return { count: pastEvents.length, @@ -706,7 +706,7 @@ async function toolGetPastEvents(eventTypeId) { function toDateSafe(value) { if (!value) return null; - if (value.toDate && typeof value.toDate === 'function') { + if (value.toDate && typeof value.toDate === "function") { return value.toDate(); } const parsed = new Date(value); @@ -714,19 +714,19 @@ function toDateSafe(value) { } function isCancelledStatus(status) { - const normalized = (status || '').toString().trim().toUpperCase(); - return normalized === 'CANCELLED' || normalized === 'CANCELED'; + const normalized = (status || "").toString().trim().toUpperCase(); + return normalized === "CANCELLED" || normalized === "CANCELED"; } function normalizeSearchText(value) { - return (value || '') - .toString() - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9\s-]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + return (value || "") + .toString() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9\s-]/g, " ") + .replace(/\s+/g, " ") + .trim(); } /** @@ -737,8 +737,8 @@ function userAllowsContainers(userMessage) { if (!userMessage || userMessage.toString().trim().length === 0) return true; const txt = userMessage.toString().toLowerCase(); const negativePatterns = [ - 'pas de container', 'sans container', 'pas de flight', 'sans flight', 'pas de flight case', 'sans flight case', - 'ne pas utiliser', 'ne pas prendre', 'do not use', 'no container', 'no flight', 'no flight case' + "pas de container", "sans container", "pas de flight", "sans flight", "pas de flight case", "sans flight case", + "ne pas utiliser", "ne pas prendre", "do not use", "no container", "no flight", "no flight case", ]; return !negativePatterns.some((pat) => txt.includes(pat)); } @@ -798,17 +798,17 @@ function dateToYmd(dateValue) { function dateToYmdInTimezone(dateValue, timeZone) { if (!dateValue) return null; - const formatter = new Intl.DateTimeFormat('en-CA', { + const formatter = new Intl.DateTimeFormat("en-CA", { timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', + year: "numeric", + month: "2-digit", + day: "2-digit", }); const parts = formatter.formatToParts(dateValue); - const year = parts.find((part) => part.type === 'year')?.value; - const month = parts.find((part) => part.type === 'month')?.value; - const day = parts.find((part) => part.type === 'day')?.value; + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; if (!year || !month || !day) { return null; @@ -822,12 +822,12 @@ function parseYmdToUtcDate(ymd) { return null; } - const [year, month, day] = ymd.split('-').map((value) => parseInt(value, 10)); + const [year, month, day] = ymd.split("-").map((value) => parseInt(value, 10)); return new Date(Date.UTC(year, month - 1, day)); } function buildToolCacheKey(name, args) { - const safeArgs = args && typeof args === 'object' ? args : {}; + const safeArgs = args && typeof args === "object" ? args : {}; return `${name}:${JSON.stringify(safeArgs)}`; } @@ -837,7 +837,7 @@ function isDateMatchingHint(eventDate, hintedYmd) { } const eventUtcYmd = dateToYmd(eventDate); - const eventParisYmd = dateToYmdInTimezone(eventDate, 'Europe/Paris'); + const eventParisYmd = dateToYmdInTimezone(eventDate, "Europe/Paris"); if (eventUtcYmd === hintedYmd || eventParisYmd === hintedYmd) { return true; @@ -852,33 +852,33 @@ function isDateMatchingHint(eventDate, hintedYmd) { const eventDateParis = parseYmdToUtcDate(eventParisYmd); const oneDayInMs = 24 * 60 * 60 * 1000; - const utcDelta = eventDateUtc - ? Math.abs(eventDateUtc.getTime() - hintedDate.getTime()) - : Number.MAX_SAFE_INTEGER; - const parisDelta = eventDateParis - ? Math.abs(eventDateParis.getTime() - hintedDate.getTime()) - : Number.MAX_SAFE_INTEGER; + const utcDelta = eventDateUtc ? + Math.abs(eventDateUtc.getTime() - hintedDate.getTime()) : + Number.MAX_SAFE_INTEGER; + const parisDelta = eventDateParis ? + Math.abs(eventDateParis.getTime() - hintedDate.getTime()) : + Number.MAX_SAFE_INTEGER; return Math.min(utcDelta, parisDelta) <= oneDayInMs; } function extractQueryTokens(rawQuery) { const monthWords = new Set([ - 'janvier', 'fevrier', 'fevr', 'mars', 'avril', 'avr', 'mai', 'juin', - 'juillet', 'juil', 'aout', 'septembre', 'sept', 'octobre', 'oct', - 'novembre', 'nov', 'decembre', 'dec', + "janvier", "fevrier", "fevr", "mars", "avril", "avr", "mai", "juin", + "juillet", "juil", "aout", "septembre", "sept", "octobre", "oct", + "novembre", "nov", "decembre", "dec", ]); const stopWords = new Set([ - 'de', 'du', 'des', 'le', 'la', 'les', 'un', 'une', 'en', 'date', 'sur', - 'pour', 'avec', 'et', 'a', 'au', 'aux', + "de", "du", "des", "le", "la", "les", "un", "une", "en", "date", "sur", + "pour", "avec", "et", "a", "au", "aux", ]); return normalizeSearchText(rawQuery) - .split(' ') - .filter((token) => token.length >= 2) - .filter((token) => !/^\d+$/.test(token)) - .filter((token) => !monthWords.has(token)) - .filter((token) => !stopWords.has(token)); + .split(" ") + .filter((token) => token.length >= 2) + .filter((token) => !/^\d+$/.test(token)) + .filter((token) => !monthWords.has(token)) + .filter((token) => !stopWords.has(token)); } /** @@ -886,29 +886,29 @@ function extractQueryTokens(rawQuery) { */ async function toolSearchContainers(equipmentIds, query) { if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) { - return { error: 'equipmentIds (array) est requis.' }; + return {error: "equipmentIds (array) est requis."}; } const normalizedEquipmentIds = new Set( - equipmentIds.map((id) => String(id || '').trim()).filter((id) => id.length > 0), + equipmentIds.map((id) => String(id || "").trim()).filter((id) => id.length > 0), ); if (normalizedEquipmentIds.size === 0) { - return { error: 'Aucun equipmentId valide.' }; + return {error: "Aucun equipmentId valide."}; } const normalizedQuery = query ? normalizeSearchText(query) : null; - const snapshot = await getDb().collection('containers').limit(300).get(); + const snapshot = await getDb().collection("containers").limit(300).get(); const results = []; for (const doc of snapshot.docs) { const data = doc.data(); if (isCancelledStatus(data.status)) continue; - const containerEquipmentIds = Array.isArray(data.equipmentIds) - ? data.equipmentIds.map((id) => String(id || '').trim()).filter(Boolean) - : []; + const containerEquipmentIds = Array.isArray(data.equipmentIds) ? + data.equipmentIds.map((id) => String(id || "").trim()).filter(Boolean) : + []; if (containerEquipmentIds.length === 0) continue; @@ -923,8 +923,8 @@ async function toolSearchContainers(equipmentIds, query) { results.push({ id: doc.id, name: data.name || doc.id, - type: data.type || '', - status: data.status || '', + type: data.type || "", + status: data.status || "", equipmentIds: containerEquipmentIds, totalItemCount: containerEquipmentIds.length, matchingEquipmentIds: matchingIds, @@ -954,7 +954,7 @@ async function toolSearchContainers(equipmentIds, query) { async function toolSearchEventReference(query, dateHint) { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { - return { query, count: 0, events: [] }; + return {query, count: 0, events: []}; } const hintedDate = parseDateHintFlexible(dateHint) || parseDateHintFlexible(query); @@ -966,46 +966,46 @@ async function toolSearchEventReference(query, dateHint) { // Priorite a une recherche ciblee autour de la date demandee. if (hintedDate) { const dayStart = new Date(Date.UTC( - hintedDate.getUTCFullYear(), - hintedDate.getUTCMonth(), - hintedDate.getUTCDate(), - 0, - 0, - 0, - 0, + hintedDate.getUTCFullYear(), + hintedDate.getUTCMonth(), + hintedDate.getUTCDate(), + 0, + 0, + 0, + 0, )); const dayEnd = new Date(Date.UTC( - hintedDate.getUTCFullYear(), - hintedDate.getUTCMonth(), - hintedDate.getUTCDate(), - 23, - 59, - 59, - 999, + hintedDate.getUTCFullYear(), + hintedDate.getUTCMonth(), + hintedDate.getUTCDate(), + 23, + 59, + 59, + 999, )); const rangeStart = new Date(dayStart.getTime() - (24 * 60 * 60 * 1000)); const rangeEnd = new Date(dayEnd.getTime() + (24 * 60 * 60 * 1000)); try { - const byDateSnapshot = await getDb().collection('events') - .where('StartDateTime', '>=', rangeStart) - .where('StartDateTime', '<=', rangeEnd) - .orderBy('StartDateTime', 'desc') - .limit(Math.max(EVENT_SEARCH_SCAN_LIMIT, 150)) - .get(); + const byDateSnapshot = await getDb().collection("events") + .where("StartDateTime", ">=", rangeStart) + .where("StartDateTime", "<=", rangeEnd) + .orderBy("StartDateTime", "desc") + .limit(Math.max(EVENT_SEARCH_SCAN_LIMIT, 150)) + .get(); docs = byDateSnapshot.docs; } catch (error) { - logger.warn('[AI] search_event_reference date query failed, fallback to scan', { - message: error?.message || 'unknown', + logger.warn("[AI] search_event_reference date query failed, fallback to scan", { + message: error?.message || "unknown", }); } } if (docs.length === 0) { - const snapshot = await getDb().collection('events') - .orderBy('StartDateTime', 'desc') - .limit(EVENT_SEARCH_SCAN_LIMIT) - .get(); + const snapshot = await getDb().collection("events") + .orderBy("StartDateTime", "desc") + .limit(EVENT_SEARCH_SCAN_LIMIT) + .get(); docs = snapshot.docs; } @@ -1013,21 +1013,21 @@ async function toolSearchEventReference(query, dateHint) { const data = doc.data(); const startDate = toDateSafe(data.StartDateTime); const endDate = toDateSafe(data.EndDateTime); - const eventName = (data.name || data.Name || '').toString(); - const searchableText = normalizeSearchText([eventName, doc.id, data.eventTypeId || ''].join(' ')); + const eventName = (data.name || data.Name || "").toString(); + const searchableText = normalizeSearchText([eventName, doc.id, data.eventTypeId || ""].join(" ")); const assignedEquipment = Array.isArray(data.assignedEquipment) ? data.assignedEquipment : []; const assignedContainers = Array.isArray(data.assignedContainers) ? data.assignedContainers : []; const matchedTokenCount = queryTokens.filter((token) => searchableText.includes(token)).length; - const hasTokenMatch = queryTokens.length === 0 - ? searchableText.includes(normalizedQuery) - : matchedTokenCount >= Math.min(2, queryTokens.length); + const hasTokenMatch = queryTokens.length === 0 ? + searchableText.includes(normalizedQuery) : + matchedTokenCount >= Math.min(2, queryTokens.length); const matchesDate = isDateMatchingHint(startDate, hintedYmd); const eventYmd = startDate ? dateToYmd(startDate) : null; return { id: doc.id, - name: eventName || 'Sans nom', + name: eventName || "Sans nom", startDate, endDate, eventYmd, @@ -1041,34 +1041,34 @@ async function toolSearchEventReference(query, dateHint) { }); const strictMatched = candidates - .filter((event) => event.matchesQuery && event.matchesDate); + .filter((event) => event.matchesQuery && event.matchesDate); const fallbackMatched = candidates - .filter((event) => event.matchesQuery); + .filter((event) => event.matchesQuery); const selectedCandidates = strictMatched.length > 0 ? strictMatched : fallbackMatched; const matched = selectedCandidates - .sort((a, b) => { - const aScore = (a.matchesDate ? 3 : 0) + (a.assignedEquipmentCount > 0 ? 1 : 0) + a.matchedTokenCount; - const bScore = (b.matchesDate ? 3 : 0) + (b.assignedEquipmentCount > 0 ? 1 : 0) + b.matchedTokenCount; - if (bScore !== aScore) return bScore - aScore; + .sort((a, b) => { + const aScore = (a.matchesDate ? 3 : 0) + (a.assignedEquipmentCount > 0 ? 1 : 0) + a.matchedTokenCount; + const bScore = (b.matchesDate ? 3 : 0) + (b.assignedEquipmentCount > 0 ? 1 : 0) + b.matchedTokenCount; + if (bScore !== aScore) return bScore - aScore; - const aTime = a.startDate ? a.startDate.getTime() : 0; - const bTime = b.startDate ? b.startDate.getTime() : 0; - return bTime - aTime; - }) - .slice(0, 5) - .map((event) => ({ - id: event.id, - name: event.name, - startDate: event.startDate ? event.startDate.toISOString() : null, - endDate: event.endDate ? event.endDate.toISOString() : null, - assignedEquipment: event.assignedEquipment.map((eq) => ({ - equipmentId: eq.equipmentId, - quantity: eq.quantity || 1, - })), - assignedContainers: event.assignedContainers, - assignedEquipmentCount: event.assignedEquipmentCount, - })); + const aTime = a.startDate ? a.startDate.getTime() : 0; + const bTime = b.startDate ? b.startDate.getTime() : 0; + return bTime - aTime; + }) + .slice(0, 5) + .map((event) => ({ + id: event.id, + name: event.name, + startDate: event.startDate ? event.startDate.toISOString() : null, + endDate: event.endDate ? event.endDate.toISOString() : null, + assignedEquipment: event.assignedEquipment.map((eq) => ({ + equipmentId: eq.equipmentId, + quantity: eq.quantity || 1, + })), + assignedContainers: event.assignedContainers, + assignedEquipmentCount: event.assignedEquipmentCount, + })); return { query, @@ -1083,41 +1083,41 @@ async function toolSearchEventReference(query, dateHint) { * Recherche des équipements par catégorie et sous-catégorie. */ async function toolListEquipmentByCategory(category, subCategory) { - let queryDb = getDb().collection('equipments'); + const queryDb = getDb().collection("equipments"); // Si on a des critères précis, on filtre. Sinon on scanne pour matcher flou. const snapshot = await queryDb.limit(1000).get(); const normalize = (str) => - (str || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim(); + (str || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); const normCat = category ? normalize(category) : null; const normSub = subCategory ? normalize(subCategory) : null; const results = snapshot.docs - .map((doc) => { - const data = doc.data(); - return { - id: doc.id, - name: data.name || doc.id, - category: data.category || '', - subCategory: data.subCategory || '', - brand: data.brand || null, - model: data.model || null, - status: data.status || '', - }; - }) - .filter(item => { - let match = true; - if (normCat) match = match && normalize(item.category).includes(normCat); - if (normSub) match = match && normalize(item.subCategory).includes(normSub); - return match; - }) - .slice(0, 50); + .map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: data.name || doc.id, + category: data.category || "", + subCategory: data.subCategory || "", + brand: data.brand || null, + model: data.model || null, + status: data.status || "", + }; + }) + .filter((item) => { + let match = true; + if (normCat) match = match && normalize(item.category).includes(normCat); + if (normSub) match = match && normalize(item.subCategory).includes(normSub); + return match; + }) + .slice(0, 50); return { - category: category || 'all', - subCategory: subCategory || 'all', + category: category || "all", + subCategory: subCategory || "all", count: results.length, results, }; @@ -1127,68 +1127,68 @@ async function toolListEquipmentByCategory(category, subCategory) { * Exécute un tool Gemini et retourne le résultat. */ async function executeTool(toolCall, excludeEventId, sharedContext) { - const { name, args } = toolCall; - logger.info(`[AI] Executing tool: ${name}`, { args }); + const {name, args} = toolCall; + logger.info(`[AI] Executing tool: ${name}`, {args}); try { switch (name) { - case 'search_equipment': + case "search_equipment": return await toolSearchEquipment(args.query); - case 'check_availability': + case "check_availability": return await toolCheckAvailability( - args.equipmentId, - args.startDate, - args.endDate, - excludeEventId, - sharedContext, + args.equipmentId, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, ); - case 'check_availability_batch': + case "check_availability_batch": return await toolCheckAvailabilityBatch( - args.equipmentIds, - args.startDate, - args.endDate, - excludeEventId, - sharedContext, + args.equipmentIds, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, ); - case 'get_past_events': + case "get_past_events": return await toolGetPastEvents(args.eventTypeId || null); - case 'search_event_reference': + case "search_event_reference": return await toolSearchEventReference(args.query, args.dateHint || null); - case 'search_containers': + case "search_containers": return await toolSearchContainers(args.equipmentIds, args.query || null); - case 'check_container_availability': + case "check_container_availability": return await toolCheckContainerAvailability( - args.containerId, - args.startDate, - args.endDate, - excludeEventId, - sharedContext, + args.containerId, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, ); - case 'check_container_availability_batch': + case "check_container_availability_batch": return await toolCheckContainerAvailabilityBatch( - args.containerIds, - args.startDate, - args.endDate, - excludeEventId, - sharedContext, + args.containerIds, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, ); - case 'list_equipment_by_category': + case "list_equipment_by_category": return await toolListEquipmentByCategory(args.category || null, args.subCategory || null); default: - return { error: `Tool inconnu: ${name}` }; + return {error: `Tool inconnu: ${name}`}; } } catch (err) { logger.error(`[AI] Tool error (${name}):`, err); - return { error: err.message }; + return {error: err.message}; } } @@ -1210,39 +1210,39 @@ function buildUserPrompt({ currentEquipment, workingProposal, }) { - const currentEquipmentStr = currentEquipment && currentEquipment.length > 0 - ? currentEquipment.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(', ') - : 'aucun'; - const workingProposalStr = workingProposal && workingProposal.length > 0 - ? workingProposal.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(', ') - : 'aucune'; + const currentEquipmentStr = currentEquipment && currentEquipment.length > 0 ? + currentEquipment.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(", ") : + "aucun"; + const workingProposalStr = workingProposal && workingProposal.length > 0 ? + workingProposal.map((eq) => `${eq.equipmentId} x${eq.quantity || 1}`).join(", ") : + "aucune"; const isAutoMode = !userMessage || userMessage.trim().length === 0; - const finalMessage = isAutoMode - ? 'Génère automatiquement une proposition de matériel adaptée à cet événement, en tenant compte des options et des détails fournis, et basée sur les événements similaires passés.' - : userMessage.trim(); + const finalMessage = isAutoMode ? + "Génère automatiquement une proposition de matériel adaptée à cet événement, en tenant compte des options et des détails fournis, et basée sur les événements similaires passés." : + userMessage.trim(); return [ - 'Contexte de l\'événement :', - `- Nom : ${eventName || 'non renseigné'}`, - `- Type d'événement (ID): ${eventTypeId || 'non renseigné'}`, + "Contexte de l'événement :", + `- Nom : ${eventName || "non renseigné"}`, + `- Type d'événement (ID): ${eventTypeId || "non renseigné"}`, `- Date de début : ${startDate}`, `- Date de fin : ${endDate}`, - `- Lieu : ${location || 'non renseigné'}`, - `- Notes/Description : ${notes || 'aucune'}`, - `- Options de l'événement : ${eventOptions || 'aucune'}`, - `- Matériel déjà assigné : ${currentEquipmentStr}`, - `- Proposition courante à modifier : ${workingProposalStr}`, - '', - 'Demande :', + `- Lieu : ${location || "non renseigné"}`, + `- Notes/Description : ${notes || "aucune"}`, + `- Options de l'événement : ${eventOptions || "aucune"}`, + `- Materiel deja assigne : ${currentEquipmentStr}`, + `- Proposition courante a modifier : ${workingProposalStr}`, + "", + "Demande :", finalMessage, - '', - 'Si un fichier (document/devis) a été joint à ma demande (sous forme inlineData by Gemini), tu dois absolument l analyser, en deduire le materiel demande, et l ajouter a ta proposition.', - 'Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.', - 'Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.', - 'Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.', - 'En cas d\'indisponibilité, chercher une alternative via search_equipment puis revérifier.', - ].join('\n'); + "", + "Si un fichier (document/devis) a été joint à ma demande (sous forme inlineData by Gemini), tu dois absolument l analyser, en deduire le materiel demande, et l ajouter a ta proposition.", + "Si la demande cite un evenement precis (nom/date), commence par search_event_reference avant de proposer du materiel.", + "Si une proposition courante existe, traite-la comme base de travail et renvoie toujours la liste finale complete apres modification.", + "Rappel : vérifier la disponibilité avant de recommander. Privilégier check_availability_batch pour contrôler plusieurs équipements en un appel.", + "En cas d'indisponibilité, chercher une alternative via search_equipment puis revérifier.", + ].join("\n"); } /** @@ -1250,7 +1250,7 @@ function buildUserPrompt({ */ function buildMessageParts(params, logDebug) { const textPrompt = buildUserPrompt(params); - const parts = [{ text: textPrompt }]; + const parts = [{text: textPrompt}]; if (params.document && params.document.data && params.document.mimeType) { if (logDebug) logDebug(`[AI] Attaching document ${params.document.mimeType}`); @@ -1259,7 +1259,7 @@ function buildMessageParts(params, logDebug) { inlineData: { data: params.document.data, mimeType: params.document.mimeType, - } + }, }); const fileNameInfo = params.document.fileName ? ` (nom du fichier: ${params.document.fileName})` : ``; parts.push({ @@ -1278,23 +1278,23 @@ function extractResponseText(modelResponse, logDebug, logError) { const warn = logError || logger.warn.bind(logger); if (!modelResponse) { - warn('[AI] extractResponseText: modelResponse est vide ou undefined'); - return ''; + warn("[AI] extractResponseText: modelResponse est vide ou undefined"); + return ""; } - info('[AI] extractResponseText: analyse de la réponse brut', { - hasTextFunc: typeof modelResponse.text === 'function', - candidatesCount: modelResponse.candidates?.length || 0 + info("[AI] extractResponseText: analyse de la réponse brut", { + hasTextFunc: typeof modelResponse.text === "function", + candidatesCount: modelResponse.candidates?.length || 0, }); try { const directText = modelResponse.text?.(); if (directText && directText.trim().length > 0) { - info('[AI] extractResponseText: Texte extrait via modelResponse.text()'); + info("[AI] extractResponseText: Texte extrait via modelResponse.text()"); return directText.trim(); } } catch (err) { - warn('[AI] extractResponseText: erreur lors de l appel a modelResponse.text()', err?.message); + warn("[AI] extractResponseText: erreur lors de l appel a modelResponse.text()", err?.message); } const candidates = Array.isArray(modelResponse.candidates) ? modelResponse.candidates : []; @@ -1305,13 +1305,13 @@ function extractResponseText(modelResponse, logDebug, logError) { if (!Array.isArray(parts)) continue; for (const part of parts) { - if (typeof part?.text === 'string' && part.text.trim().length > 0) { + if (typeof part?.text === "string" && part.text.trim().length > 0) { textParts.push(part.text.trim()); } } } - const fallbackText = textParts.join('\n').trim(); + const fallbackText = textParts.join("\n").trim(); info(`[AI] extractResponseText: Texte extrait en fallback (longueur: ${fallbackText.length})`); return fallbackText; } @@ -1324,15 +1324,15 @@ function parseAiResponse(rawText, logDebug, logError) { const err = logError || logger.error.bind(logger); if (!rawText || rawText.trim().length === 0) { - err('[AI] parseAiResponse: rawText recu est vide !'); - throw new Error('Réponse IA vide.'); + err("[AI] parseAiResponse: rawText recu est vide !"); + throw new Error("Réponse IA vide."); } - info('[AI] parseAiResponse tentatives de parsing sur le texte (apercu):', rawText.substring(0, 100) + '...'); + info("[AI] parseAiResponse tentatives de parsing sur le texte (apercu):", rawText.substring(0, 100) + "..."); // Tentative directe try { const parsed = JSON.parse(rawText.trim()); - if (parsed && typeof parsed === 'object') return parsed; + if (parsed && typeof parsed === "object") return parsed; } catch (_) { // Continuer avec extraction depuis markdown } @@ -1342,7 +1342,7 @@ function parseAiResponse(rawText, logDebug, logError) { if (fencedMatch) { try { const parsed = JSON.parse(fencedMatch[1]); - if (parsed && typeof parsed === 'object') return parsed; + if (parsed && typeof parsed === "object") return parsed; } catch (_) { // Continuer } @@ -1353,13 +1353,13 @@ function parseAiResponse(rawText, logDebug, logError) { if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]); - if (parsed && typeof parsed === 'object') return parsed; + if (parsed && typeof parsed === "object") return parsed; } catch (_) { // Échoue } } - throw new Error('JSON IA invalide ou introuvable dans la réponse.'); + throw new Error("JSON IA invalide ou introuvable dans la réponse."); } /** @@ -1376,18 +1376,18 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { return await chat.sendMessage(messagePayload); } catch (error) { const status = error?.status || error?.response?.status; - const errorMessage = error.message || ''; - const isRateLimit = status === 429 || errorMessage.includes('429') || errorMessage.includes('quota'); - const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand'); + const errorMessage = error.message || ""; + const isRateLimit = status === 429 || errorMessage.includes("429") || errorMessage.includes("quota"); + const isOverloaded = status === 503 || errorMessage.includes("503") || errorMessage.includes("overloaded") || errorMessage.includes("high demand"); if ((isRateLimit || isOverloaded) && attempt < maxRetries) { // Exponential backoff : 5s * (2 ^ (attempt - 1)) // Surcharge (503) : attente plus longue car le modèle est saturé // Quota (429) : attente plus courte car c'est juste une limite de débit const exponentialMultiplier = Math.pow(2, Math.max(0, attempt - 1)); - let delayMs = isOverloaded - ? baseDelayMs * exponentialMultiplier * 3 // 15s, 30s, 60s, 120s, etc. - : baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc. + let delayMs = isOverloaded ? + baseDelayMs * exponentialMultiplier * 3 : // 15s, 30s, 60s, 120s, etc. + baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc. // Si le serveur indique un délai spécifique, le respecter const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i); @@ -1395,11 +1395,11 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000; } - const reason = isOverloaded ? 'Modèle surchargé (503 - haute demande)' : 'Quota atteint (429)'; + const reason = isOverloaded ? "Modèle surchargé (503 - haute demande)" : "Quota atteint (429)"; logDebug( - `[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` + + `[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` + `attente de ${Math.round(delayMs / 1000)}s avant nouvelle tentative...`, - { status, attempt, maxRetries } + {status, attempt, maxRetries}, ); await new Promise((resolve) => setTimeout(resolve, delayMs)); } else { @@ -1415,12 +1415,12 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { async function handleAiEquipmentProposal(req, res) { const debugLogs = []; const logDebug = (msg, data) => { - const dataStr = data ? ` | ${JSON.stringify(data)}` : ''; + const dataStr = data ? ` | ${JSON.stringify(data)}` : ""; debugLogs.push(`[INFO] ${msg}${dataStr}`); logger.info(msg, data); }; const logError = (msg, data) => { - const dataStr = data ? ` | ${JSON.stringify(data)}` : ''; + const dataStr = data ? ` | ${JSON.stringify(data)}` : ""; debugLogs.push(`[ERROR] ${msg}${dataStr}`); logger.error(msg, data); }; @@ -1428,6 +1428,7 @@ async function handleAiEquipmentProposal(req, res) { // Récupérer la valeur de firebase functions:config si disponible let firebaseConfigKey = null; try { + const functions = require("firebase-functions"); const config = functions.config(); firebaseConfigKey = config.gemini?.api_key || config.gemini?.apikey; } catch (e) { @@ -1438,9 +1439,9 @@ async function handleAiEquipmentProposal(req, res) { const dynamicApiKey = process.env.GEMINI_API_KEY || firebaseConfigKey || process.env.GOOGLE_AI_API_KEY; if (!dynamicApiKey) { - logError('[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent'); + logError("[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent"); res.status(500).json({ - assistantMessage: 'La configuration de l\'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.', + assistantMessage: "La configuration de l'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.", proposal: null, debugLogs, }); @@ -1464,7 +1465,7 @@ async function handleAiEquipmentProposal(req, res) { } = req.body.data || {}; if (!startDate || !endDate) { - res.status(400).json({ error: 'startDate et endDate sont requis.' }); + res.status(400).json({error: "startDate et endDate sont requis."}); return; } @@ -1473,23 +1474,23 @@ async function handleAiEquipmentProposal(req, res) { model: GEMINI_MODEL, systemInstruction: SYSTEM_PROMPT, tools: AI_TOOLS, - toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, - generationConfig: { temperature: 0.2 }, + toolConfig: {functionCallingConfig: {mode: "AUTO"}}, + generationConfig: {temperature: 0.2}, }); - logDebug('[AI] Reconstruction de l historique de conversation. Elements :', history?.length || 0); + logDebug("[AI] Reconstruction de l historique de conversation. Elements :", history?.length || 0); // Reconstruire l'historique de conversation const chatHistory = (history || []) - .filter((turn) => turn.text && turn.text.trim().length > 0) - .map((turn) => ({ - role: turn.isUser ? 'user' : 'model', - parts: [{ text: turn.text.trim() }], - })); + .filter((turn) => turn.text && turn.text.trim().length > 0) + .map((turn) => ({ + role: turn.isUser ? "user" : "model", + parts: [{text: turn.text.trim()}], + })); - logDebug('[AI] Historique formaté :', chatHistory); + logDebug("[AI] Historique formaté :", chatHistory); - const chat = model.startChat({ history: chatHistory }); + const chat = model.startChat({history: chatHistory}); const toolResultCache = new Map(); const sharedToolContext = { availabilityCandidatesByWindow: new Map(), @@ -1511,19 +1512,19 @@ async function handleAiEquipmentProposal(req, res) { // Sécuriser les logs de base64 const safeMessagePartsForLogs = JSON.parse(JSON.stringify(messageParts)); - safeMessagePartsForLogs.forEach(part => { + safeMessagePartsForLogs.forEach((part) => { if (part.inlineData && part.inlineData.data) { part.inlineData.data = ``; } }); - logDebug('[AI] Starting conversation. Parts envoys:', safeMessagePartsForLogs); + logDebug("[AI] Starting conversation. Parts envoys:", safeMessagePartsForLogs); let response; try { response = await sendMessageWithRetry(chat, messageParts, logDebug, logError); - logDebug('[AI] Premiere reponse recue du sendMessage.'); + logDebug("[AI] Premiere reponse recue du sendMessage."); // Boucle de function calling avec cache local. for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { @@ -1538,10 +1539,10 @@ async function handleAiEquipmentProposal(req, res) { const callsToProcess = functionCalls.slice(0, MAX_TOOL_CALLS_PER_ITERATION); const availabilityCalls = callsToProcess.filter( - (call) => call.name === 'check_availability' - && call.args?.equipmentId - && call.args?.startDate - && call.args?.endDate, + (call) => call.name === "check_availability" && + call.args?.equipmentId && + call.args?.startDate && + call.args?.endDate, ); let batchAvailabilityMap = null; @@ -1551,36 +1552,36 @@ async function handleAiEquipmentProposal(req, res) { const firstStartDate = availabilityCalls[0].args.startDate; const firstEndDate = availabilityCalls[0].args.endDate; const hasSameWindow = availabilityCalls.every( - (call) => call.args.startDate === firstStartDate && call.args.endDate === firstEndDate, + (call) => call.args.startDate === firstStartDate && call.args.endDate === firstEndDate, ); if (hasSameWindow) { const equipmentIds = Array.from(new Set( - availabilityCalls.map((call) => String(call.args.equipmentId)), + availabilityCalls.map((call) => String(call.args.equipmentId)), )).sort(); const batchArgs = { equipmentIds, startDate: firstStartDate, endDate: firstEndDate, }; - const batchCacheKey = buildToolCacheKey('check_availability_batch', batchArgs); + const batchCacheKey = buildToolCacheKey("check_availability_batch", batchArgs); let batchResult = toolResultCache.get(batchCacheKey); if (!batchResult) { batchResult = await executeTool( - { name: 'check_availability_batch', args: batchArgs }, - excludeEventId, - sharedToolContext, + {name: "check_availability_batch", args: batchArgs}, + excludeEventId, + sharedToolContext, ); toolResultCache.set(batchCacheKey, batchResult); } if (batchResult && Array.isArray(batchResult.results)) { batchAvailabilityMap = new Map( - batchResult.results.map((item) => [String(item.equipmentId), item]), + batchResult.results.map((item) => [String(item.equipmentId), item]), ); - batchWindow = { startDate: firstStartDate, endDate: firstEndDate }; - logDebug('[AI] Consolidated check_availability calls into one batch call', { + batchWindow = {startDate: firstStartDate, endDate: firstEndDate}; + logDebug("[AI] Consolidated check_availability calls into one batch call", { iteration: iteration + 1, itemCount: equipmentIds.length, }); @@ -1591,63 +1592,63 @@ async function handleAiEquipmentProposal(req, res) { logDebug(`[AI] Tool calls executes. Preparation du renvoi des resultats...`); const toolResults = await Promise.all( - functionCalls.map(async (toolCall, ind) => { - if (ind >= MAX_TOOL_CALLS_PER_ITERATION) { - return { - functionResponse: { - name: toolCall.name, - response: { error: 'Limite de tool calls simultanes atteinte. (Ignore)' }, - }, - }; - } - - const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args); - if (toolResultCache.has(cacheKey)) { - return { - functionResponse: { - name: toolCall.name, - response: toolResultCache.get(cacheKey), - }, - }; - } - - let toolResult; - try { - if ( - batchAvailabilityMap - && batchWindow - && toolCall.name === 'check_availability' - && toolCall.args?.startDate === batchWindow.startDate - && toolCall.args?.endDate === batchWindow.endDate - ) { - toolResult = batchAvailabilityMap.get(String(toolCall.args.equipmentId)) || { - equipmentId: toolCall.args.equipmentId, - available: true, - conflictCount: 0, - conflicts: [], + functionCalls.map(async (toolCall, ind) => { + if (ind >= MAX_TOOL_CALLS_PER_ITERATION) { + return { + functionResponse: { + name: toolCall.name, + response: {error: "Limite de tool calls simultanes atteinte. (Ignore)"}, + }, }; - } else { - toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext); } - } catch (toolError) { - logError('[AI] Tool call failed, returning degraded tool response', { - tool: toolCall.name, - message: toolError?.message || 'unknown', - }); - toolResult = { error: toolError?.message || `Echec du tool ${toolCall.name}` }; - } - toolResultCache.set(cacheKey, toolResult); + const cacheKey = buildToolCacheKey(toolCall.name, toolCall.args); + if (toolResultCache.has(cacheKey)) { + return { + functionResponse: { + name: toolCall.name, + response: toolResultCache.get(cacheKey), + }, + }; + } - logDebug(`[AI] Resultat du tool ${toolCall.name} (apercu):`, JSON.stringify(toolResult).substring(0, 150) + '...'); + let toolResult; + try { + if ( + batchAvailabilityMap && + batchWindow && + toolCall.name === "check_availability" && + toolCall.args?.startDate === batchWindow.startDate && + toolCall.args?.endDate === batchWindow.endDate + ) { + toolResult = batchAvailabilityMap.get(String(toolCall.args.equipmentId)) || { + equipmentId: toolCall.args.equipmentId, + available: true, + conflictCount: 0, + conflicts: [], + }; + } else { + toolResult = await executeTool(toolCall, excludeEventId, sharedToolContext); + } + } catch (toolError) { + logError("[AI] Tool call failed, returning degraded tool response", { + tool: toolCall.name, + message: toolError?.message || "unknown", + }); + toolResult = {error: toolError?.message || `Echec du tool ${toolCall.name}`}; + } - return { - functionResponse: { - name: toolCall.name, - response: toolResult, - }, - }; - }), + toolResultCache.set(cacheKey, toolResult); + + logDebug(`[AI] Resultat du tool ${toolCall.name} (apercu):`, JSON.stringify(toolResult).substring(0, 150) + "..."); + + return { + functionResponse: { + name: toolCall.name, + response: toolResult, + }, + }; + }), ); logDebug(`[AI] Envoi de ${toolResults.length} resultats au modèle pour la suite de la conversation.`); @@ -1655,31 +1656,31 @@ async function handleAiEquipmentProposal(req, res) { logDebug(`[AI] Reponse recue du modele suite aux resultats des tools.`); } } catch (error) { - logError('[AI] Conversation timeout/error, DETAIL COMPLET:', { - message: error?.message || 'unknown', + logError("[AI] Conversation timeout/error, DETAIL COMPLET:", { + message: error?.message || "unknown", status: error?.status, details: error?.details, - stack: error?.stack + stack: error?.stack, }); res.status(200).json({ - assistantMessage: 'Je suis désolé, je n\'ai pas réussi à traiter votre demande en raison d\'un temps trop long ou d\'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.', + assistantMessage: "Je suis désolé, je n'ai pas réussi à traiter votre demande en raison d'un temps trop long ou d'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.", proposal: null, debugLogs, }); return; } - logDebug('[AI] Structure finale du response.response (apercu):', JSON.stringify(response.response).substring(0, 300) + '...'); + logDebug("[AI] Structure finale du response.response (apercu):", JSON.stringify(response.response).substring(0, 300) + "..."); const rawText = extractResponseText(response.response, logDebug, logError); - logDebug('[AI] Raw response extracted:', { + logDebug("[AI] Raw response extracted:", { hasText: rawText.length > 0, - rawTextExcerpt: rawText.substring(0, 200) + '...' + rawTextExcerpt: rawText.substring(0, 200) + "...", }); // Fallback non bloquant: éviter un 500 quand Gemini ne renvoie pas de texte exploitable. if (!rawText) { res.status(200).json({ - assistantMessage: 'Je n\'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.', + assistantMessage: "Je n'ai pas pu générer une réponse exploitable pour le moment. Réessaie avec une consigne plus précise.", proposal: null, debugLogs, }); @@ -1690,7 +1691,7 @@ async function handleAiEquipmentProposal(req, res) { try { payload = parseAiResponse(rawText, logDebug, logError); } catch (error) { - logError('[AI] JSON parsing failed, returning degraded response', error?.message); + logError("[AI] JSON parsing failed, returning degraded response", error?.message); res.status(200).json({ assistantMessage: rawText, proposal: null, @@ -1708,23 +1709,23 @@ async function handleAiEquipmentProposal(req, res) { const rawContainers = Array.isArray(payload.proposal.containers) ? payload.proposal.containers : []; const items = rawItems - .filter((item) => item.equipmentId && item.quantity > 0) - .map((item) => ({ - equipmentId: String(item.equipmentId).trim(), - quantity: Math.max(1, parseInt(item.quantity) || 1), - rationale: (item.rationale || 'Proposition IA').trim(), - })); + .filter((item) => item.equipmentId && item.quantity > 0) + .map((item) => ({ + equipmentId: String(item.equipmentId).trim(), + quantity: Math.max(1, parseInt(item.quantity) || 1), + rationale: (item.rationale || "Proposition IA").trim(), + })); const containers = rawContainers - .filter((c) => c.containerId) - .map((c) => ({ - containerId: String(c.containerId).trim(), - rationale: (c.rationale || 'Proposition IA').trim(), - })); + .filter((c) => c.containerId) + .map((c) => ({ + containerId: String(c.containerId).trim(), + rationale: (c.rationale || "Proposition IA").trim(), + })); if (items.length > 0 || containers.length > 0) { proposal = { - summary: payload.proposal.summary?.toString().trim() || 'Proposition generee automatiquement.', + summary: payload.proposal.summary?.toString().trim() || "Proposition generee automatiquement.", items, containers, }; @@ -1736,16 +1737,16 @@ async function handleAiEquipmentProposal(req, res) { try { if (proposal && Array.isArray(proposal.items) && proposal.items.length > 0) { const allowContainers = userAllowsContainers(userMessage); - logDebug('[AI] Vérification auto des containers possible (autorisé par l utilisateur) :', { allowContainers }); + logDebug("[AI] Vérification auto des containers possible (autorisé par l utilisateur) :", {allowContainers}); if (allowContainers) { const itemIds = proposal.items.map((it) => String(it.equipmentId)); if (itemIds.length > 0) { - const searchArgs = { equipmentIds: itemIds }; - const cacheKey = buildToolCacheKey('search_containers', searchArgs); + const searchArgs = {equipmentIds: itemIds}; + const cacheKey = buildToolCacheKey("search_containers", searchArgs); let containersResult = toolResultCache.get(cacheKey); if (!containersResult) { - containersResult = await executeTool({ name: 'search_containers', args: searchArgs }, excludeEventId, sharedToolContext); + containersResult = await executeTool({name: "search_containers", args: searchArgs}, excludeEventId, sharedToolContext); toolResultCache.set(cacheKey, containersResult); } @@ -1762,14 +1763,14 @@ async function handleAiEquipmentProposal(req, res) { const isFull = matching.length > 0 && missing.length === 0; // Vérifier la disponibilité du container (et de ses membres) pour la période - const checkArgs = { containerId: c.id, startDate: startDate, endDate: endDate }; - const checkKey = buildToolCacheKey('check_container_availability', checkArgs); + const checkArgs = {containerId: c.id, startDate: startDate, endDate: endDate}; + const checkKey = buildToolCacheKey("check_container_availability", checkArgs); let containerAvail = toolResultCache.get(checkKey); if (!containerAvail) { try { - containerAvail = await executeTool({ name: 'check_container_availability', args: checkArgs }, excludeEventId, sharedToolContext); + containerAvail = await executeTool({name: "check_container_availability", args: checkArgs}, excludeEventId, sharedToolContext); } catch (err) { - containerAvail = { containerId: c.id, available: true, error: err?.message || 'Erreur check' }; + containerAvail = {containerId: c.id, available: true, error: err?.message || "Erreur check"}; } toolResultCache.set(checkKey, containerAvail); } @@ -1784,7 +1785,7 @@ async function handleAiEquipmentProposal(req, res) { totalItemCount: cEquip.length, coverageRatio: c.coverageRatio || (matching.length / Math.max(1, itemsSet.size)), partial: !isFull, - available: containerAvail && typeof containerAvail.available === 'boolean' ? containerAvail.available : true, + available: containerAvail && typeof containerAvail.available === "boolean" ? containerAvail.available : true, availabilityDetail: containerAvail || null, }; @@ -1793,7 +1794,7 @@ async function handleAiEquipmentProposal(req, res) { const already = proposal.containers.find((pc) => pc.containerId === c.id); if (!already) { proposal.containers.push(containerEntry); - logDebug('[AI] Ajout automatique du container car tous ses éléments sont demandés :', { containerId: c.id }); + logDebug("[AI] Ajout automatique du container car tous ses éléments sont demandés :", {containerId: c.id}); } const covered = new Set(cEquip.map((id) => String(id))); @@ -1801,7 +1802,7 @@ async function handleAiEquipmentProposal(req, res) { } else if (matching.length > 0) { // Container partiel : proposer à l'utilisateur d'ajouter le container complète proposal.containers.push(containerEntry); - logDebug('[AI] Container partiel proposé (non retiré des items) :', { containerId: c.id, matchingCount: matching.length, missingCount: missing.length }); + logDebug("[AI] Container partiel proposé (non retiré des items) :", {containerId: c.id, matchingCount: matching.length, missingCount: missing.length}); } } } @@ -1809,10 +1810,10 @@ async function handleAiEquipmentProposal(req, res) { } } } catch (e) { - logError('[AI] Erreur pendant l auto-selection des containers :', { message: e?.message || e }); + logError("[AI] Erreur pendant l auto-selection des containers :", {message: e?.message || e}); } - res.status(200).json({ assistantMessage, proposal, debugLogs }); + res.status(200).json({assistantMessage, proposal, debugLogs}); } -module.exports = { handleAiEquipmentProposal }; +module.exports = {handleAiEquipmentProposal}; diff --git a/em2rp/functions/createAlert.js b/em2rp/functions/createAlert.js index 1f8ab87..fcbd052 100644 --- a/em2rp/functions/createAlert.js +++ b/em2rp/functions/createAlert.js @@ -1,34 +1,34 @@ -const {onRequest} = require('firebase-functions/v2/https'); -const admin = require('firebase-admin'); -const nodemailer = require('nodemailer'); -const logger = require('firebase-functions/logger'); -const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig'); -const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates'); -const auth = require('./utils/auth'); +const {onRequest} = require("firebase-functions/v2/https"); +const admin = require("firebase-admin"); +const nodemailer = require("nodemailer"); +const logger = require("firebase-functions/logger"); +const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig"); +const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates"); +const auth = require("./utils/auth"); // Configuration CORS const setCorsHeaders = (res, req) => { // Utiliser l'origin de la requête pour permettre les credentials - const origin = req.headers.origin || '*'; + const origin = req.headers.origin || "*"; - res.set('Access-Control-Allow-Origin', origin); + res.set("Access-Control-Allow-Origin", origin); // N'autoriser les credentials que si on a un origin spécifique (pas '*') - if (origin !== '*') { - res.set('Access-Control-Allow-Credentials', 'true'); + if (origin !== "*") { + res.set("Access-Control-Allow-Credentials", "true"); } - res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With'); - res.set('Access-Control-Max-Age', '3600'); + res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With"); + res.set("Access-Control-Max-Age", "3600"); }; const withCors = (handler) => { return async (req, res) => { setCorsHeaders(res, req); // Gérer les requêtes preflight OPTIONS immédiatement - if (req.method === 'OPTIONS') { - res.status(204).send(''); + if (req.method === "OPTIONS") { + res.status(204).send(""); return; } try { @@ -48,8 +48,8 @@ const withCors = (handler) => { */ exports.createAlert = onRequest({ cors: false, - invoker: 'public', - region: 'europe-west9' + invoker: "public", + region: "europe-west9", }, withCors(async (req, res) => { try { // Vérifier l'authentification @@ -70,7 +70,7 @@ exports.createAlert = onRequest({ // Validation des données if (!type || !severity || !message) { - res.status(400).json({error: 'type, severity et message sont requis'}); + res.status(400).json({error: "type, severity et message sont requis"}); return; } @@ -78,12 +78,12 @@ exports.createAlert = onRequest({ const userIds = await determineTargetUsers(type, severity, eventId); if (userIds.length === 0) { - res.status(400).json({error: 'Aucun utilisateur à notifier'}); + res.status(400).json({error: "Aucun utilisateur à notifier"}); return; } // 2. Créer l'alerte dans Firestore - const alertRef = admin.firestore().collection('alerts').doc(); + const alertRef = admin.firestore().collection("alerts").doc(); const alertData = { id: alertRef.id, type, @@ -99,14 +99,14 @@ exports.createAlert = onRequest({ createdBy: decodedToken.uid, isRead: false, emailSent: false, - status: 'ACTIVE', + status: "ACTIVE", }; await alertRef.set(alertData); // 3. Envoyer les emails si alerte critique let emailResults = {}; - if (severity === 'CRITICAL') { + if (severity === "CRITICAL") { emailResults = await sendAlertEmails(alertRef.id, alertData, userIds); // Mettre à jour le statut d'envoi @@ -124,7 +124,7 @@ exports.createAlert = onRequest({ emailsSent: Object.values(emailResults).filter((v) => v).length, }); } catch (error) { - logger.error('[createAlert] Erreur:', error); + logger.error("[createAlert] Erreur:", error); res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`}); } })); @@ -137,23 +137,23 @@ async function determineTargetUsers(alertType, severity, eventId) { const targetUserIds = new Set(); // 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins - const allUsersSnapshot = await db.collection('users').get(); + const allUsersSnapshot = await db.collection("users").get(); allUsersSnapshot.forEach((doc) => { const user = doc.data(); if (user.role) { // Le rôle peut être une référence Firestore ou une string - let rolePath = ''; - if (typeof user.role === 'string') { + let rolePath = ""; + if (typeof user.role === "string") { rolePath = user.role; } else if (user.role.path) { rolePath = user.role.path; } else if (user.role._path && user.role._path.segments) { - rolePath = user.role._path.segments.join('/'); + rolePath = user.role._path.segments.join("/"); } // Vérifier si c'est un admin (path = "roles/ADMIN") - if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') { + if (rolePath === "roles/ADMIN" || rolePath === "ADMIN") { targetUserIds.add(doc.id); } } @@ -162,7 +162,7 @@ async function determineTargetUsers(alertType, severity, eventId) { // 2. Si un événement est lié, ajouter tous les membres de la workforce if (eventId) { try { - const eventDoc = await db.collection('events').doc(eventId).get(); + const eventDoc = await db.collection("events").doc(eventId).get(); if (eventDoc.exists) { const event = eventDoc.data(); @@ -177,7 +177,7 @@ async function determineTargetUsers(alertType, severity, eventId) { logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`); } } catch (error) { - logger.error('[determineTargetUsers] Erreur récupération événement:', error); + logger.error("[determineTargetUsers] Erreur récupération événement:", error); } } @@ -222,7 +222,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) { const db = admin.firestore(); // Récupérer l'utilisateur - const userDoc = await db.collection('users').doc(userId).get(); + const userDoc = await db.collection("users").doc(userId).get(); if (!userDoc.exists) { return false; @@ -250,7 +250,7 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) { const templateData = await prepareTemplateData(alertData, user); // Rendre le template - const html = await renderTemplate('alert-individual', templateData); + const html = await renderTemplate("alert-individual", templateData); // Envoyer l'email await transporter.sendMail({ diff --git a/em2rp/functions/generateTTS.js b/em2rp/functions/generateTTS.js index c6a6016..9e7ef43 100644 --- a/em2rp/functions/generateTTS.js +++ b/em2rp/functions/generateTTS.js @@ -4,9 +4,9 @@ * Avec système de cache dans Firebase Storage */ -const textToSpeech = require('@google-cloud/text-to-speech'); -const crypto = require('crypto'); -const logger = require('firebase-functions/logger'); +const textToSpeech = require("@google-cloud/text-to-speech"); +const crypto = require("crypto"); +const logger = require("firebase-functions/logger"); /** * Génère un hash MD5 pour le texte (utilisé comme clé de cache) @@ -16,10 +16,10 @@ const logger = require('firebase-functions/logger'); function generateCacheKey(text, voiceConfig = {}) { const cacheString = JSON.stringify({ text, - lang: voiceConfig.languageCode || 'fr-FR', - voice: voiceConfig.name || 'fr-FR-Standard-B', + lang: voiceConfig.languageCode || "fr-FR", + voice: voiceConfig.name || "fr-FR-Standard-B", }); - return crypto.createHash('md5').update(cacheString).digest('hex'); + return crypto.createHash("md5").update(cacheString).digest("hex"); } /** @@ -34,21 +34,21 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { try { // Validation du texte if (!text || text.trim().length === 0) { - throw new Error('Text cannot be empty'); + throw new Error("Text cannot be empty"); } if (text.length > 5000) { - throw new Error('Text too long (max 5000 characters)'); + throw new Error("Text too long (max 5000 characters)"); } // Configuration par défaut de la voix const defaultVoiceConfig = { - languageCode: 'fr-FR', - name: 'fr-FR-Standard-B', // Voix masculine française (Standard = gratuit) - ssmlGender: 'MALE', + languageCode: "fr-FR", + name: "fr-FR-Standard-B", // Voix masculine française (Standard = gratuit) + ssmlGender: "MALE", }; - const finalVoiceConfig = { ...defaultVoiceConfig, ...voiceConfig }; + const finalVoiceConfig = {...defaultVoiceConfig, ...voiceConfig}; // Générer la clé de cache const cacheKey = generateCacheKey(text, finalVoiceConfig); @@ -59,11 +59,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { const [exists] = await file.exists(); if (exists) { - logger.info('[generateTTS] ✓ Cache HIT', { cacheKey, text: text.substring(0, 50) }); + logger.info("[generateTTS] ✓ Cache HIT", {cacheKey, text: text.substring(0, 50)}); // Générer une URL signée valide 7 jours const [url] = await file.getSignedUrl({ - action: 'read', + action: "read", expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours }); @@ -74,7 +74,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { }; } - logger.info('[generateTTS] ○ Cache MISS - Generating audio', { + logger.info("[generateTTS] ○ Cache MISS - Generating audio", { cacheKey, text: text.substring(0, 50), voice: finalVoiceConfig.name, @@ -85,10 +85,10 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { // Configuration de la requête const request = { - input: { text: text }, + input: {text: text}, voice: finalVoiceConfig, audioConfig: { - audioEncoding: 'MP3', + audioEncoding: "MP3", speakingRate: 0.9, // Légèrement plus lent pour meilleure compréhension pitch: -2.0, // Voix un peu plus grave volumeGainDb: 0.0, @@ -99,17 +99,17 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { const [response] = await client.synthesizeSpeech(request); if (!response.audioContent) { - throw new Error('No audio content returned from TTS API'); + throw new Error("No audio content returned from TTS API"); } - logger.info('[generateTTS] ✓ Audio generated', { + logger.info("[generateTTS] ✓ Audio generated", { size: response.audioContent.length, }); // Sauvegarder dans Firebase Storage await file.save(response.audioContent, { metadata: { - contentType: 'audio/mpeg', + contentType: "audio/mpeg", metadata: { text: text.substring(0, 100), // Premier 100 caractères pour debug voice: finalVoiceConfig.name, @@ -118,11 +118,11 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { }, }); - logger.info('[generateTTS] ✓ Audio cached', { fileName }); + logger.info("[generateTTS] ✓ Audio cached", {fileName}); // Générer une URL signée const [url] = await file.getSignedUrl({ - action: 'read', + action: "read", expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 jours }); @@ -132,7 +132,7 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { cacheKey, }; } catch (error) { - logger.error('[generateTTS] ✗ Error', { + logger.error("[generateTTS] ✗ Error", { error: error.message, code: error.code, text: text?.substring(0, 50), @@ -142,5 +142,5 @@ async function generateTTS(text, storage, bucket, voiceConfig = {}) { } } -module.exports = { generateTTS, generateCacheKey }; +module.exports = {generateTTS, generateCacheKey}; diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 6aae0f1..1901213 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -1,3811 +1,433 @@ /** * EM2RP Cloud Functions - * Architecture backend sécurisée avec authentification et permissions + * Architecture backend sécurisée et modulaire avec dynamic imports (lazy loading) */ -// Charger les variables d'environnement depuis .env.local (développement) -// ou .env (production Firebase) -const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '.env.local') }); -require('dotenv').config({ path: path.join(__dirname, '.env') }); +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 {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'); -const { Storage } = require('@google-cloud/storage'); +const admin = require("firebase-admin"); -// Utilitaires -const auth = require('./utils/auth'); -const helpers = require('./utils/helpers'); -const { generateTTS } = require('./generateTTS'); -const { handleAiEquipmentProposal } = require('./aiEquipmentProposal'); - -// Initialisation sécurisée +// Initialisation sécurisée de Firebase Admin au démarrage if (!admin.apps.length) { admin.initializeApp(); } -const storage = new Storage(); const db = admin.firestore(); // Configuration commune pour toutes les fonctions HTTP const httpOptions = { cors: false, - invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase) - region: 'europe-west9', // Région européenne (Paris) - // Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS + invoker: "public", + region: "europe-west9", }; // Options dédiées pour les traitements IA potentiellement longs. const aiHttpOptions = { ...httpOptions, timeoutSeconds: 300, - memory: '1GiB', + 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) => { - // Utiliser l'origin de la requête pour permettre les credentials - const origin = req.headers.origin || '*'; - - res.set('Access-Control-Allow-Origin', origin); - - // N'autoriser les credentials que si on a un origin spécifique (pas '*') - if (origin !== '*') { - res.set('Access-Control-Allow-Credentials', 'true'); + 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'); + 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 immédiatement - if (req.method === 'OPTIONS') { - res.status(204).send(''); + 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 }); + res.status(500).json({error: error.message}); } } }; }; // ============================================================================ -// STORAGE - Move Event File +// STORAGE // ============================================================================ -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 }); - } +exports.moveEventFileV2 = onRequest(httpOptions, withCors((req, res) => { + return require("./src/storage").moveEventFileV2(req, res); })); // ============================================================================ -// EQUIPMENT - CRUD +// EQUIPMENT // ============================================================================ - -// 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 }); - } +exports.createEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").createEquipment(req, res); })); -// 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; - } - - if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { - res.status(400).json({ error: 'Update data is required and must be a non-empty object' }); - return; - } - - // Empêcher la modification de l'ID - delete data.id; - - // Ajouter updatedAt - data.updatedAt = admin.firestore.Timestamp.now(); - - const dataToSave = helpers.deserializeTimestamps(data, [ - 'purchaseDate', 'lastMaintenanceDate', 'nextMaintenanceDate' - ]); - - await db.collection('equipments').doc(equipmentId).update(dataToSave); - - res.status(200).json({ message: 'Equipment updated successfully' }); - } catch (error) { - logger.error("Error updating equipment:", error); - res.status(500).json({ error: error.message }); - } +exports.updateEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").updateEquipment(req, res); })); -// 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, forceDelete = false } = req.body.data; - - if (!equipmentId) { - res.status(400).json({ error: 'Equipment ID is required' }); - return; - } - - // Vérifier si l'équipement est utilisé dans des événements à venir - const eventsSnapshot = await db.collection('events') - .where('status', '!=', 'CANCELLED') - .get(); - - const now = new Date(); - const upcomingEvents = []; - - for (const eventDoc of eventsSnapshot.docs) { - const eventData = eventDoc.data(); - const assignedEquipment = eventData.assignedEquipment || []; - - if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { - continue; - } - - let eventStart = null; - if (eventData.StartDateTime) { - eventStart = eventData.StartDateTime.toDate - ? eventData.StartDateTime.toDate() - : new Date(eventData.StartDateTime); - } - - if (eventStart && eventStart > now) { - upcomingEvents.push({ - eventId: eventDoc.id, - eventName: eventData.Name || '', - startDate: eventStart.toISOString(), - }); - } - } - - if (upcomingEvents.length > 0 && !forceDelete) { - res.status(409).json({ - error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events', - upcomingEvents, - }); - return; - } - - await db.collection('equipments').doc(equipmentId).delete(); - - res.status(200).json({ message: 'Equipment deleted successfully' }); - } catch (error) { - logger.error("Error deleting equipment:", error); - res.status(500).json({ error: error.message }); - } +exports.deleteEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").deleteEquipment(req, res); })); -// 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 }); - } +exports.getEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").getEquipment(req, res); })); -// 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'); +exports.getEquipmentsByIds = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").getEquipmentsByIds(req, res); +})); - if (!hasViewAccess && !hasManageAccess) { - res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' }); - return; - } +exports.updateEquipmentStatusOnly = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").updateEquipmentStatusOnly(req, res); +})); - const { equipmentIds } = req.body.data || {}; +exports.updateEquipmentStatus = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").updateEquipmentStatus(req, res); +})); - if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) { - res.status(400).json({ error: 'equipmentIds array is required and must not be empty' }); - return; - } +exports.getEquipmentsPaginated = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").getEquipmentsPaginated(req, res); +})); - // 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 }); - } +exports.quickSearch = onRequest(httpOptions, withCors((req, res) => { + return require("./src/equipments").quickSearch(req, res); })); // ============================================================================ -// CONTAINERS - CRUD +// CONTAINERS // ============================================================================ - -// 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 }); - } +exports.createContainer = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").createContainer(req, res); })); -// 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 }); - } +exports.updateContainer = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").updateContainer(req, res); })); -// 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 }); - } +exports.deleteContainer = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").deleteContainer(req, res); })); -// 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 }); - } +exports.getContainersByEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").getContainersByEquipment(req, res); })); -// 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 }); - } +exports.getContainersByIds = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").getContainersByIds(req, res); })); -/** - * 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 }); - } +exports.addEquipmentToContainer = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").addEquipmentToContainer(req, res); })); -/** - * 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 }); - } +exports.removeEquipmentFromContainer = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").removeEquipmentFromContainer(req, res); })); - -// ============================================================================ -// 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; - - // Désérialiser les timestamps - let dataToSave = helpers.deserializeTimestamps(eventData, [ - 'StartDateTime', 'EndDateTime', 'createdAt', 'updatedAt' - ]); - - // Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format - dataToSave = helpers.convertIdsToReferences(dataToSave); - - 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(); - - // Désérialiser les timestamps - let dataToSave = helpers.deserializeTimestamps(data, [ - 'StartDateTime', 'EndDateTime' - ]); - - // Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format - dataToSave = helpers.convertIdsToReferences(dataToSave); - - 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 }); - } +exports.getContainersPaginated = onRequest(httpOptions, withCors((req, res) => { + return require("./src/containers").getContainersPaginated(req, res); })); // ============================================================================ -// MAINTENANCES - CRUD +// EVENTS // ============================================================================ - -// 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 }); - } +exports.createEvent = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").createEvent(req, res); })); -// 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'); +exports.updateEvent = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").updateEvent(req, res); +})); - if (!hasAccess) { - res.status(403).json({ error: 'Forbidden: Requires manage_maintenances permission' }); - return; - } +exports.deleteEvent = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").deleteEvent(req, res); +})); - const { maintenanceId, data } = req.body.data; +exports.updateEventEquipment = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").updateEventEquipment(req, res); +})); - if (!maintenanceId) { - res.status(400).json({ error: 'Maintenance ID is required' }); - return; - } +exports.getEventsByEventType = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").getEventsByEventType(req, res); +})); - delete data.id; - data.updatedAt = admin.firestore.Timestamp.now(); +exports.getEvents = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").getEvents(req, res); +})); - const dataToSave = helpers.deserializeTimestamps(data, [ - 'scheduledDate', 'completedDate' - ]); +exports.getEventsByMonth = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").getEventsByMonth(req, res); +})); - await db.collection('maintenances').doc(maintenanceId).update(dataToSave); +exports.searchEvents = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").searchEvents(req, res); +})); - res.status(200).json({ message: 'Maintenance updated successfully' }); - } catch (error) { - logger.error("Error updating maintenance:", error); - res.status(500).json({ error: error.message }); - } +exports.getEventWithDetails = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").getEventWithDetails(req, res); +})); + +exports.validateEquipmentPreparation = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").validateEquipmentPreparation(req, res); +})); + +exports.validateAllPreparation = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").validateAllPreparation(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); +})); + +exports.validateEquipmentReturn = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").validateEquipmentReturn(req, res); +})); + +exports.validateAllReturn = onRequest(httpOptions, withCors((req, res) => { + return require("./src/events").validateAllReturn(req, res); })); // ============================================================================ -// OPTIONS - CRUD +// MAINTENANCES // ============================================================================ - -// 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 }); - } +exports.createMaintenance = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").createMaintenance(req, res); })); -// 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 }); - } +exports.updateMaintenance = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").updateMaintenance(req, res); })); -// 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); +exports.getMaintenances = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").getMaintenances(req, res); +})); - if (!isAdminUser) { - res.status(403).json({ error: 'Forbidden: Admin access required' }); - return; - } +exports.deleteMaintenance = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").deleteMaintenance(req, res); +})); - const { optionId } = req.body.data; +exports.checkUpcomingMaintenances = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").checkUpcomingMaintenances(req, res); +})); - 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 }); - } +exports.completeMaintenance = onRequest(httpOptions, withCors((req, res) => { + return require("./src/maintenances").completeMaintenance(req, res); })); // ============================================================================ -// USERS - CRUD +// OPTIONS & EVENT TYPES // ============================================================================ - -// 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 }); - } +exports.createOption = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").createOption(req, res); })); -// 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 }); - } +exports.updateOption = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").updateOption(req, res); })); -// 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', 'notificationPreferences']; - 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 }); - } +exports.deleteOption = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").deleteOption(req, res); })); -// 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); +exports.getOptions = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").getOptions(req, res); +})); - if (!isAdminUser) { - res.status(403).json({ error: 'Forbidden: Admin access required' }); - return; - } +exports.getEventTypes = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").getEventTypes(req, res); +})); - const { userId } = req.body.data; +exports.createEventType = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").createEventType(req, res); +})); - if (!userId) { - res.status(400).json({ error: 'User ID is required' }); - return; - } +exports.updateEventType = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").updateEventType(req, res); +})); - // 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 }); - } +exports.deleteEventType = onRequest(httpOptions, withCors((req, res) => { + return require("./src/options").deleteEventType(req, res); })); // ============================================================================ -// EQUIPMENT STATUS - Batch Update +// USERS // ============================================================================ +exports.createUser = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").createUser(req, res); +})); -// 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; +exports.createUserWithInvite = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").createUserWithInvite(req, res); +})); - if (!eventId || !updates || !Array.isArray(updates)) { - res.status(400).json({ error: 'Event ID and updates array are required' }); - return; - } +exports.updateUser = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").updateUser(req, res); +})); - // 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); +exports.deleteUser = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").deleteUser(req, res); +})); - if (!isAssigned && !isAdminUser) { - res.status(403).json({ error: 'Forbidden: Not assigned to this event' }); - return; - } +exports.getUsers = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").getUsers(req, res); +})); - // Batch update - const batch = db.batch(); +exports.getUser = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").getUser(req, res); +})); - for (const update of updates) { - const { equipmentId, status } = update; - if (equipmentId && status) { - const equipmentRef = db.collection('equipments').doc(equipmentId); - batch.update(equipmentRef, { status }); - } - } +exports.getCurrentUser = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").getCurrentUser(req, res); +})); - 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 }); - } +exports.getRoles = onRequest(httpOptions, withCors((req, res) => { + return require("./src/users").getRoles(req, res); })); // ============================================================================ -// OPTIONS - Read (public pour utilisateurs authentifiés) +// ALERTS // ============================================================================ -exports.getOptions = onRequest(httpOptions, withCors(async (req, res) => { - try { - await auth.authenticateUser(req); // Juste vérifier l'auth +exports.getAlerts = onRequest(httpOptions, withCors((req, res) => { + return require("./src/alerts").getAlerts(req, res); +})); - const snapshot = await db.collection('options').get(); - const options = snapshot.docs.map(doc => ({ - id: doc.id, - ...helpers.serializeTimestamps(doc.data()) - })); +exports.markAlertAsRead = onRequest(httpOptions, withCors((req, res) => { + return require("./src/alerts").markAlertAsRead(req, res); +})); - res.status(200).json({ options }); - } catch (error) { - logger.error("Error fetching options:", error); - res.status(500).json({ error: error.message }); - } +exports.deleteAlert = onRequest(httpOptions, withCors((req, res) => { + return require("./src/alerts").deleteAlert(req, res); })); // ============================================================================ -// EVENT TYPES - Read (public pour utilisateurs authentifiés) +// AVAILABILITY // ============================================================================ -exports.getEventTypes = onRequest(httpOptions, withCors(async (req, res) => { - try { - await auth.authenticateUser(req); // Juste vérifier l'auth +exports.checkEquipmentAvailability = onRequest(httpOptions, withCors((req, res) => { + return require("./src/availability").checkEquipmentAvailability(req, res); +})); - const snapshot = await db.collection('eventTypes').get(); - const eventTypes = snapshot.docs.map(doc => ({ - id: doc.id, - ...helpers.serializeTimestamps(doc.data()) - })); +exports.checkContainerAvailability = onRequest(httpOptions, withCors((req, res) => { + return require("./src/availability").checkContainerAvailability(req, res); +})); - res.status(200).json({ eventTypes }); - } catch (error) { - logger.error("Error fetching event types:", error); - res.status(500).json({ error: error.message }); - } +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); })); // ============================================================================ -// ROLES - Read (public pour utilisateurs authentifiés) +// TEXT-TO-SPEECH // ============================================================================ -exports.getRoles = onRequest(httpOptions, withCors(async (req, res) => { - try { - await auth.authenticateUser(req); // Juste vérifier l'auth +exports.generateTTSV2 = onRequest(ttsHttpOptions, (req, res) => { + return require("./src/tts").generateTTSV2(req, res); +}); - 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 }); - } +// ============================================================================ +// AI +// ============================================================================ +exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors((req, res) => { + return require("./aiEquipmentProposal").handleAiEquipmentProposal(req, res); })); // ============================================================================ -// EVENT EQUIPMENT - Update equipment status and quantities +// CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS) // ============================================================================ -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; +exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => { + return require("./sendAlertEmail").sendAlertEmail(request); +}); - if (!eventId) { - res.status(400).json({ error: 'Event ID is required' }); - return; - } +exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => { + return require("./createAlert").createAlert(request); +}); - // 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 }); - } -})); +exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => { + return require("./processEquipmentValidation").processEquipmentValidation(request); +}); // ============================================================================ -// EQUIPMENT STATUS - Update individual equipment status +// SCHEDULED FUNCTIONS // ============================================================================ -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 PARALLÈLE (optimisé) - const usersMap = {}; - if (userIdsSet.size > 0) { - const userIds = Array.from(userIdsSet); - const batchSize = 30; // Augmenté de 10 à 30 pour réduire le nombre de requêtes - - // Exécuter les requêtes en PARALLÈLE au lieu de séquentiel - const batchPromises = []; - for (let i = 0; i < userIds.length; i += batchSize) { - const batch = userIds.slice(i, i + batchSize); - batchPromises.push( - db.collection('users') - .where(admin.firestore.FieldPath.documentId(), 'in', batch) - .get() - ); - } - - const results = await Promise.all(batchPromises); - results.forEach(usersSnapshot => { - 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 }); - } -})); - -// ============================================================================ -// EVENTS - Get by month (optimized lazy loading) -// ============================================================================ - -/** - * Récupère les événements d'un mois spécifique (lazy loading optimisé) - * Réduit drastiquement le temps de chargement en ne chargeant que le mois demandé - */ -exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => { - try { - const decodedToken = await auth.authenticateUser(req); - const { userId, year, month } = req.body.data || {}; - - if (!year || !month) { - res.status(400).json({ error: 'year and month are required' }); - return; - } - - logger.info(`Fetching events for ${year}-${month}`); - - // Calculer le début et la fin du mois - const startOfMonth = admin.firestore.Timestamp.fromDate( - new Date(year, month - 1, 1, 0, 0, 0) - ); - const endOfMonth = admin.firestore.Timestamp.fromDate( - new Date(year, month, 0, 23, 59, 59) - ); - - // Vérifier si l'utilisateur peut voir tous les événements - const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events'); - - let eventsQuery = db.collection('events') - .where('StartDateTime', '>=', startOfMonth) - .where('StartDateTime', '<=', endOfMonth); - - if (!canViewAll) { - // Utilisateur normal : seulement ses événements assignés - const userRef = db.collection('users').doc(userId || decodedToken.uid); - eventsQuery = eventsQuery.where('workforce', 'array-contains', userRef); - } - - const eventsSnapshot = await eventsQuery.get(); - - logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`); - - // 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 PARALLÈLE (optimisé) - const usersMap = {}; - if (userIdsSet.size > 0) { - const userIds = Array.from(userIdsSet); - const batchSize = 30; // Limite Firestore augmentée de 10 à 30 - - // Exécuter les requêtes en parallèle au lieu de séquentiel - const batchPromises = []; - for (let i = 0; i < userIds.length; i += batchSize) { - const batch = userIds.slice(i, i + batchSize); - batchPromises.push( - db.collection('users') - .where(admin.firestore.FieldPath.documentId(), 'in', batch) - .get() - ); - } - - const results = await Promise.all(batchPromises); - results.forEach(usersSnapshot => { - usersSnapshot.docs.forEach(userDoc => { - const userData = userDoc.data(); - 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, - }; - }); - - logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`); - - res.status(200).json({ - events, - users: usersMap, - month: { year, month } - }); - } catch (error) { - logger.error("Error fetching events by month:", error); - res.status(500).json({ error: error.message }); - } -})); - -const normalizeSearchText = (value) => { - return (value || '') - .toString() - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .trim(); -}; - -const getEventStartDate = (eventData) => { - const startValue = eventData.StartDateTime; - - if (!startValue) { - return null; - } - - if (startValue.toDate) { - return startValue.toDate(); - } - - const parsedDate = new Date(startValue); - return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; -}; - -const getEventWorkforceUids = (eventData) => { - if (!eventData.workforce || !Array.isArray(eventData.workforce)) { - return []; - } - - return eventData.workforce - .map((userRef) => { - if (userRef && userRef.id) { - return userRef.id; - } - - if (typeof userRef === 'string' && userRef.startsWith('users/')) { - return userRef.split('/')[1]; - } - - return null; - }) - .filter((uid) => uid !== null); -}; - -const serializeEventSearchResult = (doc) => { - const data = doc.data(); - - return { - id: doc.id, - ...helpers.serializeTimestamps(data), - workforce: getEventWorkforceUids(data), - }; -}; - -// ============================================================================ -// EVENTS - Search -// ============================================================================ -exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => { - try { - const decodedToken = await auth.authenticateUser(req); - const { userId, query, limit = 20 } = req.body.data || {}; - const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20; - - const normalizedQuery = normalizeSearchText(query); - if (!normalizedQuery) { - res.status(200).json({ events: [] }); - return; - } - - const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events'); - - let eventsSnapshot; - if (canViewAll) { - eventsSnapshot = await db.collection('events').get(); - } else { - const userRef = db.collection('users').doc(userId || decodedToken.uid); - eventsSnapshot = await db.collection('events') - .where('workforce', 'array-contains', userRef) - .get(); - } - - const matchingEvents = eventsSnapshot.docs - .filter((doc) => { - const eventData = doc.data(); - const startDate = getEventStartDate(eventData); - const searchableText = normalizeSearchText([ - eventData.Name, - eventData.Description, - eventData.Address, - startDate ? startDate.toLocaleString('fr-FR') : '', - startDate ? startDate.toISOString() : '', - ].join(' ')); - - return searchableText.includes(normalizedQuery); - }) - .sort((a, b) => { - const startA = getEventStartDate(a.data()) || new Date(0); - const startB = getEventStartDate(b.data()) || new Date(0); - return startA.getTime() - startB.getTime(); - }) - .slice(0, maxResults) - .map((doc) => serializeEventSearchResult(doc)); - - res.status(200).json({ events: matchingEvents }); - } catch (error) { - logger.error('Error searching events:', error); - res.status(500).json({ error: error.message }); - } -})); - -/** - * Récupère un événement avec tous les détails (équipements complets + containers avec enfants) - * Optimisé pour la page de préparation et l'affichage détaillé - */ -exports.getEventWithDetails = onRequest(httpOptions, withCors(async (req, res) => { - try { - const decodedToken = await auth.authenticateUser(req); - const { eventId } = req.body.data || {}; - - if (!eventId) { - res.status(400).json({ error: 'eventId is required' }); - return; - } - - // Récupérer l'événement - 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(); - - // Vérifier les permissions - const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events'); - if (!canViewAll) { - // Vérifier si l'utilisateur est dans la workforce - const userRef = db.collection('users').doc(decodedToken.uid); - const isInWorkforce = eventData.workforce && eventData.workforce.some(ref => - (ref.id && ref.id === decodedToken.uid) || - (typeof ref === 'string' && ref === `users/${decodedToken.uid}`) - ); - - if (!isInWorkforce) { - res.status(403).json({ error: 'Forbidden: Not assigned to this event' }); - return; - } - } - - logger.info(`[getEventWithDetails] Loading details for event ${eventId}`); - - // Collecter tous les IDs d'équipements et de containers - const equipmentIds = new Set(); - const containerIds = new Set(); - - if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) { - eventData.assignedEquipment.forEach(eq => { - if (eq.equipmentId) { - equipmentIds.add(eq.equipmentId); - } - }); - } - - if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) { - eventData.assignedContainers.forEach(id => containerIds.add(id)); - } - - logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`); - - // Charger tous les équipements en parallèle - const equipmentPromises = Array.from(equipmentIds).map(id => - db.collection('equipments').doc(id).get() - ); - const equipmentDocs = await Promise.all(equipmentPromises); - - const equipmentMap = {}; - for (const doc of equipmentDocs) { - if (doc.exists) { - let data = { id: doc.id, ...doc.data() }; - data = helpers.serializeTimestamps(data); - data = helpers.serializeReferences(data); - equipmentMap[doc.id] = data; - } - } - - // Charger tous les containers en parallèle - const containerPromises = Array.from(containerIds).map(id => - db.collection('containers').doc(id).get() - ); - const containerDocs = await Promise.all(containerPromises); - - // Collecter les IDs des équipements enfants des containers - const childEquipmentIds = new Set(); - for (const doc of containerDocs) { - if (doc.exists) { - const containerData = doc.data(); - if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) { - containerData.equipmentIds.forEach(id => childEquipmentIds.add(id)); - } - } - } - - logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`); - - // Charger les équipements enfants des containers - const childEquipmentPromises = Array.from(childEquipmentIds).map(id => - db.collection('equipments').doc(id).get() - ); - const childEquipmentDocs = await Promise.all(childEquipmentPromises); - - // Ajouter les enfants au map d'équipements - for (const doc of childEquipmentDocs) { - if (doc.exists && !equipmentMap[doc.id]) { - let data = { id: doc.id, ...doc.data() }; - data = helpers.serializeTimestamps(data); - data = helpers.serializeReferences(data); - equipmentMap[doc.id] = data; - } - } - - // Construire les containers avec leurs enfants complets - const containerMap = {}; - for (const doc of containerDocs) { - if (doc.exists) { - let containerData = { id: doc.id, ...doc.data() }; - containerData = helpers.serializeTimestamps(containerData); - containerData = helpers.serializeReferences(containerData); - - // Ajouter les équipements enfants complets - if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) { - containerData.children = containerData.equipmentIds - .map(id => equipmentMap[id]) - .filter(eq => eq !== undefined); - } else { - containerData.children = []; - } - - containerMap[doc.id] = containerData; - } - } - - // Construire la réponse finale - const event = { - id: eventDoc.id, - ...helpers.serializeTimestamps(eventData), - workforce: eventData.workforce ? eventData.workforce.map(ref => - (ref.id || (typeof ref === 'string' ? ref.split('/')[1] : null)) - ).filter(uid => uid !== null) : [], - }; - - logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`); - - res.status(200).json({ - event, - equipments: equipmentMap, - containers: containerMap, - }); - } catch (error) { - logger.error("Error getting event with details:", 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 }); - } -})); - -// createAlert est défini dans createAlert.js et importé à la fin du fichier - -// ============================================================================ -// 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 = onRequest(httpOptions, withCors(async (req, res) => { - try { - const decodedToken = await auth.authenticateUser(req); - - const { userId } = req.body.data || req.body || {}; - if (!userId) { - res.status(400).json({ error: 'userId is required' }); - return; - } - - const userDoc = await db.collection('users').doc(userId).get(); - if (!userDoc.exists) { - res.status(404).json({ error: 'User not found' }); - return; - } - - const user = userDoc.data(); - - // 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(), - }; - } - } - - res.status(200).json({ user: userData }); - } catch (error) { - logger.error('Error fetching user:', error); - res.status(500).json({ error: error.message }); - } -})); - - -// ============================================================================ -// 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 }); - } -})); - -// ==================== EMAIL FUNCTIONS ==================== -const {sendAlertEmail} = require('./sendAlertEmail'); -exports.sendAlertEmail = sendAlertEmail; - -// ==================== ALERT FUNCTIONS ==================== -const {createAlert} = require('./createAlert'); -exports.createAlert = createAlert; - -const {processEquipmentValidation} = require('./processEquipmentValidation'); -exports.processEquipmentValidation = processEquipmentValidation; - -// ==================== SCHEDULED FUNCTIONS ==================== -const {sendDailyDigest} = require('./sendDailyDigest'); - -/** - * Fonction schedulée : Envoie quotidien d'un digest des alertes non lues - * S'exécute tous les jours à 8h00 (Europe/Paris) - */ exports.sendDailyDigest = onSchedule({ - schedule: '0 8 * * *', - timeZone: 'Europe/Paris', - region: 'europe-west9', + schedule: "0 8 * * *", + timeZone: "Europe/Paris", + region: "europe-west9", retryCount: 2, - memory: '512MiB' + memory: "512MiB", }, async (context) => { - logger.info('[Scheduler] Démarrage sendDailyDigest'); + logger.info("[Scheduler] Démarrage sendDailyDigest"); try { + const {sendDailyDigest} = require("./sendDailyDigest"); await sendDailyDigest(); - logger.info('[Scheduler] sendDailyDigest terminé avec succès'); + logger.info("[Scheduler] sendDailyDigest terminé avec succès"); } catch (error) { - logger.error('[Scheduler] Erreur sendDailyDigest:', error); + logger.error("[Scheduler] Erreur sendDailyDigest:", error); throw error; } }); -// ==================== FIRESTORE TRIGGERS ==================== +// ============================================================================ +// FIRESTORE TRIGGERS +// ============================================================================ -/** - * Trigger : Nouvel événement créé - * Envoie une notification à tous les membres de la workforce - */ exports.onEventCreated = onDocumentCreated({ - document: 'events/{eventId}', - region: 'europe-west9' + 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; - // Créer une alerte pour informer la workforce - 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')}`, + 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, @@ -3816,53 +438,39 @@ exports.onEventCreated = onDocumentCreated({ eventName: eventData.name, eventDate: eventData.startDate, }, - assignedTo: [], // Sera rempli automatiquement par la fonction createAlert + assignedTo: [], }); - - // Appeler createAlert via HTTP pour gérer l'envoi des emails - const createAlertModule = require('./createAlert'); - // Note: On ne peut pas appeler directement la fonction HTTP, mais on peut créer l'alerte directement - // L'envoi des emails sera géré par un trigger sur la collection alerts - logger.info(`[onEventCreated] Alerte créée pour événement ${eventId}`); } catch (error) { - logger.error('[onEventCreated] Erreur:', error); + logger.error("[onEventCreated] Erreur:", error); } }); -/** - * Trigger : Événement modifié (workforce changée) - * Envoie une notification aux nouveaux membres ajoutés à la workforce - */ exports.onEventUpdated = onDocumentUpdated({ - document: 'events/{eventId}', - region: 'europe-west9' + 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 { - // Vérifier si la workforce a changé const workforceBefore = before.workforce || []; const workforceAfter = after.workforce || []; - // Trouver les nouveaux membres ajoutés - const newMembers = workforceAfter.filter(afterMember => { - return !workforceBefore.some(beforeMember => - beforeMember.userId === afterMember.userId + 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}`); - - // Créer une alerte pour chaque nouveau membre 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')}`, + 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, @@ -3873,24 +481,19 @@ exports.onEventUpdated = onDocumentUpdated({ eventName: after.name, eventDate: after.startDate, }, - assignedTo: [member.userId], // Alerte ciblée uniquement pour ce membre + assignedTo: [member.userId], }); - logger.info(`[onEventUpdated] Alerte créée pour ${member.userId}`); } } } catch (error) { - logger.error('[onEventUpdated] Erreur:', error); + logger.error("[onEventUpdated] Erreur:", error); } }); -/** - * Trigger : Nouvelle alerte créée - * Envoie un email immédiat si l'alerte est critique - */ exports.onAlertCreated = onDocumentCreated({ - document: 'alerts/{alertId}', - region: 'europe-west9' + document: "alerts/{alertId}", + region: "europe-west9", }, async (event) => { const alertId = event.params.alertId; const alertData = event.data.data(); @@ -3898,677 +501,17 @@ exports.onAlertCreated = onDocumentCreated({ logger.info(`[onAlertCreated] Nouvelle alerte: ${alertId} (${alertData.severity})`); try { - // Si l'alerte est critique et pas encore envoyée par email - if (alertData.severity === 'CRITICAL' && !alertData.emailSent) { - const sendEmailModule = require('./sendAlertEmail'); - - // Les destinataires sont déjà dans assignedTo + if (alertData.severity === "CRITICAL" && !alertData.emailSent) { const userIds = alertData.assignedTo || []; - if (userIds.length > 0) { logger.info(`[onAlertCreated] Envoi email immédiat à ${userIds.length} utilisateurs`); - - // Note: Dans un trigger Firestore, on ne peut pas facilement appeler une fonction HTTP - // Il faudrait soit: - // 1. Dupliquer la logique d'envoi d'email ici - // 2. Utiliser une file d'attente (Pub/Sub ou Tasks) - // 3. Marquer l'alerte pour qu'elle soit traitée par un scheduler - - // Pour l'instant, on marque l'alerte comme devant être envoyée - await db.collection('alerts').doc(alertId).update({ + 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); + logger.error("[onAlertCreated] Erreur:", error); } }); - -// ==================== ALERT TRIGGERS ==================== -// Temporairement désactivé - erreur de permissions Eventarc -// const {onAlertCreated} = require('./onAlertCreated'); -// exports.onAlertCreated = onAlertCreated; - -// ============================================================================ -// EQUIPMENTS - Pagination et filtrage avancé -// ============================================================================ - -/** - * Récupère les équipements avec pagination et filtrage côté serveur - * - * Paramètres de requête supportés: - * - limit: nombre d'éléments par page (défaut: 20, max: 100) - * - startAfter: ID du dernier élément de la page précédente (pour pagination) - * - category: filtre par catégorie - * - status: filtre par statut - * - searchQuery: recherche textuelle (nom, ID, modèle, marque) - * - sortBy: champ de tri (défaut: 'id') - * - sortOrder: 'asc' ou 'desc' (défaut: 'asc') - */ -exports.getEquipmentsPaginated = 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; - } - - // Récupérer les paramètres de la requête - const params = req.method === 'GET' ? req.query : (req.body?.data || {}); - const limit = Math.min(parseInt(params.limit) || 20, 100); - const startAfterId = params.startAfter || null; - // Convertir en majuscules pour correspondre au format Firestore - const category = params.category ? params.category.toUpperCase() : null; - const status = params.status ? params.status.toUpperCase() : null; - const rawSearchQuery = typeof params.searchQuery === 'string' ? params.searchQuery.trim() : ''; - const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null; - const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, '') : null; - const sortBy = params.sortBy || 'id'; - const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc'; - - logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`); - - // Fast-path pour une recherche d'ID exact: évite le cap queryLimit lors d'une recherche précise. - if (searchQuery && !startAfterId) { - const exactIdCandidates = Array.from(new Set([ - rawSearchQuery, - rawSearchQuery.toUpperCase(), - rawSearchQuery.toLowerCase() - ].filter(Boolean))); - - for (const candidateId of exactIdCandidates) { - const exactDoc = await db.collection('equipments').doc(candidateId).get(); - if (!exactDoc.exists) { - continue; - } - - const exactData = exactDoc.data() || {}; - const matchesCategory = !category || exactData.category === category; - const matchesStatus = !status || exactData.status === status; - if (!matchesCategory || !matchesStatus) { - continue; - } - - if (!canManage) { - delete exactData.purchasePrice; - delete exactData.rentalPrice; - } - - const exactEquipment = { - ...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']), - id: exactDoc.id - }; - - logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`); - res.status(200).json({ - equipments: [exactEquipment], - hasMore: false, - lastVisible: exactDoc.id, - total: 1 - }); - return; - } - - // Compatibilité legacy: certains documents peuvent stocker un ancien champ `id` différent du document ID. - for (const legacyId of exactIdCandidates) { - let legacyIdQuery = db.collection('equipments').where('id', '==', legacyId); - if (category) { - legacyIdQuery = legacyIdQuery.where('category', '==', category); - } - if (status) { - legacyIdQuery = legacyIdQuery.where('status', '==', status); - } - - const legacySnapshot = await legacyIdQuery.limit(1).get(); - if (legacySnapshot.empty) { - continue; - } - - const exactDoc = legacySnapshot.docs[0]; - const exactData = exactDoc.data() || {}; - - if (!canManage) { - delete exactData.purchasePrice; - delete exactData.rentalPrice; - } - - const exactEquipment = { - ...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']), - id: exactDoc.id - }; - - logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`); - res.status(200).json({ - equipments: [exactEquipment], - hasMore: false, - lastVisible: exactDoc.id, - total: 1 - }); - return; - } - } - - // Construire la requête Firestore - let query = db.collection('equipments'); - - // Appliquer les filtres - if (category) { - query = query.where('category', '==', category); - } - if (status) { - query = query.where('status', '==', status); - } - - // Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document - // Cela garantit que TOUS les documents sont inclus, même sans champ 'id' - if (sortBy === 'id') { - query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder); - } else { - query = query.orderBy(sortBy, sortOrder); - } - - // Pagination - if (startAfterId) { - const startAfterDoc = await db.collection('equipments').doc(startAfterId).get(); - if (startAfterDoc.exists) { - query = query.startAfter(startAfterDoc); - } - } - - const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']; - - const mapEquipmentDoc = (doc) => { - const data = {...(doc.data() || {})}; - - // Masquer les prix si l'utilisateur n'a pas manage_equipment - if (!canManage) { - delete data.purchasePrice; - delete data.rentalPrice; - } - - const legacyId = typeof data.id === 'string' ? data.id : ''; - - return { - ...helpers.serializeTimestamps(data, timestampFields), - id: doc.id, - _legacyId: legacyId - }; - }; - - const matchesSearchQuery = (equipment) => { - const searchableText = [ - equipment.name || '', - equipment.id || '', - equipment._legacyId || '', - equipment.model || '', - equipment.brand || '', - equipment.subCategory || '' - ].join(' ').toLowerCase(); - - if (searchableText.includes(searchQuery)) { - return true; - } - - if (!compactSearchQuery) { - return false; - } - - const compactSearchableText = searchableText.replace(/[\s_-]+/g, ''); - return compactSearchableText.includes(compactSearchQuery); - }; - - if (!searchQuery) { - const snapshot = await query.limit(limit + 1).get(); - const rawDocCount = snapshot.docs.length; - const hasMoreDocs = rawDocCount > limit; - const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs; - - const limitedEquipments = docsToProcess - .map(mapEquipmentDoc) - .map(({_legacyId, ...equipment}) => equipment); - const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null; - - logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`); - logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`); - - res.status(200).json({ - equipments: limitedEquipments, - hasMore: hasMoreDocs, - lastVisible, - total: limitedEquipments.length - }); - return; - } - - // En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs - // afin de garantir des résultats même si les documents pertinents sont loin dans l'ordre de tri. - const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200); - const matchedEquipments = []; - let scannedDocuments = 0; - let searchQueryRef = query; - let hasMoreMatches = false; - let hasMoreDocsToScan = true; - - while (hasMoreDocsToScan && !hasMoreMatches) { - const snapshot = await searchQueryRef.limit(searchBatchSize).get(); - - if (snapshot.empty) { - hasMoreDocsToScan = false; - break; - } - - scannedDocuments += snapshot.docs.length; - - for (const doc of snapshot.docs) { - const equipment = mapEquipmentDoc(doc); - if (!matchesSearchQuery(equipment)) { - continue; - } - - matchedEquipments.push(equipment); - if (matchedEquipments.length > limit) { - hasMoreMatches = true; - break; - } - } - - if (hasMoreMatches) { - break; - } - - if (snapshot.docs.length < searchBatchSize) { - hasMoreDocsToScan = false; - break; - } - - const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1]; - searchQueryRef = query.startAfter(lastDocInBatch); - } - - const limitedEquipments = matchedEquipments - .slice(0, limit) - .map(({_legacyId, ...equipment}) => equipment); - const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null; - - logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`); - logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`); - - res.status(200).json({ - equipments: limitedEquipments, - hasMore: hasMoreMatches, - lastVisible, - total: limitedEquipments.length - }); - - } catch (error) { - logger.error("Error fetching paginated equipments:", error); - res.status(500).json({ error: error.message }); - } -})); - -// ============================================================================ -// CONTAINERS - Pagination et filtrage avancé -// ============================================================================ - -/** - * Récupère les containers avec pagination et filtrage côté serveur - * - * Paramètres similaires à getEquipmentsPaginated - */ -exports.getContainersPaginated = 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; - } - - // Récupérer les paramètres de la requête - const params = req.method === 'GET' ? req.query : (req.body?.data || {}); - const limit = Math.min(parseInt(params.limit) || 20, 100); - const startAfterId = params.startAfter || null; - // Convertir en majuscules pour correspondre au format Firestore - const type = params.type ? params.type.toUpperCase() : null; - const status = params.status ? params.status.toUpperCase() : null; - const searchQuery = params.searchQuery?.toLowerCase() || null; - const category = params.category ? params.category.toUpperCase() : null; // Filtre par catégorie d'équipements - const sortBy = params.sortBy || 'id'; - const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc'; - - logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`); - - // Construire la requête Firestore - let query = db.collection('containers'); - - // Si recherche textuelle ou filtre par catégorie, on augmente la limite pour filtrer ensuite - const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit; - - // Appliquer les filtres sur les containers - if (type) { - query = query.where('type', '==', type); - } - if (status) { - query = query.where('status', '==', status); - } - - // Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document - if (sortBy === 'id') { - query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder); - } else { - query = query.orderBy(sortBy, sortOrder); - } - - // Pagination - if (startAfterId) { - const startAfterDoc = await db.collection('containers').doc(startAfterId).get(); - if (startAfterDoc.exists) { - query = query.startAfter(startAfterDoc); - } - } - - // Limiter les résultats - query = query.limit(queryLimit + 1); - - const snapshot = await query.get(); - - // Déterminer hasMore basé sur le nombre de documents Firestore - const rawDocCount = snapshot.docs.length; - const hasMoreDocs = rawDocCount > queryLimit; - const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs; - - let containers = docsToProcess.map(doc => { - const data = doc.data(); - return { - id: doc.id, - ...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt']) - }; - }); - - // Récupérer tous les équipements liés aux containers (pour population ET filtrage) - const allEquipmentIds = new Set(); - containers.forEach(c => { - if (c.equipmentIds && Array.isArray(c.equipmentIds)) { - c.equipmentIds.forEach(id => allEquipmentIds.add(id)); - } - }); - - // Charger les équipements en batch (max 30 par requête Firestore) - const equipmentMap = new Map(); - if (allEquipmentIds.size > 0) { - const equipmentIdArray = Array.from(allEquipmentIds); - const batchSize = 30; // Limite Firestore pour les requêtes 'in' - - for (let i = 0; i < equipmentIdArray.length; i += batchSize) { - const batch = equipmentIdArray.slice(i, i + batchSize); - const equipmentSnapshot = await db.collection('equipments') - .where(admin.firestore.FieldPath.documentId(), 'in', batch) - .get(); - - equipmentSnapshot.docs.forEach(doc => { - const equipmentData = doc.data(); - equipmentMap.set(doc.id, { - id: doc.id, - ...helpers.serializeTimestamps(equipmentData) - }); - }); - } - } - - // Peupler les containers avec leurs équipements - containers = containers.map(container => ({ - ...container, - equipment: (container.equipmentIds || []) - .map(eqId => equipmentMap.get(eqId)) - .filter(eq => eq !== undefined) // Retirer les équipements non trouvés - })); - - // Filtrage par catégorie d'équipements - if (category) { - containers = containers.filter(c => { - // Garder le container s'il contient au moins un équipement de la catégorie demandée - return c.equipment.some(eq => eq.category === category); - }); - } - - // Filtrage textuel côté serveur - if (searchQuery) { - containers = containers.filter(c => { - const searchableText = [ - c.name || '', - c.id || '', - ...(c.equipment || []).map(eq => eq.name || '') - ].join(' ').toLowerCase(); - return searchableText.includes(searchQuery); - }); - } - - // Pour la limite finale après filtrage - const limitedContainers = containers.slice(0, limit); - const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null; - - // Log pour debugging - const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0); - logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`); - - // Log détaillé pour chaque container - limitedContainers.forEach(c => { - logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`); - }); - - res.status(200).json({ - containers: limitedContainers, - hasMore: containers.length > limit || hasMoreDocs, - lastVisible, - total: limitedContainers.length - }); - - } catch (error) { - logger.error("Error fetching paginated containers:", error); - res.status(500).json({ error: error.message }); - } -})); - -// ============================================================================ -// 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 }); - } -})); - -// ============================================================================ -// TEXT-TO-SPEECH - Generate TTS Audio -// ============================================================================ -// Options HTTP spécifiques pour TTS avec CORS activé -const ttsHttpOptions = { - cors: true, // Activer CORS automatique - invoker: 'public', - region: 'europe-west9', -}; - -exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => { - try { - // Authentification utilisateur - const decodedToken = await auth.authenticateUser(req); - - logger.info('[generateTTSV2] Request from user:', { - uid: decodedToken.uid, - email: decodedToken.email, - }); - - // Récupération des paramètres - const { text, voiceConfig } = req.body.data || {}; - - // Validation - if (!text) { - res.status(400).json({ error: 'Text parameter is required' }); - return; - } - - if (text.length > 5000) { - res.status(400).json({ error: 'Text too long (max 5000 characters)' }); - return; - } - - // Génération de l'audio avec cache - const bucketName = admin.storage().bucket().name; - const bucket = storage.bucket(bucketName); - - const result = await generateTTS(text, storage, bucket, voiceConfig); - - logger.info('[generateTTSV2] ✓ Success', { - cached: result.cached, - cacheKey: result.cacheKey, - }); - - res.status(200).json({ - audioUrl: result.audioUrl, - cached: result.cached, - cacheKey: result.cacheKey, - }); - - } catch (error) { - logger.error('[generateTTSV2] ✗ Error:', { - error: error.message, - code: error.code, - }); - - // Gestion des erreurs spécifiques - if (error.code === 'PERMISSION_DENIED') { - res.status(403).json({ error: 'Permission denied. Check Google Cloud TTS API is enabled.' }); - } else if (error.code === 'QUOTA_EXCEEDED') { - res.status(429).json({ error: 'TTS quota exceeded. Try again later.' }); - } else { - res.status(500).json({ error: error.message }); - } - } -}); - -// ============================================================================ -// AI - Assistant Logisticien (Gemini avec function calling côté serveur) -// ============================================================================ - -exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => { - try { - // Authentification Firebase obligatoire (pas de clé API côté client) - await auth.authenticateUser(req); - await handleAiEquipmentProposal(req, res); - } catch (error) { - logger.error('[aiEquipmentProposal] Error:', error); - if (!res.headersSent) { - res.status(500).json({ error: error.message }); - } - } -})); - diff --git a/em2rp/functions/migrate_email_prefs.js b/em2rp/functions/migrate_email_prefs.js index 61b278d..5703a38 100644 --- a/em2rp/functions/migrate_email_prefs.js +++ b/em2rp/functions/migrate_email_prefs.js @@ -2,17 +2,17 @@ * Script de migration : Active les emails pour tous les utilisateurs existants * À exécuter une seule fois après le déploiement */ -const admin = require('firebase-admin'); -const logger = require('firebase-functions/logger'); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); // AJOUTER CECI : Charger le fichier de clé -const serviceAccount = require('./serviceAccountKey.json'); +const serviceAccount = require("./serviceAccountKey.json"); // Initialiser Firebase Admin avec les credentials explicites if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici - projectId: 'em2rp-951dc', + projectId: "em2rp-951dc", }); } @@ -22,11 +22,11 @@ const db = admin.firestore(); * Active les notifications par email pour tous les utilisateurs existants */ async function migrateEmailPreferences() { - console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n'); + console.log("=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n"); try { // 1. Récupérer tous les utilisateurs - const usersSnapshot = await db.collection('users').get(); + const usersSnapshot = await db.collection("users").get(); console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`); // 2. Préparer les updates @@ -49,7 +49,7 @@ async function migrateEmailPreferences() { updates.push({ ref: doc.ref, data: { - 'notificationPreferences.emailEnabled': true, + "notificationPreferences.emailEnabled": true, }, }); } @@ -83,7 +83,7 @@ async function migrateEmailPreferences() { console.log(`\n✓ Aucune mise à jour nécessaire\n`); } - console.log('=== FIN MIGRATION ==='); + console.log("=== FIN MIGRATION ==="); return { success: true, total: usersSnapshot.size, @@ -91,7 +91,7 @@ async function migrateEmailPreferences() { updated: toUpdate, }; } catch (error) { - console.error('❌ ERREUR MIGRATION:', error); + console.error("❌ ERREUR MIGRATION:", error); throw error; } } @@ -99,15 +99,15 @@ async function migrateEmailPreferences() { // Exécuter la migration si appelé directement if (require.main === module) { migrateEmailPreferences() - .then((result) => { - console.log('\n✓ Migration réussie:', result); - process.exit(0); - }) - .catch((error) => { - console.error('\n❌ Migration échouée:', error); - process.exit(1); - }); + .then((result) => { + console.log("\n✓ Migration réussie:", result); + process.exit(0); + }) + .catch((error) => { + console.error("\n❌ Migration échouée:", error); + process.exit(1); + }); } -module.exports = { migrateEmailPreferences }; +module.exports = {migrateEmailPreferences}; diff --git a/em2rp/functions/migrate_equipment_ids.js b/em2rp/functions/migrate_equipment_ids.js index 77210be..be4b1b5 100644 --- a/em2rp/functions/migrate_equipment_ids.js +++ b/em2rp/functions/migrate_equipment_ids.js @@ -5,28 +5,28 @@ * le champ 'id' avec la valeur du document ID si ce champ est manquant. */ -const admin = require('firebase-admin'); -const serviceAccount = require('./serviceAccountKey.json'); +const admin = require("firebase-admin"); +const serviceAccount = require("./serviceAccountKey.json"); // Initialiser Firebase Admin admin.initializeApp({ - credential: admin.credential.cert(serviceAccount) + credential: admin.credential.cert(serviceAccount), }); const db = admin.firestore(); async function migrateEquipmentIds() { - console.log('🔧 Migration: Ajout du champ id aux équipements'); - console.log('================================================\n'); + console.log("🔧 Migration: Ajout du champ id aux équipements"); + console.log("================================================\n"); try { // Récupérer tous les équipements - const equipmentsSnapshot = await db.collection('equipments').get(); + const equipmentsSnapshot = await db.collection("equipments").get(); console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`); let missingIdCount = 0; let updatedCount = 0; - let errorCount = 0; + const errorCount = 0; const batch = db.batch(); let batchCount = 0; @@ -34,12 +34,12 @@ async function migrateEquipmentIds() { const data = doc.data(); // Vérifier si le champ 'id' est manquant ou vide - if (!data.id || data.id === '') { + if (!data.id || data.id === "") { missingIdCount++; - console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`); + console.log(`❌ Équipement ${doc.id} (${data.name || "Sans nom"}) : champ 'id' manquant`); // Ajouter au batch - batch.update(doc.ref, { id: doc.id }); + batch.update(doc.ref, {id: doc.id}); batchCount++; updatedCount++; @@ -58,36 +58,35 @@ async function migrateEquipmentIds() { console.log(`✅ Batch final de ${batchCount} documents mis à jour`); } - console.log('\n================================================'); - console.log('📊 RÉSUMÉ DE LA MIGRATION'); - console.log('================================================'); + console.log("\n================================================"); + console.log("📊 RÉSUMÉ DE LA MIGRATION"); + console.log("================================================"); console.log(`Total d'équipements: ${equipmentsSnapshot.size}`); console.log(`Équipements avec 'id' manquant: ${missingIdCount}`); console.log(`Équipements mis à jour: ${updatedCount}`); console.log(`Erreurs: ${errorCount}`); - console.log('================================================\n'); + console.log("================================================\n"); if (missingIdCount === 0) { - console.log('✅ Tous les équipements ont déjà un champ id !'); + console.log("✅ Tous les équipements ont déjà un champ id !"); } else if (updatedCount === missingIdCount) { - console.log('✅ Migration terminée avec succès !'); + console.log("✅ Migration terminée avec succès !"); } else { - console.log('⚠️ Migration terminée avec des erreurs'); + console.log("⚠️ Migration terminée avec des erreurs"); } - } catch (error) { - console.error('❌ Erreur lors de la migration:', error); + console.error("❌ Erreur lors de la migration:", error); throw error; } } // Exécuter la migration migrateEquipmentIds() - .then(() => { - console.log('\n✅ Script terminé'); - process.exit(0); - }) - .catch(error => { - console.error('\n❌ Script échoué:', error); - process.exit(1); - }); + .then(() => { + console.log("\n✅ Script terminé"); + process.exit(0); + }) + .catch((error) => { + console.error("\n❌ Script échoué:", error); + process.exit(1); + }); diff --git a/em2rp/functions/processEquipmentValidation.js b/em2rp/functions/processEquipmentValidation.js index d7c800f..21acbc8 100644 --- a/em2rp/functions/processEquipmentValidation.js +++ b/em2rp/functions/processEquipmentValidation.js @@ -1,8 +1,8 @@ -const {onCall} = require('firebase-functions/v2/https'); -const admin = require('firebase-admin'); -const logger = require('firebase-functions/logger'); -const nodemailer = require('nodemailer'); -const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig'); +const {onCall} = require("firebase-functions/v2/https"); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); +const nodemailer = require("nodemailer"); +const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig"); /** * Traite la validation du matériel d'un événement * Appelée par le client lors du chargement/déchargement @@ -10,14 +10,14 @@ const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig'); */ exports.processEquipmentValidation = onCall({ cors: true, - region: 'europe-west9' + region: "europe-west9", }, async (request) => { try { // L'authentification est automatique avec onCall const {auth, data} = request; if (!auth) { - throw new Error('L\'utilisateur doit être authentifié'); + throw new Error("L'utilisateur doit être authentifié"); } const { @@ -28,22 +28,22 @@ exports.processEquipmentValidation = onCall({ // Validation if (!eventId || !equipmentList || !validationType) { - throw new Error('eventId, equipmentList et validationType sont requis'); + throw new Error("eventId, equipmentList et validationType sont requis"); } const db = admin.firestore(); const alerts = []; // 1. Récupérer les détails de l'événement - const eventRef = db.collection('events').doc(eventId); + const eventRef = db.collection("events").doc(eventId); const eventDoc = await eventRef.get(); if (!eventDoc.exists) { - throw new Error('Événement introuvable'); + throw new Error("Événement introuvable"); } const event = eventDoc.data(); - const eventName = event.Name || event.name || 'Événement inconnu'; + const eventName = event.Name || event.name || "Événement inconnu"; const eventDate = formatEventDate(event); // 2. Analyser les équipements et détecter les problèmes @@ -51,16 +51,16 @@ exports.processEquipmentValidation = onCall({ const {equipmentId, status, quantity, expectedQuantity} = equipment; // Équipement non emporté: pas d'alerte de perte/manquant au retour. - if (status === 'NOT_TAKEN') { + if (status === "NOT_TAKEN") { continue; } // Cas 1: Équipement PERDU - if (status === 'LOST') { + if (status === "LOST") { const alertData = await createAlertInFirestore({ - type: 'LOST', - severity: 'CRITICAL', - title: 'Équipement perdu', + type: "LOST", + severity: "CRITICAL", + title: "Équipement perdu", message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`, equipmentId, eventId, @@ -76,11 +76,11 @@ exports.processEquipmentValidation = onCall({ } // Cas 2: Équipement MANQUANT - if (status === 'MISSING') { + if (status === "MISSING") { const alertData = await createAlertInFirestore({ - type: 'EQUIPMENT_MISSING', - severity: 'WARNING', - title: 'Équipement manquant', + type: "EQUIPMENT_MISSING", + severity: "WARNING", + title: "Équipement manquant", message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`, equipmentId, eventId, @@ -96,13 +96,13 @@ exports.processEquipmentValidation = onCall({ } // Cas 3: Quantité incorrecte - const hasExpectedQuantity = typeof expectedQuantity === 'number'; - const hasActualQuantity = typeof quantity === 'number'; + const hasExpectedQuantity = typeof expectedQuantity === "number"; + const hasActualQuantity = typeof quantity === "number"; if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) { const alertData = await createAlertInFirestore({ - type: 'QUANTITY_MISMATCH', - severity: 'INFO', - title: 'Quantité incorrecte', + type: "QUANTITY_MISMATCH", + severity: "INFO", + title: "Quantité incorrecte", message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`, equipmentId, eventId, @@ -120,11 +120,11 @@ exports.processEquipmentValidation = onCall({ } // Cas 4: Équipement endommagé - if (status === 'DAMAGED') { + if (status === "DAMAGED") { const alertData = await createAlertInFirestore({ - type: 'DAMAGED', - severity: 'WARNING', - title: 'Équipement endommagé', + type: "DAMAGED", + severity: "WARNING", + title: "Équipement endommagé", message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`, equipmentId, eventId, @@ -151,7 +151,7 @@ exports.processEquipmentValidation = onCall({ }); // 4. Envoyer les notifications pour les alertes critiques - const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL'); + const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL"); if (criticalAlerts.length > 0) { for (const alert of criticalAlerts) { try { @@ -169,7 +169,7 @@ exports.processEquipmentValidation = onCall({ alertIds: alerts.map((a) => a.id), }; } catch (error) { - logger.error('[processEquipmentValidation] Erreur:', error); + logger.error("[processEquipmentValidation] Erreur:", error); throw error; } }); @@ -179,14 +179,14 @@ exports.processEquipmentValidation = onCall({ */ async function createAlertInFirestore(alertData) { const db = admin.firestore(); - const alertRef = db.collection('alerts').doc(); + const alertRef = db.collection("alerts").doc(); const fullAlertData = { id: alertRef.id, ...alertData, createdAt: admin.firestore.FieldValue.serverTimestamp(), isRead: false, - status: 'ACTIVE', + status: "ACTIVE", emailSent: false, assignedTo: [], }; @@ -206,7 +206,7 @@ async function sendAlertNotifications(alert, eventId) { try { // 1. Récupérer TOUS les utilisateurs et leurs permissions - const allUsersSnapshot = await db.collection('users').get(); + const allUsersSnapshot = await db.collection("users").get(); // Créer un map pour stocker les références de rôles à récupérer const roleRefs = new Map(); @@ -219,17 +219,17 @@ async function sendAlertNotifications(alert, eventId) { } // Extraire le chemin du rôle - let rolePath = ''; - let roleId = ''; + let rolePath = ""; + let roleId = ""; - if (typeof user.role === 'string') { + if (typeof user.role === "string") { rolePath = user.role; - roleId = user.role.split('/').pop(); + roleId = user.role.split("/").pop(); } else if (user.role.path) { rolePath = user.role.path; - roleId = user.role.path.split('/').pop(); + roleId = user.role.path.split("/").pop(); } else if (user.role._path && user.role._path.segments) { - rolePath = user.role._path.segments.join('/'); + rolePath = user.role._path.segments.join("/"); roleId = user.role._path.segments[user.role._path.segments.length - 1]; } @@ -245,14 +245,14 @@ async function sendAlertNotifications(alert, eventId) { // 2. Récupérer les permissions de chaque rôle unique for (const [roleId, {users, rolePath}] of roleRefs.entries()) { try { - const roleDoc = await db.collection('roles').doc(roleId).get(); + const roleDoc = await db.collection("roles").doc(roleId).get(); if (roleDoc.exists) { const roleData = roleDoc.data(); const permissions = roleData.permissions || []; // Vérifier si le rôle a la permission view_all_events - if (permissions.includes('view_all_events')) { + if (permissions.includes("view_all_events")) { users.forEach((userId) => { usersWithPermission.add(userId); targetUserIds.add(userId); @@ -266,7 +266,7 @@ async function sendAlertNotifications(alert, eventId) { // 3. Ajouter la workforce de l'événement if (eventId) { - const eventDoc = await db.collection('events').doc(eventId).get(); + const eventDoc = await db.collection("events").doc(eventId).get(); if (eventDoc.exists) { const event = eventDoc.data(); @@ -276,14 +276,14 @@ async function sendAlertNotifications(alert, eventId) { // Extraire l'userId selon différentes structures possibles let userId = null; - if (typeof member === 'string') { + if (typeof member === "string") { userId = member; } else if (member.userId) { userId = member.userId; } else if (member.id) { userId = member.id; } else if (member.user) { - if (typeof member.user === 'string') { + if (typeof member.user === "string") { userId = member.user; } else if (member.user.id) { userId = member.user.id; @@ -300,18 +300,18 @@ async function sendAlertNotifications(alert, eventId) { const userIds = Array.from(targetUserIds); // 4. Mettre à jour l'alerte avec la liste des utilisateurs - await db.collection('alerts').doc(alert.id).update({ + await db.collection("alerts").doc(alert.id).update({ assignedTo: userIds, }); // 5. Envoyer les emails si alerte critique - if (alert.severity === 'CRITICAL') { + if (alert.severity === "CRITICAL") { await sendAlertEmails(alert, userIds); } return userIds; } catch (error) { - logger.error('[sendAlertNotifications] Erreur:', error); + logger.error("[sendAlertNotifications] Erreur:", error); throw error; } } @@ -321,12 +321,12 @@ async function sendAlertNotifications(alert, eventId) { */ async function sendAlertEmails(alert, userIds) { try { - const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates'); + const {renderTemplate, getEmailSubject, prepareTemplateData} = require("./utils/emailTemplates"); const db = admin.firestore(); // Vérifier que EMAIL_CONFIG est disponible if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) { - logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré'); + logger.error("[sendAlertEmails] EMAIL_CONFIG non configuré"); return 0; } @@ -343,7 +343,7 @@ async function sendAlertEmails(alert, userIds) { const promises = batch.map(async (userId) => { try { // Récupérer l'utilisateur - const userDoc = await db.collection('users').doc(userId).get(); + const userDoc = await db.collection("users").doc(userId).get(); if (!userDoc.exists) { return false; @@ -351,55 +351,55 @@ async function sendAlertEmails(alert, userIds) { const user = userDoc.data(); - // Vérifier les préférences email - const prefs = user.notificationPreferences || {}; - if (!prefs.emailEnabled) { - return false; - } + // Vérifier les préférences email + const prefs = user.notificationPreferences || {}; + if (!prefs.emailEnabled) { + return false; + } - if (!user.email) { - return false; - } + if (!user.email) { + return false; + } - // Préparer et envoyer l'email - let html; - try { - const templateData = await prepareTemplateData(alert, user); - html = await renderTemplate('alert-individual', templateData); - } catch (templateError) { - logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError); - html = ` + // Préparer et envoyer l'email + let html; + try { + const templateData = await prepareTemplateData(alert, user); + html = await renderTemplate("alert-individual", templateData); + } catch (templateError) { + logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError); + html = ` -

${alert.title || 'Nouvelle alerte'}

+

${alert.title || "Nouvelle alerte"}

${alert.message}

Voir l'alerte `; + } + + await transporter.sendMail({ + from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`, + to: user.email, + replyTo: EMAIL_CONFIG.replyTo, + subject: getEmailSubject(alert), + html: html, + text: alert.message, + }); + + return true; + } catch (error) { + logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error); + return false; } - - await transporter.sendMail({ - from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`, - to: user.email, - replyTo: EMAIL_CONFIG.replyTo, - subject: getEmailSubject(alert), - html: html, - text: alert.message, - }); - - return true; - } catch (error) { - logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error); - return false; - } - }); + }); const results = await Promise.all(promises); successCount += results.filter((r) => r).length; } // Mettre à jour l'alerte - await db.collection('alerts').doc(alert.id).update({ + await db.collection("alerts").doc(alert.id).update({ emailSent: true, emailSentAt: admin.firestore.FieldValue.serverTimestamp(), emailsSentCount: successCount, @@ -407,7 +407,7 @@ async function sendAlertEmails(alert, userIds) { return successCount; } catch (error) { - logger.error('[sendAlertEmails] Erreur globale:', error); + logger.error("[sendAlertEmails] Erreur globale:", error); return 0; } } @@ -425,10 +425,10 @@ function formatEventDate(event) { const parsedDate = parseFirestoreDate(rawDate); const safeDate = parsedDate || new Date(); - return safeDate.toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'numeric', - year: 'numeric', + return safeDate.toLocaleDateString("fr-FR", { + day: "numeric", + month: "numeric", + year: "numeric", }); } @@ -437,7 +437,7 @@ function parseFirestoreDate(value) { return null; } - if (typeof value.toDate === 'function') { + if (typeof value.toDate === "function") { return value.toDate(); } @@ -445,16 +445,16 @@ function parseFirestoreDate(value) { return value; } - if (typeof value === 'string' || typeof value === 'number') { + if (typeof value === "string" || typeof value === "number") { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } - if (typeof value === 'object' && typeof value.seconds === 'number') { + if (typeof value === "object" && typeof value.seconds === "number") { return new Date(value.seconds * 1000); } - if (typeof value === 'object' && typeof value._seconds === 'number') { + if (typeof value === "object" && typeof value._seconds === "number") { return new Date(value._seconds * 1000); } diff --git a/em2rp/functions/sendAlertEmail.js b/em2rp/functions/sendAlertEmail.js index 259b4f7..3521f7c 100644 --- a/em2rp/functions/sendAlertEmail.js +++ b/em2rp/functions/sendAlertEmail.js @@ -1,51 +1,51 @@ -const {onCall} = require('firebase-functions/v2/https'); -const admin = require('firebase-admin'); -const nodemailer = require('nodemailer'); -const handlebars = require('handlebars'); -const fs = require('fs').promises; -const path = require('path'); -const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig'); +const {onCall} = require("firebase-functions/v2/https"); +const admin = require("firebase-admin"); +const nodemailer = require("nodemailer"); +const handlebars = require("handlebars"); +const fs = require("fs").promises; +const path = require("path"); +const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig"); /** * Envoie un email d'alerte à un utilisateur * Appelé par le client Dart via callable function */ exports.sendAlertEmail = onCall({ - region: 'europe-west9', - cors: true + region: "europe-west9", + cors: true, }, async (request) => { // Vérifier l'authentification if (!request.auth) { - throw new Error('L\'utilisateur doit être authentifié'); + throw new Error("L'utilisateur doit être authentifié"); } const {alertId, userId, templateType} = request.data; if (!alertId || !userId) { - throw new Error('alertId et userId sont requis'); + throw new Error("alertId et userId sont requis"); } try { // Récupérer l'alerte depuis Firestore const alertDoc = await admin.firestore() - .collection('alerts') + .collection("alerts") .doc(alertId) .get(); if (!alertDoc.exists) { - throw new Error('Alerte introuvable'); + throw new Error("Alerte introuvable"); } const alert = alertDoc.data(); // Récupérer l'utilisateur const userDoc = await admin.firestore() - .collection('users') + .collection("users") .doc(userId) .get(); if (!userDoc.exists) { - throw new Error('Utilisateur introuvable'); + throw new Error("Utilisateur introuvable"); } const user = userDoc.data(); @@ -54,7 +54,7 @@ exports.sendAlertEmail = onCall({ const prefs = user.notificationPreferences || {}; if (!prefs.emailEnabled) { console.log(`Email désactivé pour l'utilisateur ${userId}`); - return {success: true, skipped: true, reason: 'email_disabled'}; + return {success: true, skipped: true, reason: "email_disabled"}; } // Vérifier la préférence pour ce type d'alerte @@ -62,7 +62,7 @@ exports.sendAlertEmail = onCall({ const shouldSend = checkAlertPreference(alertType, prefs); if (!shouldSend) { console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`); - return {success: true, skipped: true, reason: 'alert_type_disabled'}; + return {success: true, skipped: true, reason: "alert_type_disabled"}; } // Préparer les données pour le template @@ -70,7 +70,7 @@ exports.sendAlertEmail = onCall({ // Rendre le template HTML const html = await renderTemplate( - templateType || 'alert-individual', + templateType || "alert-individual", templateData, ); @@ -88,7 +88,7 @@ exports.sendAlertEmail = onCall({ text: alert.message, }); - console.log('Email envoyé:', info.messageId); + console.log("Email envoyé:", info.messageId); // Marquer l'email comme envoyé dans l'alerte await alertDoc.ref.update({ @@ -102,7 +102,7 @@ exports.sendAlertEmail = onCall({ skipped: false, }; } catch (error) { - console.error('Erreur envoi email:', error); + console.error("Erreur envoi email:", error); throw new Error(`Erreur lors de l'envoi de l'email: ${error.message}`); } }); @@ -112,13 +112,13 @@ exports.sendAlertEmail = onCall({ */ function checkAlertPreference(alertType, preferences) { const typeMapping = { - 'EVENT_CREATED': 'eventsNotifications', - 'EVENT_MODIFIED': 'eventsNotifications', - 'EVENT_CANCELLED': 'eventsNotifications', - 'LOST': 'equipmentNotifications', - 'EQUIPMENT_MISSING': 'equipmentNotifications', - 'MAINTENANCE_REMINDER': 'maintenanceNotifications', - 'STOCK_LOW': 'stockNotifications', + "EVENT_CREATED": "eventsNotifications", + "EVENT_MODIFIED": "eventsNotifications", + "EVENT_CANCELLED": "eventsNotifications", + "LOST": "equipmentNotifications", + "EQUIPMENT_MISSING": "equipmentNotifications", + "MAINTENANCE_REMINDER": "maintenanceNotifications", + "STOCK_LOW": "stockNotifications", }; const prefKey = typeMapping[alertType]; @@ -130,12 +130,12 @@ function checkAlertPreference(alertType, preferences) { */ async function prepareTemplateData(alert, user) { const data = { - userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() || - 'Utilisateur', + userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() || + "Utilisateur", alertTitle: getAlertTitle(alert.type), alertMessage: alert.message, - isCritical: alert.severity === 'CRITICAL', - actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`, + isCritical: alert.severity === "CRITICAL", + actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`, appUrl: EMAIL_CONFIG.appUrl, unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`, year: new Date().getFullYear(), @@ -146,7 +146,7 @@ async function prepareTemplateData(alert, user) { if (alert.eventId) { try { const eventDoc = await admin.firestore() - .collection('events') + .collection("events") .doc(alert.eventId) .get(); @@ -155,22 +155,22 @@ async function prepareTemplateData(alert, user) { data.eventName = event.Name; if (event.StartDateTime) { const date = event.StartDateTime.toDate(); - data.eventDate = date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', + data.eventDate = date.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", }); } } } catch (error) { - console.error('Erreur récupération événement:', error); + console.error("Erreur récupération événement:", error); } } if (alert.equipmentId) { try { const eqDoc = await admin.firestore() - .collection('equipments') + .collection("equipments") .doc(alert.equipmentId) .get(); @@ -178,7 +178,7 @@ async function prepareTemplateData(alert, user) { data.equipmentName = eqDoc.data().name; } } catch (error) { - console.error('Erreur récupération équipement:', error); + console.error("Erreur récupération équipement:", error); } } @@ -190,16 +190,16 @@ async function prepareTemplateData(alert, user) { */ function getEmailSubject(alert) { const subjects = { - 'EVENT_CREATED': '📅 Nouvel événement créé', - 'EVENT_MODIFIED': '📝 Événement modifié', - 'EVENT_CANCELLED': '❌ Événement annulé', - 'LOST': '🔴 Alerte critique : Équipement perdu', - 'EQUIPMENT_MISSING': '⚠️ Équipement manquant', - 'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance', - 'STOCK_LOW': '📦 Stock faible', + "EVENT_CREATED": "📅 Nouvel événement créé", + "EVENT_MODIFIED": "📝 Événement modifié", + "EVENT_CANCELLED": "❌ Événement annulé", + "LOST": "🔴 Alerte critique : Équipement perdu", + "EQUIPMENT_MISSING": "⚠️ Équipement manquant", + "MAINTENANCE_REMINDER": "🔧 Rappel de maintenance", + "STOCK_LOW": "📦 Stock faible", }; - return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events'; + return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events"; } /** @@ -207,16 +207,16 @@ function getEmailSubject(alert) { */ function getAlertTitle(type) { const titles = { - 'EVENT_CREATED': 'Nouvel événement créé', - 'EVENT_MODIFIED': 'Événement modifié', - 'EVENT_CANCELLED': 'Événement annulé', - 'LOST': 'Équipement perdu', - 'EQUIPMENT_MISSING': 'Équipement manquant', - 'MAINTENANCE_REMINDER': 'Maintenance requise', - 'STOCK_LOW': 'Stock faible', + "EVENT_CREATED": "Nouvel événement créé", + "EVENT_MODIFIED": "Événement modifié", + "EVENT_CANCELLED": "Événement annulé", + "LOST": "Équipement perdu", + "EQUIPMENT_MISSING": "Équipement manquant", + "MAINTENANCE_REMINDER": "Maintenance requise", + "STOCK_LOW": "Stock faible", }; - return titles[type] || 'Nouvelle alerte'; + return titles[type] || "Nouvelle alerte"; } /** @@ -225,16 +225,16 @@ function getAlertTitle(type) { async function renderTemplate(templateName, data) { try { // Lire le template de base - const basePath = path.join(__dirname, 'templates', 'base-template.html'); - const baseTemplate = await fs.readFile(basePath, 'utf8'); + const basePath = path.join(__dirname, "templates", "base-template.html"); + const baseTemplate = await fs.readFile(basePath, "utf8"); // Lire le template de contenu const contentPath = path.join( __dirname, - 'templates', + "templates", `${templateName}.html`, ); - const contentTemplate = await fs.readFile(contentPath, 'utf8'); + const contentTemplate = await fs.readFile(contentPath, "utf8"); // Compiler les templates const compileContent = handlebars.compile(contentTemplate); @@ -249,7 +249,7 @@ async function renderTemplate(templateName, data) { content: renderedContent, }); } catch (error) { - console.error('Erreur rendu template:', error); + console.error("Erreur rendu template:", error); // Fallback vers un template simple return ` diff --git a/em2rp/functions/sendDailyDigest.js b/em2rp/functions/sendDailyDigest.js index 26b3323..11605a3 100644 --- a/em2rp/functions/sendDailyDigest.js +++ b/em2rp/functions/sendDailyDigest.js @@ -3,10 +3,10 @@ * S'exécute tous les jours à 8h00 (Europe/Paris) */ -const admin = require('firebase-admin'); -const logger = require('firebase-functions/logger'); -const nodemailer = require('nodemailer'); -const { getSmtpConfig } = require('./utils/emailConfig'); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); +const nodemailer = require("nodemailer"); +const {getSmtpConfig} = require("./utils/emailConfig"); /** * Fonction principale : envoie le digest quotidien @@ -14,11 +14,11 @@ const { getSmtpConfig } = require('./utils/emailConfig'); async function sendDailyDigest() { const db = admin.firestore(); - logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN ====='); + logger.info("[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN ====="); try { // 1. Récupérer tous les utilisateurs avec email activé - const usersSnapshot = await db.collection('users').get(); + const usersSnapshot = await db.collection("users").get(); const eligibleUsers = []; usersSnapshot.forEach((doc) => { @@ -30,8 +30,8 @@ async function sendDailyDigest() { eligibleUsers.push({ uid: doc.id, email: user.email, - firstName: user.firstName || 'Utilisateur', - lastName: user.lastName || '', + firstName: user.firstName || "Utilisateur", + lastName: user.lastName || "", }); } }); @@ -48,12 +48,12 @@ async function sendDailyDigest() { for (const user of eligibleUsers) { try { // Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h - const alertsSnapshot = await db.collection('alerts') - .where('assignedTo', 'array-contains', user.uid) - .where('isRead', '==', false) - .where('createdAt', '>=', yesterday) - .orderBy('createdAt', 'desc') - .get(); + const alertsSnapshot = await db.collection("alerts") + .where("assignedTo", "array-contains", user.uid) + .where("isRead", "==", false) + .where("createdAt", ">=", yesterday) + .orderBy("createdAt", "desc") + .get(); if (alertsSnapshot.empty) { continue; // Pas d'alertes non lues pour cet utilisateur @@ -61,7 +61,7 @@ async function sendDailyDigest() { const alerts = []; alertsSnapshot.forEach((doc) => { - alerts.push({ id: doc.id, ...doc.data() }); + alerts.push({id: doc.id, ...doc.data()}); }); logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`); @@ -77,11 +77,11 @@ async function sendDailyDigest() { } logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`); - logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN ====='); + logger.info("[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN ====="); - return { success: true, emailsSent }; + return {success: true, emailsSent}; } catch (error) { - logger.error('[sendDailyDigest] Erreur globale:', error); + logger.error("[sendDailyDigest] Erreur globale:", error); throw error; } } @@ -92,9 +92,9 @@ async function sendDailyDigest() { async function sendDigestEmail(transporter, user, alerts) { try { // Grouper les alertes par sévérité - const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL'); - const warningAlerts = alerts.filter(a => a.severity === 'WARNING'); - const infoAlerts = alerts.filter(a => a.severity === 'INFO'); + const criticalAlerts = alerts.filter((a) => a.severity === "CRITICAL"); + const warningAlerts = alerts.filter((a) => a.severity === "WARNING"); + const infoAlerts = alerts.filter((a) => a.severity === "INFO"); // Construire le HTML const html = buildDigestHtml(user, { @@ -125,7 +125,7 @@ async function sendDigestEmail(transporter, user, alerts) { function buildDigestHtml(user, alertsByType) { const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length; - let alertsHtml = ''; + let alertsHtml = ""; // Alertes critiques if (alertsByType.critical.length > 0) { @@ -134,7 +134,7 @@ function buildDigestHtml(user, alertsByType) {

🔴 Alertes critiques (${alertsByType.critical.length})

- ${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')} + ${alertsByType.critical.map((alert) => formatAlertItem(alert)).join("")} `; } @@ -146,7 +146,7 @@ function buildDigestHtml(user, alertsByType) {

⚠️ Avertissements (${alertsByType.warning.length})

- ${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')} + ${alertsByType.warning.map((alert) => formatAlertItem(alert)).join("")} `; } @@ -158,7 +158,7 @@ function buildDigestHtml(user, alertsByType) {

ℹ️ Informations (${alertsByType.info.length})

- ${alertsByType.info.map(alert => formatAlertItem(alert)).join('')} + ${alertsByType.info.map((alert) => formatAlertItem(alert)).join("")} `; } @@ -216,24 +216,24 @@ function buildDigestHtml(user, alertsByType) { */ function formatAlertItem(alert) { const date = alert.createdAt?.toDate ? - new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' + new Date(alert.createdAt.toDate()).toLocaleString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", }) : - 'Date inconnue'; + "Date inconnue"; // Type d'alerte en français const typeLabels = { - 'EQUIPMENT_MISSING': 'Équipement manquant', - 'LOST': 'Équipement perdu', - 'DAMAGED': 'Équipement endommagé', - 'QUANTITY_MISMATCH': 'Écart de quantité', - 'EVENT_CREATED': 'Événement créé', - 'EVENT_MODIFIED': 'Événement modifié', - 'WORKFORCE_ADDED': 'Ajout à la workforce', + "EQUIPMENT_MISSING": "Équipement manquant", + "LOST": "Équipement perdu", + "DAMAGED": "Équipement endommagé", + "QUANTITY_MISMATCH": "Écart de quantité", + "EVENT_CREATED": "Événement créé", + "EVENT_MODIFIED": "Événement modifié", + "WORKFORCE_ADDED": "Ajout à la workforce", }; const typeLabel = typeLabels[alert.type] || alert.type; @@ -245,7 +245,7 @@ function formatAlertItem(alert) { ${date}

- ${alert.message || 'Aucun message'} + ${alert.message || "Aucun message"}

`; @@ -256,12 +256,12 @@ function formatAlertItem(alert) { */ function getSeverityColor(severity) { switch (severity) { - case 'CRITICAL': return '#dc2626'; - case 'WARNING': return '#f59e0b'; - case 'INFO': return '#3b82f6'; - default: return '#6b7280'; + case "CRITICAL": return "#dc2626"; + case "WARNING": return "#f59e0b"; + case "INFO": return "#3b82f6"; + default: return "#6b7280"; } } -module.exports = { sendDailyDigest }; +module.exports = {sendDailyDigest}; diff --git a/em2rp/functions/src/alerts.js b/em2rp/functions/src/alerts.js new file mode 100644 index 0000000..02b2b14 --- /dev/null +++ b/em2rp/functions/src/alerts.js @@ -0,0 +1,72 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Récupère toutes les alertes (filtrées et limitées) +exports.getAlerts = async (req, res) => { + try { + await auth.authenticateUser(req); + + const snapshot = await db.collection("alerts") + .orderBy("createdAt", "desc") + .limit(100) + .get(); + + const alerts = snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + ...helpers.serializeTimestamps(data, ["createdAt"]), + }; + }); + + res.status(200).json({alerts}); + } catch (error) { + logger.error("Error fetching alerts:", error); + res.status(500).json({error: error.message}); + } +}; + +// Marquer une alerte comme lue +exports.markAlertAsRead = async (req, res) => { + try { + await auth.authenticateUser(req); + + const alertId = req.body.data?.alertId; + if (!alertId) { + res.status(400).json({error: "alertId is required"}); + return; + } + + await db.collection("alerts").doc(alertId).update({ + isRead: true, + }); + + res.status(200).json({success: true}); + } catch (error) { + logger.error("Error marking alert as read:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer une alerte +exports.deleteAlert = async (req, res) => { + try { + await auth.authenticateUser(req); + + const alertId = req.body.data?.alertId; + if (!alertId) { + res.status(400).json({error: "alertId is required"}); + return; + } + + await db.collection("alerts").doc(alertId).delete(); + + res.status(200).json({success: true}); + } catch (error) { + logger.error("Error deleting alert:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/availability.js b/em2rp/functions/src/availability.js new file mode 100644 index 0000000..ccbf9f5 --- /dev/null +++ b/em2rp/functions/src/availability.js @@ -0,0 +1,628 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Vérifie si un équipement est disponible pour une plage de dates +exports.checkEquipmentAvailability = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {equipmentId, startDate, endDate, excludeEventId} = req.body.data; + + if (!equipmentId || !startDate || !endDate) { + res.status(400).json({error: "equipmentId, startDate, and endDate are required"}); + return; + } + + logger.info(`Checking availability for equipment ${equipmentId} from ${startDate} to ${endDate}, excluding event: ${excludeEventId}`); + + const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate)); + const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate)); + + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + logger.info(`Found ${eventsSnapshot.docs.length} events to check`); + + const conflicts = []; + + for (const eventDoc of eventsSnapshot.docs) { + const event = eventDoc.data(); + + if (excludeEventId && eventDoc.id === excludeEventId) { + continue; + } + + let eventStart; let eventEnd; + if (event.StartDateTime) { + eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime); + } + if (event.EndDateTime) { + eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime); + } + + if (!eventStart || !eventEnd) { + continue; + } + + const assignedEquipment = event.assignedEquipment || []; + const assignedContainers = event.assignedContainers || []; + + const isEquipmentDirectlyAssigned = assignedEquipment.some((eq) => eq.equipmentId === equipmentId); + + let isEquipmentInAssignedContainer = false; + if (assignedContainers.length > 0) { + logger.info(`Event ${eventDoc.id} has ${assignedContainers.length} assigned containers`); + for (const containerId of assignedContainers) { + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (containerDoc.exists) { + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + logger.info(`Container ${containerId} contains equipment IDs: ${equipmentIds.join(", ")}`); + if (equipmentIds.includes(equipmentId)) { + isEquipmentInAssignedContainer = true; + logger.info(`Equipment ${equipmentId} found in container ${containerId} for event ${eventDoc.id}`); + break; + } + } + } + } + + if (isEquipmentDirectlyAssigned) { + logger.info(`Equipment ${equipmentId} is directly assigned to event ${eventDoc.id}`); + } + + if (!isEquipmentDirectlyAssigned && !isEquipmentInAssignedContainer) { + continue; + } + + const requestStart = startTimestamp.toDate(); + const requestEnd = endTimestamp.toDate(); + + const installationTime = event.InstallationTime || 0; + const disassemblyTime = event.DisassemblyTime || 0; + + const eventStartWithSetup = new Date(eventStart); + eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime); + + const eventEndWithTeardown = new Date(eventEnd); + eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime); + + const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup; + + if (hasOverlap) { + const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup)); + const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown)); + const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24)); + + logger.info(`Conflict detected: Equipment ${equipmentId} conflicts with event ${eventDoc.id} (${event.Name})`); + + const eventData = helpers.serializeTimestamps(event); + conflicts.push({ + eventId: eventDoc.id, + eventName: event.Name, + eventData: eventData, + startDate: eventStart.toISOString(), + endDate: eventEnd.toISOString(), + overlapDays: overlapDays, + }); + } + } + + logger.info(`Total conflicts found: ${conflicts.length}`); + + res.status(200).json({conflicts, available: conflicts.length === 0}); + } catch (error) { + logger.error("Error checking equipment availability:", error); + res.status(500).json({error: error.message || "Failed to check equipment availability"}); + } +}; + +// Vérifie la disponibilité d'un container et de son contenu +exports.checkContainerAvailability = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {containerId, startDate, endDate, excludeEventId} = req.body.data; + + if (!containerId || !startDate || !endDate) { + res.status(400).json({error: "containerId, startDate, and endDate are required"}); + return; + } + + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (!containerDoc.exists) { + throw new Error("Container not found"); + } + + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + + const startTimestamp = admin.firestore.Timestamp.fromDate(new Date(startDate)); + const endTimestamp = admin.firestore.Timestamp.fromDate(new Date(endDate)); + + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + const containerConflicts = []; + const equipmentConflicts = {}; + + for (const eventDoc of eventsSnapshot.docs) { + const event = eventDoc.data(); + + if (excludeEventId && eventDoc.id === excludeEventId) { + continue; + } + + let eventStart; let eventEnd; + if (event.StartDateTime) { + eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime); + } + if (event.EndDateTime) { + eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime); + } + + if (!eventStart || !eventEnd) { + continue; + } + + const assignedContainers = event.assignedContainers || []; + const isContainerAssigned = assignedContainers.includes(containerId); + + const assignedEquipment = event.assignedEquipment || []; + const conflictingEquipmentIds = equipmentIds.filter((eqId) => + assignedEquipment.some((eq) => eq.equipmentId === eqId), + ); + + if (!isContainerAssigned && conflictingEquipmentIds.length === 0) { + continue; + } + + const requestStart = startTimestamp.toDate(); + const requestEnd = endTimestamp.toDate(); + + const installationTime = event.InstallationTime || 0; + const disassemblyTime = event.DisassemblyTime || 0; + + const eventStartWithSetup = new Date(eventStart); + eventStartWithSetup.setHours(eventStartWithSetup.getHours() - installationTime); + + const eventEndWithTeardown = new Date(eventEnd); + eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + disassemblyTime); + + const hasOverlap = requestStart < eventEndWithTeardown && requestEnd > eventStartWithSetup; + + if (hasOverlap) { + const overlapStart = new Date(Math.max(requestStart, eventStartWithSetup)); + const overlapEnd = new Date(Math.min(requestEnd, eventEndWithTeardown)); + const overlapDays = Math.ceil((overlapEnd - overlapStart) / (1000 * 60 * 60 * 24)); + + const conflictInfo = { + eventId: eventDoc.id, + eventName: event.Name, + startDate: eventStart.toISOString(), + endDate: eventEnd.toISOString(), + overlapDays: overlapDays, + }; + + if (isContainerAssigned) { + containerConflicts.push(conflictInfo); + } + + conflictingEquipmentIds.forEach((eqId) => { + if (!equipmentConflicts[eqId]) { + equipmentConflicts[eqId] = []; + } + equipmentConflicts[eqId].push(conflictInfo); + }); + } + } + + const hasContainerConflict = containerConflicts.length > 0; + const hasPartialConflict = Object.keys(equipmentConflicts).length > 0 && !hasContainerConflict; + const conflictType = hasContainerConflict ? "complete" : (hasPartialConflict ? "partial" : "none"); + + res.status(200).json({ + conflictType, + containerConflicts, + equipmentConflicts, + isAvailable: conflictType === "none", + }); + } catch (error) { + logger.error("Error checking container availability:", error); + res.status(500).json({error: error.message || "Failed to check container availability"}); + } +}; + +// Récupère tous les équipements et conteneurs en conflit pour une période donnée +exports.getConflictingEquipmentIds = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {startDate, endDate, excludeEventId, installationTime = 0, disassemblyTime = 0} = req.body.data; + + if (!startDate || !endDate) { + res.status(400).json({error: "startDate and endDate are required"}); + return; + } + + logger.info(`Getting conflicting equipment IDs for period ${startDate} to ${endDate}`); + + const requestStartDate = new Date(startDate); + requestStartDate.setHours(requestStartDate.getHours() - installationTime); + + const requestEndDate = new Date(endDate); + requestEndDate.setHours(requestEndDate.getHours() + disassemblyTime); + + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + logger.info(`Found ${eventsSnapshot.docs.length} events to check`); + + const equipmentsSnapshot = await db.collection("equipments").get(); + const equipmentsInfo = {}; + equipmentsSnapshot.docs.forEach((doc) => { + const data = doc.data(); + equipmentsInfo[doc.id] = { + category: data.category, + totalQuantity: data.totalQuantity || 0, + hasQuantity: data.category === "CABLE" || data.category === "CONSUMABLE", + }; + }); + + const conflictingEquipmentIds = new Set(); + const conflictingContainerIds = new Set(); + const conflictDetails = {}; + const equipmentQuantities = {}; + + for (const eventDoc of eventsSnapshot.docs) { + if (excludeEventId && eventDoc.id === excludeEventId) { + continue; + } + + const event = eventDoc.data(); + + let eventStart; let eventEnd; + if (event.StartDateTime) { + eventStart = event.StartDateTime.toDate ? event.StartDateTime.toDate() : new Date(event.StartDateTime); + } + if (event.EndDateTime) { + eventEnd = event.EndDateTime.toDate ? event.EndDateTime.toDate() : new Date(event.EndDateTime); + } + + if (!eventStart || !eventEnd) { + continue; + } + + const eventInstallTime = event.InstallationTime || 0; + const eventDisassemblyTime = event.DisassemblyTime || 0; + + const eventStartWithSetup = new Date(eventStart); + eventStartWithSetup.setHours(eventStartWithSetup.getHours() - eventInstallTime); + + const eventEndWithTeardown = new Date(eventEnd); + eventEndWithTeardown.setHours(eventEndWithTeardown.getHours() + eventDisassemblyTime); + + const hasOverlap = requestStartDate < eventEndWithTeardown && requestEndDate > eventStartWithSetup; + + if (!hasOverlap) { + continue; + } + + const assignedEquipment = event.assignedEquipment || []; + const assignedContainers = event.assignedContainers || []; + + const conflictInfo = { + eventId: eventDoc.id, + eventName: event.Name, + startDate: eventStart.toISOString(), + endDate: eventEnd.toISOString(), + }; + + for (const eq of assignedEquipment) { + const equipmentId = eq.equipmentId; + const quantity = eq.quantity || 1; + const equipInfo = equipmentsInfo[equipmentId]; + + if (equipInfo && equipInfo.hasQuantity) { + if (!equipmentQuantities[equipmentId]) { + equipmentQuantities[equipmentId] = { + totalQuantity: equipInfo.totalQuantity, + reservedQuantity: 0, + availableQuantity: equipInfo.totalQuantity, + reservations: [], + }; + } + + equipmentQuantities[equipmentId].reservedQuantity += quantity; + equipmentQuantities[equipmentId].availableQuantity = equipInfo.totalQuantity - equipmentQuantities[equipmentId].reservedQuantity; + equipmentQuantities[equipmentId].reservations.push({ + ...conflictInfo, + quantity: quantity, + }); + + if (equipmentQuantities[equipmentId].availableQuantity <= 0) { + conflictingEquipmentIds.add(equipmentId); + } + } else { + conflictingEquipmentIds.add(equipmentId); + } + + if (!conflictDetails[equipmentId]) { + conflictDetails[equipmentId] = []; + } + conflictDetails[equipmentId].push({ + ...conflictInfo, + quantity: quantity, + }); + } + + for (const containerId of assignedContainers) { + conflictingContainerIds.add(containerId); + + if (!conflictDetails[containerId]) { + conflictDetails[containerId] = []; + } + conflictDetails[containerId].push(conflictInfo); + + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (containerDoc.exists) { + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + + for (const equipmentId of equipmentIds) { + conflictingEquipmentIds.add(equipmentId); + + if (!conflictDetails[equipmentId]) { + conflictDetails[equipmentId] = []; + } + conflictDetails[equipmentId].push({ + ...conflictInfo, + viaContainer: containerId, + viaContainerName: containerData.name || "Conteneur inconnu", + }); + } + } + } + } + + logger.info(`Found ${conflictingEquipmentIds.size} conflicting equipment(s) and ${conflictingContainerIds.size} conflicting container(s)`); + + res.status(200).json({ + conflictingEquipmentIds: Array.from(conflictingEquipmentIds), + conflictingContainerIds: Array.from(conflictingContainerIds), + conflictDetails: conflictDetails, + equipmentQuantities: equipmentQuantities, + }); + } catch (error) { + logger.error("Error getting conflicting equipment IDs:", error); + res.status(500).json({error: error.message || "Failed to get conflicting equipment IDs"}); + } +}; + +/** + * Trouver des alternatives (même modèle) disponibles pour une période donnée + */ +exports.findAlternativeEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {model, startDate, endDate} = req.body.data; + + if (!model || !startDate || !endDate) { + res.status(400).json({error: "model, startDate and endDate are required"}); + return; + } + + const start = admin.firestore.Timestamp.fromDate(new Date(startDate)); + const end = admin.firestore.Timestamp.fromDate(new Date(endDate)); + + // Récupérer tous les équipements du même modèle + const equipmentsSnapshot = await db.collection("equipments") + .where("model", "==", model) + .get(); + + // Récupérer tous les événements qui chevauchent la période + const eventsSnapshot = await db.collection("events") + .where("StartDateTime", "<=", end) + .where("EndDateTime", ">=", start) + .where("status", "!=", "CANCELLED") + .get(); + + // Créer un set des équipements en conflit + const conflictingEquipmentIds = new Set(); + eventsSnapshot.docs.forEach((doc) => { + const eventData = doc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + assignedEquipment.forEach((eq) => conflictingEquipmentIds.add(eq.equipmentId)); + }); + + // Filtrer les équipements disponibles + const alternatives = []; + equipmentsSnapshot.docs.forEach((doc) => { + const data = doc.data(); + if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") { + alternatives.push({ + id: doc.id, + ...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]), + }); + } + }); + + res.status(200).json({alternatives}); + } catch (error) { + logger.error("Error finding alternative equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +/** + * Calculer le statut réel d'un ou plusieurs équipements basé sur les événements en cours + */ +exports.calculateEquipmentStatuses = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {equipmentIds} = req.body.data; + + if (!equipmentIds || !Array.isArray(equipmentIds)) { + res.status(400).json({error: "equipmentIds array is required"}); + return; + } + + // Récupérer tous les événements en cours (préparation complétée mais pas encore retournés) + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + const equipmentIdsInUse = new Set(); + const containerIdsInUse = new Set(); + + eventsSnapshot.docs.forEach((doc) => { + const event = doc.data(); + + const isPrepared = event.preparationStatus === "completed" || + event.preparationStatus === "completedWithMissing"; + const isReturned = event.returnStatus === "completed" || + event.returnStatus === "completedWithMissing"; + + if (isPrepared && !isReturned) { + // Ajouter les équipements directs + const assignedEquipment = event.assignedEquipment || []; + assignedEquipment.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId)); + + // Ajouter les conteneurs + const assignedContainers = event.assignedContainers || []; + assignedContainers.forEach((containerId) => containerIdsInUse.add(containerId)); + } + }); + + // Récupérer les équipements dans les conteneurs en cours d'utilisation + if (containerIdsInUse.size > 0) { + const containersSnapshot = await db.collection("containers") + .where(admin.firestore.FieldPath.documentId(), "in", Array.from(containerIdsInUse)) + .get(); + + containersSnapshot.docs.forEach((doc) => { + const containerData = doc.data(); + const equipmentList = containerData.equipment || []; + equipmentList.forEach((eq) => equipmentIdsInUse.add(eq.equipmentId)); + }); + } + + // Récupérer les données des équipements demandés + const statuses = {}; + + for (const equipmentId of equipmentIds) { + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + + if (!equipmentDoc.exists) { + statuses[equipmentId] = null; + continue; + } + + const equipmentData = equipmentDoc.data(); + let calculatedStatus = equipmentData.status; + + // Si l'équipement est perdu ou HS, garder ce statut + if (equipmentData.status === "lost" || equipmentData.status === "outOfService") { + calculatedStatus = equipmentData.status; + } else if (equipmentIdsInUse.has(equipmentId)) { + calculatedStatus = "inUse"; + } else if (equipmentData.status === "maintenance" || + equipmentData.status === "rented") { + calculatedStatus = equipmentData.status; + } else { + calculatedStatus = "available"; + } + + statuses[equipmentId] = calculatedStatus; + } + + res.status(200).json({statuses}); + } catch (error) { + logger.error("Error calculating equipment statuses:", error); + res.status(500).json({error: error.message}); + } +}; + +/** + * Récupérer tous les événements en cours (pour le calcul de statuts) + */ +exports.getActiveEvents = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "view_events"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires view_events permission"}); + return; + } + + // Récupérer les événements en cours (préparation complétée mais pas encore retournés) + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + const activeEvents = []; + + eventsSnapshot.docs.forEach((doc) => { + const event = doc.data(); + + const isPrepared = event.preparationStatus === "completed" || + event.preparationStatus === "completedWithMissing"; + const isReturned = event.returnStatus === "completed" || + event.returnStatus === "completedWithMissing"; + + if (isPrepared && !isReturned) { + activeEvents.push({ + id: doc.id, + assignedEquipment: event.assignedEquipment || [], + assignedContainers: event.assignedContainers || [], + preparationStatus: event.preparationStatus, + returnStatus: event.returnStatus, + }); + } + }); + + res.status(200).json({events: activeEvents}); + } catch (error) { + logger.error("Error fetching active events:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/containers.js b/em2rp/functions/src/containers.js new file mode 100644 index 0000000..c284478 --- /dev/null +++ b/em2rp/functions/src/containers.js @@ -0,0 +1,504 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Créer un container +exports.createContainer = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const containerData = req.body.data; + const containerId = containerData.id; + + if (!containerId) { + res.status(400).json({error: "Container ID is required"}); + return; + } + + const existingDoc = await db.collection("containers").doc(containerId).get(); + if (existingDoc.exists) { + res.status(409).json({error: "Container ID already exists"}); + return; + } + + const dataToSave = helpers.deserializeTimestamps(containerData, ["createdAt", "updatedAt"]); + + await db.collection("containers").doc(containerId).set(dataToSave); + + res.status(201).json({id: containerId, message: "Container created successfully"}); + } catch (error) { + logger.error("Error creating container:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour un container +exports.updateContainer = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {containerId, data} = req.body.data; + + if (!containerId) { + res.status(400).json({error: "Container ID is required"}); + return; + } + + delete data.id; + data.updatedAt = admin.firestore.Timestamp.now(); + + await db.collection("containers").doc(containerId).update(data); + + res.status(200).json({message: "Container updated successfully"}); + } catch (error) { + logger.error("Error updating container:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer un container +exports.deleteContainer = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {containerId} = req.body.data; + + if (!containerId) { + res.status(400).json({error: "Container ID is required"}); + return; + } + + // Récupérer le container pour obtenir les équipements + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (containerDoc.exists) { + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + + // Retirer le container des parentBoxIds de chaque équipement + for (const equipmentId of equipmentIds) { + try { + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + const parentBoxIds = (equipmentData.parentBoxIds || []).filter((boxId) => boxId !== containerId); + await db.collection("equipments").doc(equipmentId).update({ + parentBoxIds: parentBoxIds, + updatedAt: admin.firestore.Timestamp.now(), + }); + } + } catch (err) { + logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err); + } + } + } + + await db.collection("containers").doc(containerId).delete(); + + res.status(200).json({message: "Container deleted successfully"}); + } catch (error) { + logger.error("Error deleting container:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer les containers contenant un équipement +exports.getContainersByEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasViewAccess && !hasManageAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {equipmentId} = req.body.data || {}; + + if (!equipmentId) { + res.status(400).json({error: "equipmentId is required"}); + return; + } + + const snapshot = await db.collection("containers") + .where("equipmentIds", "array-contains", equipmentId) + .get(); + + const containers = []; + snapshot.forEach((doc) => { + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + containers.push(data); + }); + + res.status(200).json({containers}); + } catch (error) { + logger.error("Error getting containers by equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer plusieurs containers par leurs IDs +exports.getContainersByIds = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasViewAccess && !hasManageAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {containerIds} = req.body.data || {}; + + if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) { + res.status(400).json({error: "containerIds array is required and must not be empty"}); + return; + } + + if (containerIds.length > 100) { + res.status(400).json({error: "Maximum 100 container IDs per request"}); + return; + } + + const promises = containerIds.map((id) => db.collection("containers").doc(id).get()); + const docs = await Promise.all(promises); + + const containers = []; + for (const doc of docs) { + if (doc.exists) { + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + containers.push(data); + } + } + + res.status(200).json({containers}); + } catch (error) { + logger.error("Error getting containers by IDs:", error); + res.status(500).json({error: error.message}); + } +}; + +// Ajouter un équipement à un container +exports.addEquipmentToContainer = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {containerId, equipmentId, userId} = req.body.data; + + if (!containerId || !equipmentId) { + res.status(400).json({error: "containerId and equipmentId are required"}); + return; + } + + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (!containerDoc.exists) { + res.status(404).json({success: false, message: "Container non trouvé"}); + return; + } + + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + + if (equipmentIds.includes(equipmentId)) { + res.status(400).json({success: false, message: "Cet équipement est déjà dans ce container"}); + return; + } + + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + if (!equipmentDoc.exists) { + res.status(404).json({success: false, message: "Équipement non trouvé"}); + return; + } + + const equipmentData = equipmentDoc.data(); + const parentBoxIds = equipmentData.parentBoxIds || []; + + const warnings = []; + if (parentBoxIds.length > 0) { + const otherContainersPromises = parentBoxIds.map((boxId) => + db.collection("containers").doc(boxId).get(), + ); + const otherContainersDocs = await Promise.all(otherContainersPromises); + const otherNames = otherContainersDocs + .filter((doc) => doc.exists) + .map((doc) => doc.data().name); + + if (otherNames.length > 0) { + warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`); + } + } + + await db.collection("containers").doc(containerId).update({ + equipmentIds: [...equipmentIds, equipmentId], + updatedAt: admin.firestore.Timestamp.now(), + }); + + await db.collection("equipments").doc(equipmentId).update({ + parentBoxIds: [...parentBoxIds, containerId], + updatedAt: admin.firestore.Timestamp.now(), + }); + + const history = containerData.history || []; + const historyEntry = { + timestamp: admin.firestore.Timestamp.now(), + action: "equipment_added", + equipmentId: equipmentId, + newValue: equipmentId, + userId: userId || decodedToken.uid, + }; + + const updatedHistory = [...history, historyEntry].slice(-100); + + await db.collection("containers").doc(containerId).update({ + history: updatedHistory, + }); + + res.status(200).json({ + success: true, + message: "Équipement ajouté avec succès", + warnings: warnings.length > 0 ? warnings[0] : null, + }); + } catch (error) { + logger.error("Error adding equipment to container:", error); + res.status(500).json({success: false, message: error.message}); + } +}; + +// Retirer un équipement d'un container +exports.removeEquipmentFromContainer = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {containerId, equipmentId, userId} = req.body.data; + + if (!containerId || !equipmentId) { + res.status(400).json({error: "containerId and equipmentId are required"}); + return; + } + + const containerDoc = await db.collection("containers").doc(containerId).get(); + if (!containerDoc.exists) { + res.status(404).json({error: "Container non trouvé"}); + return; + } + + const containerData = containerDoc.data(); + const equipmentIds = containerData.equipmentIds || []; + + const updatedEquipmentIds = equipmentIds.filter((id) => id !== equipmentId); + + await db.collection("containers").doc(containerId).update({ + equipmentIds: updatedEquipmentIds, + updatedAt: admin.firestore.Timestamp.now(), + }); + + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + const parentBoxIds = equipmentData.parentBoxIds || []; + const updatedParentBoxIds = parentBoxIds.filter((id) => id !== containerId); + + await db.collection("equipments").doc(equipmentId).update({ + parentBoxIds: updatedParentBoxIds, + updatedAt: admin.firestore.Timestamp.now(), + }); + } + + const history = containerData.history || []; + const historyEntry = { + timestamp: admin.firestore.Timestamp.now(), + action: "equipment_removed", + equipmentId: equipmentId, + previousValue: equipmentId, + userId: userId || decodedToken.uid, + }; + + const updatedHistory = [...history, historyEntry].slice(-100); + + await db.collection("containers").doc(containerId).update({ + history: updatedHistory, + }); + + res.status(200).json({success: true}); + } catch (error) { + logger.error("Error removing equipment from container:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer les containers avec pagination et filtrage côté serveur +exports.getContainersPaginated = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + // Vérifier les permissions + const canView = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!canView) { + res.status(403).json({error: "Forbidden: Requires equipment permissions"}); + return; + } + + // Récupérer les paramètres de la requête + const params = req.method === "GET" ? req.query : (req.body?.data || {}); + const limit = Math.min(parseInt(params.limit) || 20, 100); + const startAfterId = params.startAfter || null; + const type = params.type ? params.type.toUpperCase() : null; + const status = params.status ? params.status.toUpperCase() : null; + const searchQuery = params.searchQuery?.toLowerCase() || null; + const category = params.category ? params.category.toUpperCase() : null; + const sortBy = params.sortBy || "id"; + const sortOrder = params.sortOrder === "desc" ? "desc" : "asc"; + + logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`); + + let query = db.collection("containers"); + + const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit; + + if (type) { + query = query.where("type", "==", type); + } + if (status) { + query = query.where("status", "==", status); + } + + if (sortBy === "id") { + query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder); + } else { + query = query.orderBy(sortBy, sortOrder); + } + + if (startAfterId) { + const startAfterDoc = await db.collection("containers").doc(startAfterId).get(); + if (startAfterDoc.exists) { + query = query.startAfter(startAfterDoc); + } + } + + query = query.limit(queryLimit + 1); + + const snapshot = await query.get(); + + const rawDocCount = snapshot.docs.length; + const hasMoreDocs = rawDocCount > queryLimit; + const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs; + + let containers = docsToProcess.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + ...helpers.serializeTimestamps(data, ["createdAt", "updatedAt"]), + }; + }); + + const allEquipmentIds = new Set(); + containers.forEach((c) => { + if (c.equipmentIds && Array.isArray(c.equipmentIds)) { + c.equipmentIds.forEach((id) => allEquipmentIds.add(id)); + } + }); + + const equipmentMap = new Map(); + if (allEquipmentIds.size > 0) { + const equipmentIdArray = Array.from(allEquipmentIds); + const batchSize = 30; + + for (let i = 0; i < equipmentIdArray.length; i += batchSize) { + const batch = equipmentIdArray.slice(i, i + batchSize); + const equipmentSnapshot = await db.collection("equipments") + .where(admin.firestore.FieldPath.documentId(), "in", batch) + .get(); + + equipmentSnapshot.docs.forEach((doc) => { + const equipmentData = doc.data(); + equipmentMap.set(doc.id, { + id: doc.id, + ...helpers.serializeTimestamps(equipmentData), + }); + }); + } + } + + containers = containers.map((container) => ({ + ...container, + equipment: (container.equipmentIds || []) + .map((eqId) => equipmentMap.get(eqId)) + .filter((eq) => eq !== undefined), + })); + + if (category) { + containers = containers.filter((c) => { + return c.equipment.some((eq) => eq.category === category); + }); + } + + if (searchQuery) { + containers = containers.filter((c) => { + const searchableText = [ + c.name || "", + c.id || "", + ...(c.equipment || []).map((eq) => eq.name || ""), + ].join(" ").toLowerCase(); + return searchableText.includes(searchQuery); + }); + } + + const limitedContainers = containers.slice(0, limit); + const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null; + + const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0); + logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`); + + limitedContainers.forEach((c) => { + logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`); + }); + + res.status(200).json({ + containers: limitedContainers, + hasMore: containers.length > limit || hasMoreDocs, + lastVisible, + total: limitedContainers.length, + }); + } catch (error) { + logger.error("Error fetching paginated containers:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/equipments.js b/em2rp/functions/src/equipments.js new file mode 100644 index 0000000..2ff1f1c --- /dev/null +++ b/em2rp/functions/src/equipments.js @@ -0,0 +1,668 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Créer un équipement (admin ou manage_equipment) +exports.createEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const equipmentData = req.body.data; + const equipmentId = equipmentData.id; + + if (!equipmentId) { + res.status(400).json({error: "Equipment ID is required"}); + return; + } + + // Vérifier unicité de l'ID + const existingDoc = await db.collection("equipments").doc(equipmentId).get(); + if (existingDoc.exists) { + res.status(409).json({error: "Equipment ID already exists"}); + return; + } + + // Convertir les timestamps + const dataToSave = helpers.deserializeTimestamps(equipmentData, [ + "createdAt", "updatedAt", "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate", + ]); + + await db.collection("equipments").doc(equipmentId).set(dataToSave); + + res.status(201).json({id: equipmentId, message: "Equipment created successfully"}); + } catch (error) { + logger.error("Error creating equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour un équipement +exports.updateEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {equipmentId, data} = req.body.data; + + if (!equipmentId) { + res.status(400).json({error: "Equipment ID is required"}); + return; + } + + if (!data || typeof data !== "object" || Object.keys(data).length === 0) { + res.status(400).json({error: "Update data is required and must be a non-empty object"}); + return; + } + + // Empêcher la modification de l'ID + delete data.id; + + // Ajouter updatedAt + data.updatedAt = admin.firestore.Timestamp.now(); + + const dataToSave = helpers.deserializeTimestamps(data, [ + "purchaseDate", "lastMaintenanceDate", "nextMaintenanceDate", + ]); + + await db.collection("equipments").doc(equipmentId).update(dataToSave); + + res.status(200).json({message: "Equipment updated successfully"}); + } catch (error) { + logger.error("Error updating equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer un équipement +exports.deleteEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {equipmentId, forceDelete = false} = req.body.data; + + if (!equipmentId) { + res.status(400).json({error: "Equipment ID is required"}); + return; + } + + // Vérifier si l'équipement est utilisé dans des événements à venir + const eventsSnapshot = await db.collection("events") + .where("status", "!=", "CANCELLED") + .get(); + + const now = new Date(); + const upcomingEvents = []; + + for (const eventDoc of eventsSnapshot.docs) { + const eventData = eventDoc.data(); + const assignedEquipment = eventData.assignedEquipment || []; + + if (!assignedEquipment.some((eq) => eq.equipmentId === equipmentId)) { + continue; + } + + let eventStart = null; + if (eventData.StartDateTime) { + eventStart = eventData.StartDateTime.toDate ? + eventData.StartDateTime.toDate() : + new Date(eventData.StartDateTime); + } + + if (eventStart && eventStart > now) { + upcomingEvents.push({ + eventId: eventDoc.id, + eventName: eventData.Name || "", + startDate: eventStart.toISOString(), + }); + } + } + + if (upcomingEvents.length > 0 && !forceDelete) { + res.status(409).json({ + error: "FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events", + upcomingEvents, + }); + return; + } + + await db.collection("equipments").doc(equipmentId).delete(); + + res.status(200).json({message: "Equipment deleted successfully"}); + } catch (error) { + logger.error("Error deleting equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer un équipement par ID +exports.getEquipment = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasViewAccess && !hasManageAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {equipmentId} = req.body.data || req.query; + + if (!equipmentId) { + res.status(400).json({error: "Equipment ID is required"}); + return; + } + + const doc = await db.collection("equipments").doc(equipmentId).get(); + + if (!doc.exists) { + res.status(404).json({error: "Equipment not found"}); + return; + } + + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + + // Masquer les prix si pas de permission manage_equipment + data = helpers.maskSensitiveFields(data, hasManageAccess); + + res.status(200).json({equipment: data}); + } catch (error) { + logger.error("Error getting equipment:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer plusieurs équipements par leurs IDs +exports.getEquipmentsByIds = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasViewAccess = await auth.hasPermission(decodedToken.uid, "view_equipment"); + const hasManageAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasViewAccess && !hasManageAccess) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const {equipmentIds} = req.body.data || {}; + + if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) { + res.status(400).json({error: "equipmentIds array is required and must not be empty"}); + return; + } + + // Limiter à 100 équipements max par requête + if (equipmentIds.length > 100) { + res.status(400).json({error: "Maximum 100 equipment IDs per request"}); + return; + } + + // Récupérer tous les documents en parallèle + const promises = equipmentIds.map((id) => db.collection("equipments").doc(id).get()); + const docs = await Promise.all(promises); + + const equipments = []; + for (const doc of docs) { + if (doc.exists) { + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + // Masquer les prix si pas de permission manage_equipment + data = helpers.maskSensitiveFields(data, hasManageAccess); + equipments.push(data); + } + } + + res.status(200).json({equipments}); + } catch (error) { + logger.error("Error getting equipments by IDs:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour uniquement le statut d'un équipement +exports.updateEquipmentStatusOnly = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {equipmentId, status, availableQuantity} = req.body.data; + + if (!equipmentId) { + res.status(400).json({error: "Equipment ID is required"}); + return; + } + + // Vérifier les permissions + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const updateData = {updatedAt: admin.firestore.Timestamp.now()}; + if (status) updateData.status = status; + if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity; + + await db.collection("equipments").doc(equipmentId).update(updateData); + + res.status(200).json({message: "Equipment status updated successfully"}); + } catch (error) { + logger.error("Error updating equipment status:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour le statut de plusieurs équipements (pour préparation/retour) +exports.updateEquipmentStatus = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {eventId, updates} = req.body.data; + + if (!eventId || !updates || !Array.isArray(updates)) { + res.status(400).json({error: "Event ID and updates array are required"}); + return; + } + + // Vérifier que l'utilisateur est assigné à l'événement ou est admin + const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAssigned && !isAdminUser) { + res.status(403).json({error: "Forbidden: Not assigned to this event"}); + return; + } + + // Batch update + const batch = db.batch(); + + for (const update of updates) { + const {equipmentId, status} = update; + if (equipmentId && status) { + const equipmentRef = db.collection("equipments").doc(equipmentId); + batch.update(equipmentRef, {status}); + } + } + + await batch.commit(); + + res.status(200).json({message: "Equipment statuses updated successfully"}); + } catch (error) { + logger.error("Error updating equipment statuses:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère les équipements avec pagination et filtrage côté serveur +exports.getEquipmentsPaginated = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + // Vérifier les permissions + const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + const canView = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!canManage && !canView) { + res.status(403).json({error: "Forbidden: Requires equipment permissions"}); + return; + } + + // Récupérer les paramètres de la requête + const params = req.method === "GET" ? req.query : (req.body?.data || {}); + const limit = Math.min(parseInt(params.limit) || 20, 100); + const startAfterId = params.startAfter || null; + const category = params.category ? params.category.toUpperCase() : null; + const status = params.status ? params.status.toUpperCase() : null; + const rawSearchQuery = typeof params.searchQuery === "string" ? params.searchQuery.trim() : ""; + const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null; + const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, "") : null; + const sortBy = params.sortBy || "id"; + const sortOrder = params.sortOrder === "desc" ? "desc" : "asc"; + + logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`); + + // Fast-path pour une recherche d'ID exact + if (searchQuery && !startAfterId) { + const exactIdCandidates = Array.from(new Set([ + rawSearchQuery, + rawSearchQuery.toUpperCase(), + rawSearchQuery.toLowerCase(), + ].filter(Boolean))); + + for (const candidateId of exactIdCandidates) { + const exactDoc = await db.collection("equipments").doc(candidateId).get(); + if (!exactDoc.exists) { + continue; + } + + const exactData = exactDoc.data() || {}; + const matchesCategory = !category || exactData.category === category; + const matchesStatus = !status || exactData.status === status; + if (!matchesCategory || !matchesStatus) { + continue; + } + + if (!canManage) { + delete exactData.purchasePrice; + delete exactData.rentalPrice; + } + + const exactEquipment = { + ...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]), + id: exactDoc.id, + }; + + logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`); + res.status(200).json({ + equipments: [exactEquipment], + hasMore: false, + lastVisible: exactDoc.id, + total: 1, + }); + return; + } + + // Compatibilité legacy + for (const legacyId of exactIdCandidates) { + let legacyIdQuery = db.collection("equipments").where("id", "==", legacyId); + if (category) { + legacyIdQuery = legacyIdQuery.where("category", "==", category); + } + if (status) { + legacyIdQuery = legacyIdQuery.where("status", "==", status); + } + + const legacySnapshot = await legacyIdQuery.limit(1).get(); + if (legacySnapshot.empty) { + continue; + } + + const exactDoc = legacySnapshot.docs[0]; + const exactData = exactDoc.data() || {}; + + if (!canManage) { + delete exactData.purchasePrice; + delete exactData.rentalPrice; + } + + const exactEquipment = { + ...helpers.serializeTimestamps(exactData, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]), + id: exactDoc.id, + }; + + logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`); + res.status(200).json({ + equipments: [exactEquipment], + hasMore: false, + lastVisible: exactDoc.id, + total: 1, + }); + return; + } + } + + // Construire la requête Firestore + let query = db.collection("equipments"); + + if (category) { + query = query.where("category", "==", category); + } + if (status) { + query = query.where("status", "==", status); + } + + if (sortBy === "id") { + query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder); + } else { + query = query.orderBy(sortBy, sortOrder); + } + + if (startAfterId) { + const startAfterDoc = await db.collection("equipments").doc(startAfterId).get(); + if (startAfterDoc.exists) { + query = query.startAfter(startAfterDoc); + } + } + + const timestampFields = ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]; + + const mapEquipmentDoc = (doc) => { + const data = {...(doc.data() || {})}; + + if (!canManage) { + delete data.purchasePrice; + delete data.rentalPrice; + } + + const legacyId = typeof data.id === "string" ? data.id : ""; + + return { + ...helpers.serializeTimestamps(data, timestampFields), + id: doc.id, + _legacyId: legacyId, + }; + }; + + const matchesSearchQuery = (equipment) => { + const searchableText = [ + equipment.name || "", + equipment.id || "", + equipment._legacyId || "", + equipment.model || "", + equipment.brand || "", + equipment.subCategory || "", + ].join(" ").toLowerCase(); + + if (searchableText.includes(searchQuery)) { + return true; + } + + if (!compactSearchQuery) { + return false; + } + + const compactSearchableText = searchableText.replace(/[\s_-]+/g, ""); + return compactSearchableText.includes(compactSearchQuery); + }; + + if (!searchQuery) { + const snapshot = await query.limit(limit + 1).get(); + const rawDocCount = snapshot.docs.length; + const hasMoreDocs = rawDocCount > limit; + const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs; + + const limitedEquipments = docsToProcess + .map(mapEquipmentDoc) + .map(({_legacyId, ...equipment}) => equipment); + const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null; + + logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`); + logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`); + + res.status(200).json({ + equipments: limitedEquipments, + hasMore: hasMoreDocs, + lastVisible, + total: limitedEquipments.length, + }); + return; + } + + const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200); + const matchedEquipments = []; + let scannedDocuments = 0; + let searchQueryRef = query; + let hasMoreMatches = false; + let hasMoreDocsToScan = true; + + while (hasMoreDocsToScan && !hasMoreMatches) { + const snapshot = await searchQueryRef.limit(searchBatchSize).get(); + + if (snapshot.empty) { + hasMoreDocsToScan = false; + break; + } + + scannedDocuments += snapshot.docs.length; + + for (const doc of snapshot.docs) { + const equipment = mapEquipmentDoc(doc); + if (!matchesSearchQuery(equipment)) { + continue; + } + + matchedEquipments.push(equipment); + if (matchedEquipments.length > limit) { + hasMoreMatches = true; + break; + } + } + + if (hasMoreMatches) { + break; + } + + if (snapshot.docs.length < searchBatchSize) { + hasMoreDocsToScan = false; + break; + } + + const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1]; + searchQueryRef = query.startAfter(lastDocInBatch); + } + + const limitedEquipments = matchedEquipments + .slice(0, limit) + .map(({_legacyId, ...equipment}) => equipment); + const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null; + + logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`); + logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`); + + res.status(200).json({ + equipments: limitedEquipments, + hasMore: hasMoreMatches, + lastVisible, + total: limitedEquipments.length, + }); + } catch (error) { + logger.error("Error fetching paginated equipments:", error); + res.status(500).json({error: error.message}); + } +}; + +// Recherche rapide d'équipements et containers pour l'autocomplétion +exports.quickSearch = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + // Vérifier les permissions + const canView = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!canView) { + res.status(403).json({error: "Forbidden: Requires view_equipment permission"}); + return; + } + + const params = req.method === "GET" ? req.query : (req.body?.data || {}); + const searchQuery = params.query?.toLowerCase() || ""; + const limit = Math.min(parseInt(params.limit) || 10, 50); + const includeEquipments = params.includeEquipments !== "false"; + const includeContainers = params.includeContainers !== "false"; + + if (!searchQuery || searchQuery.length < 2) { + res.status(200).json({results: []}); + return; + } + + const results = []; + + // Rechercher dans les équipements + if (includeEquipments) { + const equipmentSnapshot = await db.collection("equipments") + .orderBy("id") + .limit(limit * 2) + .get(); + + equipmentSnapshot.docs.forEach((doc) => { + const data = doc.data(); + const searchableText = [ + data.name || "", + doc.id || "", + data.model || "", + data.brand || "", + ].join(" ").toLowerCase(); + + if (searchableText.includes(searchQuery)) { + results.push({ + type: "equipment", + id: doc.id, + name: data.name, + category: data.category, + model: data.model, + brand: data.brand, + }); + } + }); + } + + // Rechercher dans les containers + if (includeContainers) { + const containerSnapshot = await db.collection("containers") + .orderBy("id") + .limit(limit * 2) + .get(); + + containerSnapshot.docs.forEach((doc) => { + const data = doc.data(); + const searchableText = [ + data.name || "", + doc.id || "", + ].join(" ").toLowerCase(); + + if (searchableText.includes(searchQuery)) { + results.push({ + type: "container", + id: doc.id, + name: data.name, + containerType: data.type, + }); + } + }); + } + + const limitedResults = results + .sort((a, b) => { + const aStarts = a.id.toLowerCase().startsWith(searchQuery); + const bStarts = b.id.toLowerCase().startsWith(searchQuery); + if (aStarts && !bStarts) return -1; + if (!aStarts && bStarts) return 1; + return 0; + }) + .slice(0, limit); + + res.status(200).json({results: limitedResults}); + } catch (error) { + logger.error("Error in quick search:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/events.js b/em2rp/functions/src/events.js new file mode 100644 index 0000000..fc00cb7 --- /dev/null +++ b/em2rp/functions/src/events.js @@ -0,0 +1,1082 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Helper functions for search +const normalizeSearchText = (value) => { + return (value || "") + .toString() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .trim(); +}; + +const getEventStartDate = (eventData) => { + const startValue = eventData.StartDateTime; + + if (!startValue) { + return null; + } + + if (startValue.toDate) { + return startValue.toDate(); + } + + const parsedDate = new Date(startValue); + return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; +}; + +const getEventWorkforceUids = (eventData) => { + if (!eventData.workforce || !Array.isArray(eventData.workforce)) { + return []; + } + + return eventData.workforce + .map((userRef) => { + if (userRef && userRef.id) { + return userRef.id; + } + + if (typeof userRef === "string" && userRef.startsWith("users/")) { + return userRef.split("/")[1]; + } + + return null; + }) + .filter((uid) => uid !== null); +}; + +const serializeEventSearchResult = (doc) => { + const data = doc.data(); + + return { + id: doc.id, + ...helpers.serializeTimestamps(data), + workforce: getEventWorkforceUids(data), + }; +}; + +// Créer un événement +exports.createEvent = 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; + + let dataToSave = helpers.deserializeTimestamps(eventData, [ + "StartDateTime", "EndDateTime", "createdAt", "updatedAt", + ]); + + dataToSave = helpers.convertIdsToReferences(dataToSave); + + 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 = 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; + } + + 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(); + + let dataToSave = helpers.deserializeTimestamps(data, [ + "StartDateTime", "EndDateTime", + ]); + + dataToSave = helpers.convertIdsToReferences(dataToSave); + + 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 = 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}); + } +}; + +// Met à jour les équipements d'un événement +exports.updateEventEquipment = 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; + } + + 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"); + + 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; + } + + const updateData = {}; + + if (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; + + 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}); + } +}; + +// Récupérer les événements utilisant un type d'événement +exports.getEventsByEventType = 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}); + } +}; + +// Récupère tous les événements (filtrés selon permissions) +exports.getEvents = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {userId} = req.body.data || {}; + + const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events"); + + let eventsSnapshot; + + if (canViewAll) { + eventsSnapshot = await db.collection("events").get(); + } else { + const userRef = db.collection("users").doc(userId || decodedToken.uid); + eventsSnapshot = await db.collection("events") + .where("workforce", "array-contains", userRef) + .get(); + } + + 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]); + } + }); + } + }); + + const usersMap = {}; + if (userIdsSet.size > 0) { + const userIds = Array.from(userIdsSet); + const batchSize = 30; + + const batchPromises = []; + for (let i = 0; i < userIds.length; i += batchSize) { + const batch = userIds.slice(i, i + batchSize); + batchPromises.push( + db.collection("users") + .where(admin.firestore.FieldPath.documentId(), "in", batch) + .get(), + ); + } + + const results = await Promise.all(batchPromises); + results.forEach((usersSnapshot) => { + usersSnapshot.docs.forEach((userDoc) => { + const userData = userDoc.data(); + usersMap[userDoc.id] = { + uid: userDoc.id, + firstName: userData.firstName || "", + lastName: userData.lastName || "", + email: userData.email || "", + phoneNumber: userData.phoneNumber || "", + profilePhotoUrl: userData.profilePhotoUrl || "", + }; + }); + }); + } + + const events = eventsSnapshot.docs.map((doc) => { + const data = doc.data(); + + 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, + }; + }); + + res.status(200).json({ + events, + users: usersMap, + }); + } catch (error) { + logger.error("Error fetching events:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère les événements d'un mois spécifique (lazy loading optimisé) +exports.getEventsByMonth = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {userId, year, month} = req.body.data || {}; + + if (!year || !month) { + res.status(400).json({error: "year and month are required"}); + return; + } + + logger.info(`Fetching events for ${year}-${month}`); + + const startOfMonth = admin.firestore.Timestamp.fromDate( + new Date(year, month - 1, 1, 0, 0, 0), + ); + const endOfMonth = admin.firestore.Timestamp.fromDate( + new Date(year, month, 0, 23, 59, 59), + ); + + const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events"); + + let eventsQuery = db.collection("events") + .where("StartDateTime", ">=", startOfMonth) + .where("StartDateTime", "<=", endOfMonth); + + if (!canViewAll) { + const userRef = db.collection("users").doc(userId || decodedToken.uid); + eventsQuery = eventsQuery.where("workforce", "array-contains", userRef); + } + + const eventsSnapshot = await eventsQuery.get(); + + logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`); + + 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]); + } + }); + } + }); + + const usersMap = {}; + if (userIdsSet.size > 0) { + const userIds = Array.from(userIdsSet); + const batchSize = 30; + + const batchPromises = []; + for (let i = 0; i < userIds.length; i += batchSize) { + const batch = userIds.slice(i, i + batchSize); + batchPromises.push( + db.collection("users") + .where(admin.firestore.FieldPath.documentId(), "in", batch) + .get(), + ); + } + + const results = await Promise.all(batchPromises); + results.forEach((usersSnapshot) => { + usersSnapshot.docs.forEach((userDoc) => { + const userData = userDoc.data(); + usersMap[userDoc.id] = { + uid: userDoc.id, + firstName: userData.firstName || "", + lastName: userData.lastName || "", + email: userData.email || "", + phoneNumber: userData.phoneNumber || "", + profilePhotoUrl: userData.profilePhotoUrl || "", + }; + }); + }); + } + + const events = eventsSnapshot.docs.map((doc) => { + const data = doc.data(); + + 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, + }; + }); + + logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`); + + res.status(200).json({ + events, + users: usersMap, + month: {year, month}, + }); + } catch (error) { + logger.error("Error fetching events by month:", error); + res.status(500).json({error: error.message}); + } +}; + +// Recherche des événements accessibles à l'utilisateur +exports.searchEvents = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {userId, query, limit = 20} = req.body.data || {}; + const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20; + + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) { + res.status(200).json({events: []}); + return; + } + + const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events"); + + let eventsSnapshot; + if (canViewAll) { + eventsSnapshot = await db.collection("events").get(); + } else { + const userRef = db.collection("users").doc(userId || decodedToken.uid); + eventsSnapshot = await db.collection("events") + .where("workforce", "array-contains", userRef) + .get(); + } + + const matchingEvents = eventsSnapshot.docs + .filter((doc) => { + const eventData = doc.data(); + const startDate = getEventStartDate(eventData); + const searchableText = normalizeSearchText([ + eventData.Name, + eventData.Description, + eventData.Address, + startDate ? startDate.toLocaleString("fr-FR") : "", + startDate ? startDate.toISOString() : "", + ].join(" ")); + + return searchableText.includes(normalizedQuery); + }) + .sort((a, b) => { + const startA = getEventStartDate(a.data()) || new Date(0); + const startB = getEventStartDate(b.data()) || new Date(0); + return startA.getTime() - startB.getTime(); + }) + .slice(0, maxResults) + .map((doc) => serializeEventSearchResult(doc)); + + res.status(200).json({events: matchingEvents}); + } catch (error) { + logger.error("Error searching events:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère un événement avec tous les détails (équipements complets + containers avec enfants) +exports.getEventWithDetails = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + 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 canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_events"); + if (!canViewAll) { + const userRef = db.collection("users").doc(decodedToken.uid); + const isInWorkforce = eventData.workforce && eventData.workforce.some((ref) => + (ref.id && ref.id === decodedToken.uid) || + (typeof ref === "string" && ref === `users/${decodedToken.uid}`), + ); + + if (!isInWorkforce) { + res.status(403).json({error: "Forbidden: Not assigned to this event"}); + return; + } + } + + logger.info(`[getEventWithDetails] Loading details for event ${eventId}`); + + const equipmentIds = new Set(); + const containerIds = new Set(); + + if (eventData.assignedEquipment && Array.isArray(eventData.assignedEquipment)) { + eventData.assignedEquipment.forEach((eq) => { + if (eq.equipmentId) { + equipmentIds.add(eq.equipmentId); + } + }); + } + + if (eventData.assignedContainers && Array.isArray(eventData.assignedContainers)) { + eventData.assignedContainers.forEach((id) => containerIds.add(id)); + } + + logger.info(`[getEventWithDetails] Loading ${equipmentIds.size} equipments and ${containerIds.size} containers`); + + const equipmentPromises = Array.from(equipmentIds).map((id) => + db.collection("equipments").doc(id).get(), + ); + const equipmentDocs = await Promise.all(equipmentPromises); + + const equipmentMap = {}; + for (const doc of equipmentDocs) { + if (doc.exists) { + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + equipmentMap[doc.id] = data; + } + } + + const containerPromises = Array.from(containerIds).map((id) => + db.collection("containers").doc(id).get(), + ); + const containerDocs = await Promise.all(containerPromises); + + const childEquipmentIds = new Set(); + for (const doc of containerDocs) { + if (doc.exists) { + const containerData = doc.data(); + if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) { + containerData.equipmentIds.forEach((id) => childEquipmentIds.add(id)); + } + } + } + + logger.info(`[getEventWithDetails] Loading ${childEquipmentIds.size} child equipments from containers`); + + const childEquipmentPromises = Array.from(childEquipmentIds).map((id) => + db.collection("equipments").doc(id).get(), + ); + const childEquipmentDocs = await Promise.all(childEquipmentPromises); + + for (const doc of childEquipmentDocs) { + if (doc.exists && !equipmentMap[doc.id]) { + let data = {id: doc.id, ...doc.data()}; + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + equipmentMap[doc.id] = data; + } + } + + const containerMap = {}; + for (const doc of containerDocs) { + if (doc.exists) { + let containerData = {id: doc.id, ...doc.data()}; + containerData = helpers.serializeTimestamps(containerData); + containerData = helpers.serializeReferences(containerData); + + if (containerData.equipmentIds && Array.isArray(containerData.equipmentIds)) { + containerData.children = containerData.equipmentIds + .map((id) => equipmentMap[id]) + .filter((eq) => eq !== undefined); + } else { + containerData.children = []; + } + + containerMap[doc.id] = containerData; + } + } + + const event = { + id: eventDoc.id, + ...helpers.serializeTimestamps(eventData), + workforce: eventData.workforce ? eventData.workforce.map((ref) => + (ref.id || (typeof ref === "string" ? ref.split("/")[1] : null)), + ).filter((uid) => uid !== null) : [], + }; + + logger.info(`[getEventWithDetails] Returning event with ${Object.keys(equipmentMap).length} equipments and ${Object.keys(containerMap).length} containers`); + + res.status(200).json({ + event, + equipments: equipmentMap, + containers: containerMap, + }); + } catch (error) { + logger.error("Error getting event with details:", error); + res.status(500).json({error: error.message}); + } +}; + +// 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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}); + } +}; diff --git a/em2rp/functions/src/maintenances.js b/em2rp/functions/src/maintenances.js new file mode 100644 index 0000000..19a2dec --- /dev/null +++ b/em2rp/functions/src/maintenances.js @@ -0,0 +1,328 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Créer une maintenance +exports.createMaintenance = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"}); + return; + } + + const maintenanceData = req.body.data; + + const dataToSave = helpers.deserializeTimestamps(maintenanceData, [ + "scheduledDate", "completedDate", "createdAt", "updatedAt", + ]); + + const docRef = await db.collection("maintenances").add(dataToSave); + const maintenanceId = docRef.id; + + if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) { + for (const equipmentId of maintenanceData.equipmentIds) { + try { + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + const maintenanceIds = equipmentData.maintenanceIds || []; + if (!maintenanceIds.includes(maintenanceId)) { + maintenanceIds.push(maintenanceId); + await db.collection("equipments").doc(equipmentId).update({ + maintenanceIds: maintenanceIds, + }); + } + } + + if (maintenanceData.scheduledDate) { + const scheduledDate = maintenanceData.scheduledDate.toDate ? + maintenanceData.scheduledDate.toDate() : + new Date(maintenanceData.scheduledDate); + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + + if (scheduledDate <= sevenDaysFromNow) { + const existingAlerts = await db.collection("alerts") + .where("equipmentId", "==", equipmentId) + .where("type", "==", "maintenanceDue") + .where("isRead", "==", false) + .get(); + + let alertExists = false; + for (const alertDoc of existingAlerts.docs) { + const alertData = alertDoc.data(); + if (alertData.message && alertData.message.includes(maintenanceData.name || "")) { + alertExists = true; + break; + } + } + + if (!alertExists) { + const equipmentName = equipmentDoc.exists ? + (equipmentDoc.data().name || equipmentId) : + equipmentId; + + const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24)); + + await db.collection("alerts").add({ + type: "maintenanceDue", + message: `Maintenance "${maintenanceData.name || "Sans nom"}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`, + equipmentId: equipmentId, + createdAt: admin.firestore.Timestamp.now(), + isRead: false, + }); + } + } + } + } catch (err) { + logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err); + } + } + } + + res.status(201).json({id: maintenanceId, message: "Maintenance created successfully"}); + } catch (error) { + logger.error("Error creating maintenance:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour une maintenance +exports.updateMaintenance = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_maintenances"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_maintenances permission"}); + return; + } + + const {maintenanceId, data} = req.body.data; + + if (!maintenanceId) { + res.status(400).json({error: "Maintenance ID is required"}); + return; + } + + delete data.id; + data.updatedAt = admin.firestore.Timestamp.now(); + + const dataToSave = helpers.deserializeTimestamps(data, [ + "scheduledDate", "completedDate", + ]); + + await db.collection("maintenances").doc(maintenanceId).update(dataToSave); + + res.status(200).json({message: "Maintenance updated successfully"}); + } catch (error) { + logger.error("Error updating maintenance:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer toutes les maintenances +exports.getMaintenances = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {equipmentId} = req.body.data || {}; + + const canView = await auth.hasPermission(decodedToken.uid, "view_equipment"); + + if (!canView) { + res.status(403).json({error: "Forbidden: Requires equipment permissions"}); + return; + } + + let query = db.collection("maintenances"); + + if (equipmentId) { + query = query.where("equipmentIds", "array-contains", equipmentId); + } + + const snapshot = await query.get(); + const maintenances = snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + ...helpers.serializeTimestamps(data, ["scheduledDate", "completedDate", "createdAt", "updatedAt"]), + }; + }); + + res.status(200).json({maintenances}); + } catch (error) { + logger.error("Error fetching maintenances:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer une maintenance +exports.deleteMaintenance = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + const canManage = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + if (!canManage) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const maintenanceId = req.body.data?.maintenanceId; + if (!maintenanceId) { + res.status(400).json({error: "maintenanceId is required"}); + return; + } + + const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get(); + if (maintenanceDoc.exists) { + const maintenance = maintenanceDoc.data(); + + if (maintenance.equipmentIds) { + for (const equipmentId of maintenance.equipmentIds) { + const equipmentDoc = await db.collection("equipments").doc(equipmentId).get(); + if (equipmentDoc.exists) { + const equipmentData = equipmentDoc.data(); + const maintenanceIds = (equipmentData.maintenanceIds || []).filter((id) => id !== maintenanceId); + await db.collection("equipments").doc(equipmentId).update({maintenanceIds}); + } + } + } + } + + await db.collection("maintenances").doc(maintenanceId).delete(); + + res.status(200).json({success: true}); + } catch (error) { + logger.error("Error deleting maintenance:", error); + res.status(500).json({error: error.message}); + } +}; + +/** + * Vérifier les maintenances à venir et créer des alertes + */ +exports.checkUpcomingMaintenances = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + + const now = admin.firestore.Timestamp.now(); + const sevenDaysTimestamp = admin.firestore.Timestamp.fromDate(sevenDaysFromNow); + + // Récupérer les maintenances planifiées dans les 7 prochains jours + const maintenancesSnapshot = await db.collection("maintenances") + .where("scheduledDate", "<=", sevenDaysTimestamp) + .where("scheduledDate", ">=", now) + .get(); + + const alertsCreated = []; + + for (const doc of maintenancesSnapshot.docs) { + const maintenance = doc.data(); + + // Vérifier si une alerte existe déjà pour cette maintenance + const existingAlertSnapshot = await db.collection("alerts") + .where("type", "==", "MAINTENANCE_DUE") + .where("relatedMaintenanceId", "==", doc.id) + .get(); + + if (existingAlertSnapshot.empty) { + // Créer une nouvelle alerte + const alertData = { + type: "MAINTENANCE_DUE", + title: `Maintenance à venir`, + message: `Une maintenance est prévue pour ${maintenance.equipmentIds?.length || 0} équipement(s)`, + severity: "MEDIUM", + isRead: false, + relatedMaintenanceId: doc.id, + createdAt: admin.firestore.Timestamp.now(), + }; + + const alertRef = await db.collection("alerts").add(alertData); + alertsCreated.push({id: alertRef.id, ...alertData}); + } + } + + res.status(200).json({ + success: true, + alertsCreated: alertsCreated.length, + alerts: alertsCreated, + }); + } catch (error) { + logger.error("Error checking upcoming maintenances:", error); + res.status(500).json({error: error.message}); + } +}; + +/** + * Compléter une maintenance + */ +exports.completeMaintenance = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const hasAccess = await auth.hasPermission(decodedToken.uid, "manage_equipment"); + + if (!hasAccess) { + res.status(403).json({error: "Forbidden: Requires manage_equipment permission"}); + return; + } + + const {maintenanceId, performedBy, cost} = req.body.data; + + if (!maintenanceId) { + res.status(400).json({error: "maintenanceId is required"}); + return; + } + + const now = admin.firestore.Timestamp.now(); + const updateData = { + completedDate: now, + updatedAt: now, + }; + + if (performedBy) { + updateData.performedBy = performedBy; + } + + if (cost !== undefined && cost !== null) { + updateData.cost = cost; + } + + // Mettre à jour la maintenance + await db.collection("maintenances").doc(maintenanceId).update(updateData); + + // Récupérer la maintenance pour mettre à jour les équipements + const maintenanceDoc = await db.collection("maintenances").doc(maintenanceId).get(); + const maintenanceData = maintenanceDoc.data(); + + // Mettre à jour la date de dernière maintenance des équipements + if (maintenanceData && maintenanceData.equipmentIds) { + const updatePromises = maintenanceData.equipmentIds.map((equipmentId) => + db.collection("equipments").doc(equipmentId).update({ + lastMaintenanceDate: now, + updatedAt: now, + }), + ); + + await Promise.all(updatePromises); + } + + res.status(200).json({success: true}); + } catch (error) { + logger.error("Error completing maintenance:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/options.js b/em2rp/functions/src/options.js new file mode 100644 index 0000000..338cb97 --- /dev/null +++ b/em2rp/functions/src/options.js @@ -0,0 +1,263 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Récupérer toutes les options (public pour utilisateurs authentifiés) +exports.getOptions = async (req, res) => { + try { + await auth.authenticateUser(req); + + const snapshot = await db.collection("options").get(); + const options = snapshot.docs.map((doc) => ({ + id: doc.id, + ...helpers.serializeTimestamps(doc.data()), + })); + + res.status(200).json({options}); + } catch (error) { + logger.error("Error fetching options:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer tous les types d'événements (public pour utilisateurs authentifiés) +exports.getEventTypes = async (req, res) => { + try { + await auth.authenticateUser(req); + + const snapshot = await db.collection("eventTypes").get(); + const eventTypes = snapshot.docs.map((doc) => ({ + id: doc.id, + ...helpers.serializeTimestamps(doc.data()), + })); + + res.status(200).json({eventTypes}); + } catch (error) { + logger.error("Error fetching event types:", error); + res.status(500).json({error: error.message}); + } +}; + +// Créer une option +exports.createOption = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const optionData = req.body.data; + const optionId = optionData.id; + + if (!optionId) { + res.status(400).json({error: "Option ID is required"}); + return; + } + + await db.collection("options").doc(optionId).set(optionData); + + res.status(201).json({id: optionId, message: "Option created successfully"}); + } catch (error) { + logger.error("Error creating option:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour une option +exports.updateOption = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const {optionId, data} = req.body.data; + + if (!optionId) { + res.status(400).json({error: "Option ID is required"}); + return; + } + + delete data.id; + + await db.collection("options").doc(optionId).update(data); + + res.status(200).json({message: "Option updated successfully"}); + } catch (error) { + logger.error("Error updating option:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer une option +exports.deleteOption = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const {optionId} = req.body.data; + + if (!optionId) { + res.status(400).json({error: "Option ID is required"}); + return; + } + + await db.collection("options").doc(optionId).delete(); + + res.status(200).json({message: "Option deleted successfully"}); + } catch (error) { + logger.error("Error deleting option:", error); + res.status(500).json({error: error.message}); + } +}; + +// Créer un type d'événement +exports.createEventType = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data"); + + if (!isAdmin) { + res.status(403).json({error: "Forbidden: Admin permission required"}); + return; + } + + const {name, defaultPrice} = req.body.data; + + if (!name || defaultPrice === undefined) { + res.status(400).json({error: "Name and defaultPrice are required"}); + return; + } + + const existingSnapshot = await db.collection("eventTypes") + .where("name", "==", name) + .get(); + + if (!existingSnapshot.empty) { + res.status(409).json({error: "Event type name already exists"}); + return; + } + + const eventTypeData = { + name, + defaultPrice, + createdAt: admin.firestore.Timestamp.now(), + }; + + const docRef = await db.collection("eventTypes").add(eventTypeData); + + res.status(201).json({id: docRef.id, message: "Event type created successfully"}); + } catch (error) { + logger.error("Error creating event type:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour un type d'événement +exports.updateEventType = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data"); + + if (!isAdmin) { + res.status(403).json({error: "Forbidden: Admin permission required"}); + return; + } + + const {eventTypeId, name, defaultPrice} = req.body.data; + + if (!eventTypeId) { + res.status(400).json({error: "Event type ID is required"}); + return; + } + + const docRef = db.collection("eventTypes").doc(eventTypeId); + const doc = await docRef.get(); + + if (!doc.exists) { + res.status(404).json({error: "Event type not found"}); + return; + } + + if (name) { + const existingSnapshot = await db.collection("eventTypes") + .where("name", "==", name) + .get(); + + const hasDuplicate = existingSnapshot.docs.some((d) => d.id !== eventTypeId); + if (hasDuplicate) { + res.status(409).json({error: "Event type name already exists"}); + return; + } + } + + const updateData = {}; + if (name) updateData.name = name; + if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice; + + await docRef.update(updateData); + + res.status(200).json({message: "Event type updated successfully"}); + } catch (error) { + logger.error("Error updating event type:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer un type d'événement +exports.deleteEventType = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdmin = await auth.hasPermission(decodedToken.uid, "edit_data"); + + if (!isAdmin) { + res.status(403).json({error: "Forbidden: Admin permission required"}); + return; + } + + const {eventTypeId} = req.body.data; + + if (!eventTypeId) { + res.status(400).json({error: "Event type ID is required"}); + return; + } + + const eventsSnapshot = await db.collection("events") + .where("eventTypeId", "==", eventTypeId) + .get(); + + const now = admin.firestore.Timestamp.now(); + const futureEvents = eventsSnapshot.docs.filter((doc) => { + const startDate = doc.data().StartDateTime; + return startDate && startDate > now; + }); + + if (futureEvents.length > 0) { + res.status(409).json({ + error: "Cannot delete event type with future events", + futureEventsCount: futureEvents.length, + }); + return; + } + + await db.collection("eventTypes").doc(eventTypeId).delete(); + + res.status(200).json({message: "Event type deleted successfully"}); + } catch (error) { + logger.error("Error deleting event type:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/storage.js b/em2rp/functions/src/storage.js new file mode 100644 index 0000000..8cf71f9 --- /dev/null +++ b/em2rp/functions/src/storage.js @@ -0,0 +1,33 @@ +const admin = require("firebase-admin"); +const {Storage} = require("@google-cloud/storage"); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); + +const storage = new Storage(); + +exports.moveEventFileV2 = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {sourcePath, destinationPath} = req.body.data || {}; + + if (!sourcePath || !destinationPath) { + res.status(400).json({error: "Source and destination paths are required."}); + return; + } + + const bucketName = admin.storage().bucket().name; + const bucket = storage.bucket(bucketName); + + await bucket.file(sourcePath).copy(bucket.file(destinationPath)); + await bucket.file(sourcePath).delete(); + const [url] = await bucket.file(destinationPath).getSignedUrl({ + action: "read", + expires: "03-01-2500", + }); + + res.status(200).json({url}); + } catch (error) { + logger.error("Error moving file:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/src/tts.js b/em2rp/functions/src/tts.js new file mode 100644 index 0000000..88e7616 --- /dev/null +++ b/em2rp/functions/src/tts.js @@ -0,0 +1,58 @@ +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); + +exports.generateTTSV2 = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + logger.info("[generateTTSV2] Request from user:", { + uid: decodedToken.uid, + email: decodedToken.email, + }); + + const {text, voiceConfig} = req.body.data || {}; + + if (!text) { + res.status(400).json({error: "Text parameter is required"}); + return; + } + + if (text.length > 5000) { + res.status(400).json({error: "Text too long (max 5000 characters)"}); + return; + } + + const {Storage} = require("@google-cloud/storage"); + const storage = new Storage(); + const bucketName = admin.storage().bucket().name; + const bucket = storage.bucket(bucketName); + + const {generateTTS} = require("../generateTTS"); + const result = await generateTTS(text, storage, bucket, voiceConfig); + + logger.info("[generateTTSV2] ✓ Success", { + cached: result.cached, + cacheKey: result.cacheKey, + }); + + res.status(200).json({ + audioUrl: result.audioUrl, + cached: result.cached, + cacheKey: result.cacheKey, + }); + } catch (error) { + logger.error("[generateTTSV2] ✗ Error:", { + error: error.message, + code: error.code, + }); + + if (error.code === "PERMISSION_DENIED") { + res.status(403).json({error: "Permission denied. Check Google Cloud TTS API is enabled."}); + } else if (error.code === "QUOTA_EXCEEDED") { + res.status(429).json({error: "TTS quota exceeded. Try again later."}); + } else { + res.status(500).json({error: error.message}); + } + } +}; diff --git a/em2rp/functions/src/users.js b/em2rp/functions/src/users.js new file mode 100644 index 0000000..fc4a2b1 --- /dev/null +++ b/em2rp/functions/src/users.js @@ -0,0 +1,336 @@ +const admin = require("firebase-admin"); +const db = admin.firestore(); +const logger = require("firebase-functions/logger"); +const auth = require("../utils/auth"); +const helpers = require("../utils/helpers"); + +// Créer un utilisateur +exports.createUser = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const userData = req.body.data; + const userId = userData.uid; + + if (!userId) { + res.status(400).json({error: "User ID is required"}); + return; + } + + await db.collection("users").doc(userId).set(userData); + + res.status(201).json({id: userId, message: "User created successfully"}); + } catch (error) { + logger.error("Error creating user:", error); + res.status(500).json({error: error.message}); + } +}; + +// Créer un utilisateur avec invitation par email +exports.createUserWithInvite = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const {email, firstName, lastName, phoneNumber, roleId} = req.body.data; + + if (!email || !firstName || !lastName || !roleId) { + res.status(400).json({error: "email, firstName, lastName, and roleId are required"}); + return; + } + + const tempPassword = Math.random().toString(36).slice(-12) + "Aa1!"; + + let userRecord; + try { + userRecord = await admin.auth().createUser({ + email: email, + password: tempPassword, + emailVerified: false, + displayName: `${firstName} ${lastName}`, + }); + } catch (authError) { + logger.error("Error creating user in Auth:", authError); + res.status(500).json({error: `Failed to create user in Auth: ${authError.message}`}); + return; + } + + try { + await db.collection("users").doc(userRecord.uid).set({ + firstName: firstName, + lastName: lastName, + email: email, + phoneNumber: phoneNumber || "", + profilePhotoUrl: "", + role: db.collection("roles").doc(roleId), + createdAt: admin.firestore.FieldValue.serverTimestamp(), + createdBy: decodedToken.uid, + }); + } catch (firestoreError) { + logger.error("Error creating user in Firestore:", firestoreError); + try { + await admin.auth().deleteUser(userRecord.uid); + } catch (cleanupError) { + logger.error("Error cleaning up Auth user:", cleanupError); + } + res.status(500).json({error: `Failed to create user in Firestore: ${firestoreError.message}`}); + return; + } + + try { + const axios = require("axios"); + const firebaseApiKey = "AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U"; + + await axios.post( + `https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`, + { + requestType: "PASSWORD_RESET", + email: email, + }, + ); + logger.info(`Password reset email sent to ${email}`); + } catch (emailError) { + logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`); + } + + logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`); + res.status(201).json({ + id: userRecord.uid, + message: "User created successfully. Password reset email sent.", + }); + } catch (error) { + logger.error("Error in createUserWithInvite:", error); + res.status(500).json({error: error.message}); + } +}; + +// Mettre à jour un utilisateur +exports.updateUser = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const {userId, data} = req.body.data; + + if (!userId) { + res.status(400).json({error: "User ID is required"}); + return; + } + + // Un utilisateur ne peut modifier que son propre compte, sauf s'il est admin + const isAdminUser = await auth.isAdmin(decodedToken.uid); + if (decodedToken.uid !== userId && !isAdminUser) { + res.status(403).json({error: "Forbidden: Cannot update other user accounts"}); + return; + } + + // Empêcher les non-admins de modifier le rôle + if (!isAdminUser && data.role) { + delete data.role; + } + + // Si le rôle est fourni et est un string, le convertir en DocumentReference + if (data.role && typeof data.role === "string") { + data.role = db.collection("roles").doc(data.role); + } + + await db.collection("users").doc(userId).update(data); + + res.status(200).json({message: "User updated successfully"}); + } catch (error) { + logger.error("Error updating user:", error); + res.status(500).json({error: error.message}); + } +}; + +// Supprimer un utilisateur +exports.deleteUser = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const isAdminUser = await auth.isAdmin(decodedToken.uid); + + if (!isAdminUser) { + res.status(403).json({error: "Forbidden: Admin access required"}); + return; + } + + const {userId} = req.body.data; + + if (!userId) { + res.status(400).json({error: "User ID is required"}); + return; + } + + if (decodedToken.uid === userId) { + res.status(400).json({error: "Cannot delete your own account"}); + return; + } + + await db.collection("users").doc(userId).delete(); + + try { + await admin.auth().deleteUser(userId); + } catch (authError) { + logger.warn(`Could not delete user from Auth: ${authError.message}`); + } + + res.status(200).json({message: "User deleted successfully"}); + } catch (error) { + logger.error("Error deleting user:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupérer tous les utilisateurs (selon permissions) +exports.getUsers = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + const canViewAll = await auth.hasPermission(decodedToken.uid, "view_all_users"); + + if (!canViewAll) { + const userDoc = await db.collection("users").doc(decodedToken.uid).get(); + + if (!userDoc.exists) { + res.status(404).json({error: "User not found"}); + return; + } + + let userData = userDoc.data(); + userData = helpers.serializeTimestamps(userData); + userData = helpers.serializeReferences(userData); + + res.status(200).json({ + users: [{ + id: userDoc.id, + ...userData, + }], + }); + return; + } + + const snapshot = await db.collection("users").get(); + const users = snapshot.docs.map((doc) => { + let data = doc.data(); + data = helpers.serializeTimestamps(data); + data = helpers.serializeReferences(data); + return { + id: doc.id, + ...data, + }; + }); + + res.status(200).json({users}); + } catch (error) { + logger.error("Error fetching users:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère un utilisateur spécifique +exports.getUser = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + + const {userId} = req.body.data || req.body || {}; + if (!userId) { + res.status(400).json({error: "userId is required"}); + return; + } + + const userDoc = await db.collection("users").doc(userId).get(); + if (!userDoc.exists) { + res.status(404).json({error: "User not found"}); + return; + } + + const user = userDoc.data(); + + const userData = { + id: userDoc.id, + uid: user.uid || userDoc.id, + email: user.email || "", + firstName: user.firstName || "", + lastName: user.lastName || "", + phoneNumber: user.phoneNumber || "", + profilePhotoUrl: user.profilePhotoUrl || "", + }; + + if (user.role) { + const roleDoc = await user.role.get(); + if (roleDoc.exists) { + userData.role = { + id: roleDoc.id, + ...roleDoc.data(), + }; + } + } + + res.status(200).json({user: userData}); + } catch (error) { + logger.error("Error fetching user:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère l'utilisateur actuellement authentifié avec son rôle +exports.getCurrentUser = async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const userId = decodedToken.uid; + + const userDoc = await db.collection("users").doc(userId).get(); + if (!userDoc.exists) { + res.status(404).json({error: "User not found"}); + return; + } + + const userData = userDoc.data(); + + let roleData = null; + if (userData.role) { + const roleDoc = await userData.role.get(); + if (roleDoc.exists) { + roleData = {id: roleDoc.id, ...roleDoc.data()}; + } + } + + res.status(200).json({ + user: { + uid: userId, + ...helpers.serializeTimestamps(userData), + role: roleData, + }, + }); + } catch (error) { + logger.error("Error getting current user:", error); + res.status(500).json({error: error.message}); + } +}; + +// Récupère tous les rôles +exports.getRoles = async (req, res) => { + try { + await auth.authenticateUser(req); + + const snapshot = await db.collection("roles").get(); + const roles = snapshot.docs.map((doc) => ({ + id: doc.id, + ...helpers.serializeTimestamps(doc.data()), + })); + + res.status(200).json({roles}); + } catch (error) { + logger.error("Error fetching roles:", error); + res.status(500).json({error: error.message}); + } +}; diff --git a/em2rp/functions/utils/auth.js b/em2rp/functions/utils/auth.js index da445a4..cd70084 100644 --- a/em2rp/functions/utils/auth.js +++ b/em2rp/functions/utils/auth.js @@ -1,24 +1,24 @@ /** * Utilitaires d'authentification et d'autorisation */ -const admin = require('firebase-admin'); -const logger = require('firebase-functions/logger'); +const admin = require("firebase-admin"); +const logger = require("firebase-functions/logger"); /** * Vérifie le token Firebase et retourne l'utilisateur */ async function authenticateUser(req) { - if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) { - throw new Error('Unauthorized: No token provided'); + if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer ")) { + throw new Error("Unauthorized: No token provided"); } - const idToken = req.headers.authorization.split('Bearer ')[1]; + const idToken = req.headers.authorization.split("Bearer ")[1]; try { const decodedToken = await admin.auth().verifyIdToken(idToken); return decodedToken; } catch (e) { logger.error("Error verifying Firebase ID token:", e); - throw new Error('Unauthorized: Invalid token'); + throw new Error("Unauthorized: Invalid token"); } } @@ -26,11 +26,11 @@ async function authenticateUser(req) { * Récupère les données utilisateur depuis Firestore */ async function getUserData(uid) { - const userDoc = await admin.firestore().collection('users').doc(uid).get(); + const userDoc = await admin.firestore().collection("users").doc(uid).get(); if (!userDoc.exists) { return null; } - return { uid, ...userDoc.data() }; + return {uid, ...userDoc.data()}; } /** @@ -40,7 +40,7 @@ async function getRolePermissions(roleRef) { if (!roleRef) return []; let roleId; - if (typeof roleRef === 'string') { + if (typeof roleRef === "string") { roleId = roleRef; } else if (roleRef.id) { roleId = roleRef.id; @@ -48,7 +48,7 @@ async function getRolePermissions(roleRef) { return []; } - const roleDoc = await admin.firestore().collection('roles').doc(roleId).get(); + const roleDoc = await admin.firestore().collection("roles").doc(roleId).get(); if (!roleDoc.exists) return []; return roleDoc.data().permissions || []; @@ -74,7 +74,7 @@ async function isAdmin(uid) { let roleId; const roleField = userData.role; - if (typeof roleField === 'string') { + if (typeof roleField === "string") { roleId = roleField; } else if (roleField && roleField.id) { roleId = roleField.id; @@ -82,22 +82,22 @@ async function isAdmin(uid) { return false; } - return roleId === 'ADMIN'; + return roleId === "ADMIN"; } /** * Vérifie si l'utilisateur est assigné à un événement */ async function isAssignedToEvent(uid, eventId) { - const eventDoc = await admin.firestore().collection('events').doc(eventId).get(); + const eventDoc = await admin.firestore().collection("events").doc(eventId).get(); if (!eventDoc.exists) return false; const eventData = eventDoc.data(); const workforce = eventData.workforce || []; // workforce contient des références DocumentReference - return workforce.some(ref => { - if (typeof ref === 'string') return ref === uid; + return workforce.some((ref) => { + if (typeof ref === "string") return ref === uid; if (ref && ref.id) return ref.id === uid; return false; }); @@ -113,7 +113,7 @@ async function authMiddleware(req, res, next) { req.uid = decodedToken.uid; next(); } catch (error) { - res.status(401).json({ error: error.message }); + res.status(401).json({error: error.message}); } } @@ -125,12 +125,12 @@ function requirePermission(permission) { try { const hasAccess = await hasPermission(req.uid, permission); if (!hasAccess) { - res.status(403).json({ error: `Forbidden: Requires permission '${permission}'` }); + res.status(403).json({error: `Forbidden: Requires permission '${permission}'`}); return; } next(); } catch (error) { - res.status(403).json({ error: error.message }); + res.status(403).json({error: error.message}); } }; } @@ -142,12 +142,12 @@ async function requireAdmin(req, res, next) { try { const adminAccess = await isAdmin(req.uid); if (!adminAccess) { - res.status(403).json({ error: 'Forbidden: Admin access required' }); + res.status(403).json({error: "Forbidden: Admin access required"}); return; } next(); } catch (error) { - res.status(403).json({ error: error.message }); + res.status(403).json({error: error.message}); } } diff --git a/em2rp/functions/utils/emailConfig.js b/em2rp/functions/utils/emailConfig.js index f0c0d3b..5b34133 100644 --- a/em2rp/functions/utils/emailConfig.js +++ b/em2rp/functions/utils/emailConfig.js @@ -7,12 +7,12 @@ // Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase const getSmtpConfig = () => { return { - host: process.env.SMTP_HOST || 'mail.em2events.fr', - port: parseInt(process.env.SMTP_PORT || '465'), + host: process.env.SMTP_HOST || "mail.em2events.fr", + port: parseInt(process.env.SMTP_PORT || "465"), secure: true, // true pour port 465, false pour autres ports auth: { - user: process.env.SMTP_USER || 'notify@em2events.fr', - pass: process.env.SMTP_PASS || '', + user: process.env.SMTP_USER || "notify@em2events.fr", + pass: process.env.SMTP_PASS || "", }, tls: { // Ne pas échouer sur certificats invalides @@ -24,12 +24,12 @@ const getSmtpConfig = () => { // Configuration email par défaut const EMAIL_CONFIG = { from: { - name: 'EM2 Events', - address: 'notify@em2events.fr', + name: "EM2 Events", + address: "notify@em2events.fr", }, - replyTo: 'contact@em2events.fr', + replyTo: "contact@em2events.fr", // URL de l'application pour les liens - appUrl: process.env.APP_URL || 'https://app.em2events.fr', + appUrl: process.env.APP_URL || "https://app.em2events.fr", }; module.exports = { diff --git a/em2rp/functions/utils/emailTemplates.js b/em2rp/functions/utils/emailTemplates.js index 43796e7..bf946fb 100644 --- a/em2rp/functions/utils/emailTemplates.js +++ b/em2rp/functions/utils/emailTemplates.js @@ -1,23 +1,23 @@ -const admin = require('firebase-admin'); -const handlebars = require('handlebars'); -const fs = require('fs').promises; -const path = require('path'); -const {EMAIL_CONFIG} = require('./emailConfig'); +const admin = require("firebase-admin"); +const handlebars = require("handlebars"); +const fs = require("fs").promises; +const path = require("path"); +const {EMAIL_CONFIG} = require("./emailConfig"); /** * Vérifie si l'utilisateur souhaite recevoir ce type d'alerte */ function checkAlertPreference(alertType, preferences) { const typeMapping = { - 'EVENT_CREATED': 'eventsNotifications', - 'EVENT_MODIFIED': 'eventsNotifications', - 'EVENT_CANCELLED': 'eventsNotifications', - 'LOST': 'equipmentNotifications', - 'EQUIPMENT_MISSING': 'equipmentNotifications', - 'DAMAGED': 'equipmentNotifications', - 'QUANTITY_MISMATCH': 'equipmentNotifications', - 'MAINTENANCE_REMINDER': 'maintenanceNotifications', - 'STOCK_LOW': 'stockNotifications', + "EVENT_CREATED": "eventsNotifications", + "EVENT_MODIFIED": "eventsNotifications", + "EVENT_CANCELLED": "eventsNotifications", + "LOST": "equipmentNotifications", + "EQUIPMENT_MISSING": "equipmentNotifications", + "DAMAGED": "equipmentNotifications", + "QUANTITY_MISMATCH": "equipmentNotifications", + "MAINTENANCE_REMINDER": "maintenanceNotifications", + "STOCK_LOW": "stockNotifications", }; const prefKey = typeMapping[alertType]; @@ -29,12 +29,12 @@ function checkAlertPreference(alertType, preferences) { */ async function prepareTemplateData(alert, user) { const data = { - userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() || - 'Utilisateur', + userName: `${user.firstName || ""} ${user.lastName || ""}`.trim() || + "Utilisateur", alertTitle: getAlertTitle(alert.type), alertMessage: alert.message, - isCritical: alert.severity === 'CRITICAL', - actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`, + isCritical: alert.severity === "CRITICAL", + actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || "/alerts"}`, appUrl: EMAIL_CONFIG.appUrl, unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`, year: new Date().getFullYear(), @@ -45,20 +45,20 @@ async function prepareTemplateData(alert, user) { if (alert.eventId) { try { const eventDoc = await admin.firestore() - .collection('events') + .collection("events") .doc(alert.eventId) .get(); if (eventDoc.exists) { const event = eventDoc.data(); - data.eventName = event.Name || event.name || 'Événement'; + data.eventName = event.Name || event.name || "Événement"; if (event.StartDateTime || event.startDate) { const dateField = event.StartDateTime || event.startDate; const date = dateField.toDate ? dateField.toDate() : new Date(dateField); - data.eventDate = date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', + data.eventDate = date.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", }); } } @@ -70,7 +70,7 @@ async function prepareTemplateData(alert, user) { if (alert.equipmentId) { try { const eqDoc = await admin.firestore() - .collection('equipments') + .collection("equipments") .doc(alert.equipmentId) .get(); @@ -90,18 +90,18 @@ async function prepareTemplateData(alert, user) { */ function getEmailSubject(alert) { const subjects = { - 'EVENT_CREATED': '📅 Nouvel événement créé', - 'EVENT_MODIFIED': '📝 Événement modifié', - 'EVENT_CANCELLED': '❌ Événement annulé', - 'LOST': '🔴 Alerte critique : Équipement perdu', - 'EQUIPMENT_MISSING': '⚠️ Équipement manquant', - 'DAMAGED': '⚠️ Équipement endommagé', - 'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte', - 'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance', - 'STOCK_LOW': '📦 Stock faible', + "EVENT_CREATED": "📅 Nouvel événement créé", + "EVENT_MODIFIED": "📝 Événement modifié", + "EVENT_CANCELLED": "❌ Événement annulé", + "LOST": "🔴 Alerte critique : Équipement perdu", + "EQUIPMENT_MISSING": "⚠️ Équipement manquant", + "DAMAGED": "⚠️ Équipement endommagé", + "QUANTITY_MISMATCH": "ℹ️ Quantité incorrecte", + "MAINTENANCE_REMINDER": "🔧 Rappel de maintenance", + "STOCK_LOW": "📦 Stock faible", }; - return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events'; + return subjects[alert.type] || "🔔 Nouvelle alerte - EM2 Events"; } /** @@ -109,18 +109,18 @@ function getEmailSubject(alert) { */ function getAlertTitle(type) { const titles = { - 'EVENT_CREATED': 'Nouvel événement créé', - 'EVENT_MODIFIED': 'Événement modifié', - 'EVENT_CANCELLED': 'Événement annulé', - 'LOST': 'Équipement perdu', - 'EQUIPMENT_MISSING': 'Équipement manquant', - 'DAMAGED': 'Équipement endommagé', - 'QUANTITY_MISMATCH': 'Quantité incorrecte', - 'MAINTENANCE_REMINDER': 'Maintenance requise', - 'STOCK_LOW': 'Stock faible', + "EVENT_CREATED": "Nouvel événement créé", + "EVENT_MODIFIED": "Événement modifié", + "EVENT_CANCELLED": "Événement annulé", + "LOST": "Équipement perdu", + "EQUIPMENT_MISSING": "Équipement manquant", + "DAMAGED": "Équipement endommagé", + "QUANTITY_MISMATCH": "Quantité incorrecte", + "MAINTENANCE_REMINDER": "Maintenance requise", + "STOCK_LOW": "Stock faible", }; - return titles[type] || 'Nouvelle alerte'; + return titles[type] || "Nouvelle alerte"; } /** @@ -129,17 +129,17 @@ function getAlertTitle(type) { async function renderTemplate(templateName, data) { try { // Lire le template de base - const basePath = path.join(__dirname, '..', 'templates', 'base-template.html'); - const baseTemplate = await fs.readFile(basePath, 'utf8'); + const basePath = path.join(__dirname, "..", "templates", "base-template.html"); + const baseTemplate = await fs.readFile(basePath, "utf8"); // Lire le template de contenu const contentPath = path.join( __dirname, - '..', - 'templates', + "..", + "templates", `${templateName}.html`, ); - const contentTemplate = await fs.readFile(contentPath, 'utf8'); + const contentTemplate = await fs.readFile(contentPath, "utf8"); // Compiler les templates const compileContent = handlebars.compile(contentTemplate); diff --git a/em2rp/functions/utils/helpers.js b/em2rp/functions/utils/helpers.js index 3558433..77714ba 100644 --- a/em2rp/functions/utils/helpers.js +++ b/em2rp/functions/utils/helpers.js @@ -1,7 +1,7 @@ /** * Helpers pour la manipulation de données Firestore */ -const admin = require('firebase-admin'); +const admin = require("firebase-admin"); /** * Convertit les Timestamps Firestore en ISO strings pour JSON @@ -19,7 +19,7 @@ function serializeTimestamps(data) { return null; } - const result = { ...data }; + const result = {...data}; for (const key in result) { const value = result[key]; @@ -29,31 +29,31 @@ function serializeTimestamps(data) { } // Gérer les Timestamps Firestore - if (value.toDate && typeof value.toDate === 'function') { + if (value.toDate && typeof value.toDate === "function") { result[key] = value.toDate().toISOString(); } // Gérer les DocumentReference - else if (value.path && value.id && typeof value.path === 'string') { + else if (value.path && value.id && typeof value.path === "string") { result[key] = value.path; } // Gérer les GeoPoint else if (value.latitude !== undefined && value.longitude !== undefined) { result[key] = { latitude: value.latitude, - longitude: value.longitude + longitude: value.longitude, }; } // Gérer les tableaux else if (Array.isArray(value)) { - result[key] = value.map(item => { - if (!item || typeof item !== 'object') return item; + result[key] = value.map((item) => { + if (!item || typeof item !== "object") return item; // DocumentReference dans un tableau if (item.path && item.id) { return item.path; } // Timestamp dans un tableau - if (item.toDate && typeof item.toDate === 'function') { + if (item.toDate && typeof item.toDate === "function") { return item.toDate().toISOString(); } // Objet normal @@ -61,7 +61,7 @@ function serializeTimestamps(data) { }); } // Gérer les objets imbriqués (mais pas les objets Firestore) - else if (typeof value === 'object' && !value._firestore && !value._path) { + else if (typeof value === "object" && !value._firestore && !value._path) { result[key] = serializeTimestamps(value); } } @@ -75,10 +75,10 @@ function serializeTimestamps(data) { function deserializeTimestamps(data, timestampFields = []) { if (!data) return data; - const result = { ...data }; + const result = {...data}; for (const field of timestampFields) { - if (result[field] && typeof result[field] === 'string') { + if (result[field] && typeof result[field] === "string") { result[field] = admin.firestore.Timestamp.fromDate(new Date(result[field])); } } @@ -92,15 +92,15 @@ function deserializeTimestamps(data, timestampFields = []) { function serializeReferences(data) { if (!data) return data; - const result = { ...data }; + const result = {...data}; for (const key in result) { - if (result[key] && result[key].path && typeof result[key].path === 'string') { + if (result[key] && result[key].path && typeof result[key].path === "string") { // C'est une DocumentReference result[key] = result[key].id; } else if (Array.isArray(result[key])) { - result[key] = result[key].map(item => { - if (item && item.path && typeof item.path === 'string') { + result[key] = result[key].map((item) => { + if (item && item.path && typeof item.path === "string") { return item.id; } return item; @@ -117,7 +117,7 @@ function serializeReferences(data) { function maskSensitiveFields(data, canViewSensitive) { if (canViewSensitive) return data; - const masked = { ...data }; + const masked = {...data}; // Masquer les prix si pas de permission manage_equipment delete masked.purchasePrice; @@ -143,34 +143,34 @@ function paginate(query, limit = 50, startAfter = null) { * Filtre les événements annulés */ function filterCancelledEvents(events) { - return events.filter(event => event.status !== 'CANCELLED'); + return events.filter((event) => event.status !== "CANCELLED"); } /** * Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format * @param {Object} data - Données de l'événement - * @returns {Object} - Données avec DocumentReference + * @return {Object} - Données avec DocumentReference */ function convertIdsToReferences(data) { if (!data) return data; - const result = { ...data }; + const result = {...data}; // Convertir EventType (ID → DocumentReference) - if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) { - result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType); + if (result.EventType && typeof result.EventType === "string" && !result.EventType.includes("/")) { + result.EventType = admin.firestore().collection("eventTypes").doc(result.EventType); } // Convertir customer (ID → DocumentReference) - if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) { - result.customer = admin.firestore().collection('customers').doc(result.customer); + if (result.customer && typeof result.customer === "string" && !result.customer.includes("/")) { + result.customer = admin.firestore().collection("customers").doc(result.customer); } // Convertir workforce (IDs → DocumentReference) if (Array.isArray(result.workforce)) { - result.workforce = result.workforce.map(item => { - if (typeof item === 'string' && !item.includes('/')) { - return admin.firestore().collection('users').doc(item); + result.workforce = result.workforce.map((item) => { + if (typeof item === "string" && !item.includes("/")) { + return admin.firestore().collection("users").doc(item); } return item; }); diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index 2883324..7b9044f 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.2.1'; + static const String version = '1.2.3'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/config/env.dart b/em2rp/lib/config/env.dart index 539a953..eff3b62 100644 --- a/em2rp/lib/config/env.dart +++ b/em2rp/lib/config/env.dart @@ -1,9 +1,9 @@ class Env { - static const bool isDevelopment = true; + static const bool isDevelopment = false; // Configuration de l'auto-login en développement - static const String devAdminEmail = 'paul.fournel@em2events.fr'; - static const String devAdminPassword = 'Pastis51!'; + static const String devAdminEmail = ''; + static const String devAdminPassword = ''; // URLs et endpoints static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; @@ -14,4 +14,3 @@ class Env { // Autres configurations static const int apiTimeout = 30000; // 30 secondes } - diff --git a/em2rp/lib/repositories/alert_repository.dart b/em2rp/lib/repositories/alert_repository.dart new file mode 100644 index 0000000..376c718 --- /dev/null +++ b/em2rp/lib/repositories/alert_repository.dart @@ -0,0 +1,38 @@ +import 'package:em2rp/services/api_service.dart'; + +/// Repository pour gérer toutes les opérations sur les alertes. +class AlertRepository { + final ApiService _apiService; + + AlertRepository(this._apiService); + + /// Récupère toutes les alertes + Future>> getAlerts() async { + try { + final result = await _apiService.call('getAlerts', {}); + final alerts = result['alerts'] as List?; + if (alerts == null) return []; + return alerts.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des alertes: $e'); + } + } + + /// Marque une alerte comme lue + Future markAlertAsRead(String alertId) async { + try { + await _apiService.call('markAlertAsRead', {'alertId': alertId}); + } catch (e) { + throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e'); + } + } + + /// Supprime une alerte + Future deleteAlert(String alertId) async { + try { + await _apiService.call('deleteAlert', {'alertId': alertId}); + } catch (e) { + throw Exception('Erreur lors de la suppression de l\'alerte: $e'); + } + } +} diff --git a/em2rp/lib/repositories/container_repository.dart b/em2rp/lib/repositories/container_repository.dart new file mode 100644 index 0000000..b675ebf --- /dev/null +++ b/em2rp/lib/repositories/container_repository.dart @@ -0,0 +1,128 @@ +import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; + +/// Repository pour gérer toutes les opérations sur les conteneurs. +class ContainerRepository { + final ApiService _apiService; + + ContainerRepository(this._apiService); + + /// Récupère tous les conteneurs + Future>> getContainers() async { + try { + final result = await _apiService.call('getContainers', {}); + final containers = result['containers'] as List?; + if (containers == null) return []; + return containers.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des conteneurs: $e'); + } + } + + /// Récupère plusieurs containers par leurs IDs + Future>> getContainersByIds( + List containerIds) async { + try { + if (containerIds.isEmpty) return []; + + print( + '[ContainerRepository] Getting containers by IDs: ${containerIds.length} items'); + final result = await _apiService.call('getContainersByIds', { + 'containerIds': containerIds, + }); + final containers = result['containers'] as List?; + if (containers == null) { + print('[ContainerRepository] No containers in result'); + return []; + } + print('[ContainerRepository] Found ${containers.length} containers by IDs'); + return containers.map((e) => e as Map).toList(); + } catch (e) { + print('[ContainerRepository] Error getting containers by IDs: $e'); + throw Exception('Erreur lors de la récupération des containers: $e'); + } + } + + /// Récupère les containers avec pagination et filtrage + Future> getContainersPaginated({ + int limit = 20, + String? startAfter, + String? type, + String? status, + String? searchQuery, + String? category, + String sortBy = 'id', + String sortOrder = 'asc', + }) async { + try { + final params = { + 'limit': limit, + 'sortBy': sortBy, + 'sortOrder': sortOrder, + }; + + if (startAfter != null) params['startAfter'] = startAfter; + if (type != null) params['type'] = type; + if (status != null) params['status'] = status; + if (category != null) params['category'] = category; + if (searchQuery != null && searchQuery.isNotEmpty) { + params['searchQuery'] = searchQuery; + } + + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( + 'getContainersPaginated', + params, + ); + + return { + 'containers': (result['containers'] as List?) + ?.map((e) => e as Map) + .toList() ?? + [], + 'hasMore': result['hasMore'] as bool? ?? false, + 'lastVisible': result['lastVisible'] as String?, + 'total': result['total'] as int? ?? 0, + }; + } catch (e) { + DebugLog.error('[ContainerRepository] Error in getContainersPaginated', e); + throw Exception( + 'Erreur lors de la récupération paginée des containers: $e'); + } + } + + /// Récupère les containers contenant un équipement + Future>> getContainersByEquipment( + String equipmentId) async { + try { + final result = await _apiService.call('getContainersByEquipment', { + 'equipmentId': equipmentId, + }); + final containers = result['containers'] as List?; + if (containers == null) return []; + return containers.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des containers: $e'); + } + } + + /// Vérifie la disponibilité d'un container + Future> checkContainerAvailability({ + required String containerId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final result = await _apiService.call('checkContainerAvailability', { + 'containerId': containerId, + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + if (excludeEventId != null) 'excludeEventId': excludeEventId, + }); + return result; + } catch (e) { + throw Exception('Erreur lors de la vérification de disponibilité du container: $e'); + } + } +} diff --git a/em2rp/lib/repositories/equipment_repository.dart b/em2rp/lib/repositories/equipment_repository.dart new file mode 100644 index 0000000..07336ad --- /dev/null +++ b/em2rp/lib/repositories/equipment_repository.dart @@ -0,0 +1,350 @@ +import 'package:em2rp/services/api_service.dart'; +import 'package:em2rp/utils/debug_log.dart'; + +/// Repository pour gérer toutes les opérations sur les équipements. +class EquipmentRepository { + final ApiService _apiService; + + EquipmentRepository(this._apiService); + + /// Récupère tous les équipements (avec masquage des prix selon permissions) + Future>> getEquipments() async { + try { + print('[EquipmentRepository] Calling getEquipments API...'); + final result = await _apiService.call('getEquipments', {}); + print('[EquipmentRepository] API call successful, parsing result...'); + final equipments = result['equipments'] as List?; + if (equipments == null) { + print('[EquipmentRepository] No equipments in result'); + return []; + } + print('[EquipmentRepository] Found ${equipments.length} equipments'); + return equipments.map((e) => e as Map).toList(); + } catch (e) { + print('[EquipmentRepository] Error getting equipments: $e'); + throw Exception('Erreur lors de la récupération des équipements: $e'); + } + } + + /// Récupère plusieurs équipements par leurs IDs + Future>> getEquipmentsByIds( + List equipmentIds) async { + try { + if (equipmentIds.isEmpty) return []; + + print( + '[EquipmentRepository] Getting equipments by IDs: ${equipmentIds.length} items'); + final result = await _apiService.call('getEquipmentsByIds', { + 'equipmentIds': equipmentIds, + }); + final equipments = result['equipments'] as List?; + if (equipments == null) { + print('[EquipmentRepository] No equipments in result'); + return []; + } + print('[EquipmentRepository] Found ${equipments.length} equipments by IDs'); + return equipments.map((e) => e as Map).toList(); + } catch (e) { + print('[EquipmentRepository] Error getting equipments by IDs: $e'); + throw Exception('Erreur lors de la récupération des équipements: $e'); + } + } + + /// Récupère les équipements avec pagination et filtrage + Future> getEquipmentsPaginated({ + int limit = 20, + String? startAfter, + String? category, + String? status, + String? searchQuery, + String sortBy = 'id', + String sortOrder = 'asc', + }) async { + try { + final params = { + 'limit': limit, + 'sortBy': sortBy, + 'sortOrder': sortOrder, + }; + + if (startAfter != null) params['startAfter'] = startAfter; + if (category != null) params['category'] = category; + if (status != null) params['status'] = status; + if (searchQuery != null && searchQuery.isNotEmpty) { + params['searchQuery'] = searchQuery; + } + + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( + 'getEquipmentsPaginated', + params, + ); + + return { + 'equipments': (result['equipments'] as List?) + ?.map((e) => e as Map) + .toList() ?? + [], + 'hasMore': result['hasMore'] as bool? ?? false, + 'lastVisible': result['lastVisible'] as String?, + 'total': result['total'] as int? ?? 0, + }; + } catch (e) { + DebugLog.error('[EquipmentRepository] Error in getEquipmentsPaginated', e); + throw Exception( + 'Erreur lors de la récupération paginée des équipements: $e'); + } + } + + /// Crée un équipement + Future createEquipment( + String equipmentId, Map data) async { + try { + final equipmentData = Map.from(data); + equipmentData['id'] = equipmentId; + + await _apiService.call('createEquipment', equipmentData); + } catch (e) { + throw Exception('Erreur lors de la création de l\'équipement: $e'); + } + } + + /// Met à jour un équipement + Future updateEquipment( + String equipmentId, Map data) async { + try { + await _apiService.call('updateEquipment', { + 'equipmentId': equipmentId, + 'data': data, + }); + } catch (e) { + throw Exception('Erreur lors de la mise à jour de l\'équipement: $e'); + } + } + + /// Supprime un équipement + Future deleteEquipment(String equipmentId, + {bool forceDelete = false}) async { + try { + await _apiService.call('deleteEquipment', { + 'equipmentId': equipmentId, + 'forceDelete': forceDelete, + }); + } on ApiException { + rethrow; + } catch (e) { + throw Exception('Erreur lors de la suppression de l\'équipement: $e'); + } + } + + /// Met à jour uniquement le statut d'un équipement + Future updateEquipmentStatusOnly({ + required String equipmentId, + String? status, + int? availableQuantity, + }) async { + try { + final data = {'equipmentId': equipmentId}; + + if (status != null) data['status'] = status; + if (availableQuantity != null) { + data['availableQuantity'] = availableQuantity; + } + + await _apiService.call('updateEquipmentStatusOnly', data); + } catch (e) { + throw Exception( + 'Erreur lors de la mise à jour du statut de l\'équipement: $e'); + } + } + + /// Recherche rapide (autocomplétion) + Future>> quickSearch( + String query, { + int limit = 10, + bool includeEquipments = true, + bool includeContainers = true, + }) async { + try { + return await (_apiService as FirebaseFunctionsApiService).quickSearch( + query, + limit: limit, + includeEquipments: includeEquipments, + includeContainers: includeContainers, + ); + } catch (e) { + DebugLog.error('[EquipmentRepository] Error in quickSearch', e); + return []; + } + } + + /// Recherche des équipements pour l'assistant IA avec fallback paginé. + Future>> searchEquipmentsForAssistant({ + required String query, + int limit = 12, + }) async { + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty) { + return []; + } + + try { + final quickResults = await quickSearch( + normalizedQuery, + limit: limit, + includeEquipments: true, + includeContainers: false, + ); + + final equipmentResults = quickResults + .where((item) => + (item['type']?.toString().toLowerCase() ?? '') == 'equipment') + .map(_normalizeAssistantEquipment) + .toList(); + + if (equipmentResults.isNotEmpty) { + return equipmentResults; + } + + final paginated = await getEquipmentsPaginated( + limit: limit, + searchQuery: normalizedQuery, + sortBy: 'id', + sortOrder: 'asc', + ); + + final equipments = + paginated['equipments'] as List>? ?? []; + return equipments.map(_normalizeAssistantEquipment).toList(); + } catch (e) { + DebugLog.error('[EquipmentRepository] Error in searchEquipmentsForAssistant', e); + throw Exception('Erreur lors de la recherche de matériel: $e'); + } + } + + /// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA. + Future> checkEquipmentAvailabilityForAssistant({ + required String equipmentId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final result = await checkEquipmentAvailability( + equipmentId: equipmentId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + final available = result['available'] as bool? ?? true; + final conflicts = (result['conflicts'] as List? ?? const []) + .whereType>() + .map((conflict) { + final eventData = + conflict['eventData'] as Map? ?? const {}; + final eventName = + (eventData['Name'] ?? conflict['eventName'] ?? '').toString(); + return { + 'eventId': conflict['eventId']?.toString() ?? '', + 'eventName': eventName, + 'overlapDays': conflict['overlapDays'] as int? ?? 0, + }; + }).toList(); + + return { + 'equipmentId': equipmentId, + 'available': available, + 'conflictCount': conflicts.length, + 'conflicts': conflicts, + }; + } catch (e) { + DebugLog.error( + '[EquipmentRepository] Error in checkEquipmentAvailabilityForAssistant', e); + throw Exception('Erreur lors de la vérification de disponibilité: $e'); + } + } + + /// Vérifie la disponibilité d'un équipement + Future> checkEquipmentAvailability({ + required String equipmentId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final result = await _apiService.call('checkEquipmentAvailability', { + 'equipmentId': equipmentId, + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + if (excludeEventId != null) 'excludeEventId': excludeEventId, + }); + return result; + } catch (e) { + throw Exception('Erreur lors de la vérification de disponibilité: $e'); + } + } + + /// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période + /// Optimisé : une seule requête au lieu d'une par équipement + Future> getConflictingEquipmentIds({ + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + int installationTime = 0, + int disassemblyTime = 0, + }) async { + try { + final result = await _apiService.call('getConflictingEquipmentIds', { + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + if (excludeEventId != null) 'excludeEventId': excludeEventId, + 'installationTime': installationTime, + 'disassemblyTime': disassemblyTime, + }); + return result; + } catch (e) { + throw Exception( + 'Erreur lors de la récupération des équipements en conflit: $e'); + } + } + + Map _normalizeAssistantEquipment(Map item) { + return { + 'id': (item['id'] ?? '').toString(), + 'name': (item['name'] ?? item['id'] ?? '').toString(), + 'category': (item['category'] ?? '').toString(), + 'status': (item['status'] ?? '').toString(), + 'brand': item['brand']?.toString(), + 'model': item['model']?.toString(), + 'availableQuantity': item['availableQuantity'], + 'totalQuantity': item['totalQuantity'], + }; + } + + /// Récupère toutes les maintenances + Future>> getMaintenances( + {String? equipmentId}) async { + try { + final data = {}; + if (equipmentId != null) data['equipmentId'] = equipmentId; + + final result = await _apiService.call('getMaintenances', data); + final maintenances = result['maintenances'] as List?; + if (maintenances == null) return []; + return maintenances.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des maintenances: $e'); + } + } + + /// Supprime une maintenance + Future deleteMaintenance(String maintenanceId) async { + try { + await _apiService + .call('deleteMaintenance', {'maintenanceId': maintenanceId}); + } catch (e) { + throw Exception('Erreur lors de la suppression de la maintenance: $e'); + } + } +} diff --git a/em2rp/lib/repositories/event_repository.dart b/em2rp/lib/repositories/event_repository.dart new file mode 100644 index 0000000..b3a9483 --- /dev/null +++ b/em2rp/lib/repositories/event_repository.dart @@ -0,0 +1,179 @@ +import 'package:em2rp/services/api_service.dart'; + +/// Repository pour gérer toutes les opérations sur les événements. +class EventRepository { + final ApiService _apiService; + + EventRepository(this._apiService); + + /// Met à jour les équipements d'un événement + Future updateEventEquipment({ + required String eventId, + List>? assignedEquipment, + String? preparationStatus, + String? loadingStatus, + String? unloadingStatus, + String? returnStatus, + }) async { + try { + final data = {'eventId': eventId}; + + if (assignedEquipment != null) { + data['assignedEquipment'] = assignedEquipment; + } + if (preparationStatus != null) { + data['preparationStatus'] = preparationStatus; + } + if (loadingStatus != null) data['loadingStatus'] = loadingStatus; + if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus; + if (returnStatus != null) data['returnStatus'] = returnStatus; + + await _apiService.call('updateEventEquipment', data); + } catch (e) { + throw Exception( + 'Erreur lors de la mise à jour des équipements de l\'événement: $e'); + } + } + + /// Met à jour un événement + Future updateEvent(String eventId, Map data) async { + try { + final requestData = {'eventId': eventId, ...data}; + await _apiService.call('updateEvent', requestData); + } catch (e) { + throw Exception('Erreur lors de la mise à jour de l\'événement: $e'); + } + } + + /// Supprime un événement + Future deleteEvent(String eventId) async { + try { + await _apiService.call('deleteEvent', {'eventId': eventId}); + } catch (e) { + throw Exception('Erreur lors de la suppression de l\'événement: $e'); + } + } + + /// Récupère les événements utilisant un type d'événement donné + Future>> getEventsByEventType( + String eventTypeId) async { + try { + final result = await _apiService + .call('getEventsByEventType', {'eventTypeId': eventTypeId}); + final events = result['events'] as List?; + if (events == null) return []; + return events.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des événements: $e'); + } + } + + /// Récupère tous les événements (filtrés selon permissions) + /// Retourne { events: List, users: Map } + Future> getEvents({String? userId}) async { + try { + final data = {}; + if (userId != null) data['userId'] = userId; + + final result = await _apiService.call('getEvents', data); + + // Extraire events et users + final events = result['events'] as List? ?? []; + final users = result['users'] as Map? ?? {}; + + return { + 'events': events.map((e) => e as Map).toList(), + 'users': users, + }; + } catch (e) { + throw Exception('Erreur lors de la récupération des événements: $e'); + } + } + + /// Récupère les événements d'un mois spécifique (lazy loading optimisé) + Future> getEventsByMonth({ + required String userId, + required int year, + required int month, + }) async { + try { + print('[EventRepository] Calling getEventsByMonth for $year-$month'); + final result = await _apiService.call('getEventsByMonth', { + 'userId': userId, + 'year': year, + 'month': month, + }); + + // Extraire events et users + final events = result['events'] as List? ?? []; + final users = result['users'] as Map? ?? {}; + + print( + '[EventRepository] Events loaded for $year-$month: ${events.length} events'); + + return { + 'events': events.map((e) => e as Map).toList(), + 'users': users, + }; + } catch (e) { + print('[EventRepository] Error getting events by month: $e'); + throw Exception( + 'Erreur lors de la récupération des événements du mois: $e'); + } + } + + /// Recherche des événements accessibles à l'utilisateur. + Future>> searchEvents({ + required String userId, + required String query, + int limit = 20, + }) async { + try { + final result = await _apiService.call('searchEvents', { + 'userId': userId, + 'query': query, + 'limit': limit, + }); + + final events = result['events'] as List?; + if (events == null) { + return []; + } + + return events.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la recherche d\'événements: $e'); + } + } + + /// Récupère un événement avec tous les détails (équipements complets + containers avec enfants) + Future> getEventWithDetails(String eventId) async { + try { + print('[EventRepository] Getting event with details: $eventId'); + final result = await _apiService.call('getEventWithDetails', { + 'eventId': eventId, + }); + + final event = result['event'] as Map?; + final equipments = result['equipments'] as Map? ?? {}; + final containers = result['containers'] as Map? ?? {}; + + if (event == null) { + throw Exception('Event not found'); + } + + print( + '[EventRepository] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); + + return { + 'event': event, + 'equipments': equipments, + 'containers': containers, + }; + } catch (e) { + print('[EventRepository] Error getting event with details: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'événement avec détails: $e'); + } + } +} diff --git a/em2rp/lib/repositories/option_repository.dart b/em2rp/lib/repositories/option_repository.dart new file mode 100644 index 0000000..362462d --- /dev/null +++ b/em2rp/lib/repositories/option_repository.dart @@ -0,0 +1,109 @@ +import 'package:em2rp/services/api_service.dart'; + +/// Repository pour gérer toutes les opérations sur les options et types d'événements. +class OptionRepository { + final ApiService _apiService; + + OptionRepository(this._apiService); + + /// Récupère toutes les options + Future>> getOptions() async { + try { + final result = await _apiService.call('getOptions', {}); + final options = result['options'] as List?; + if (options == null) return []; + return options.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des options: $e'); + } + } + + /// Récupère tous les types d'événements + Future>> getEventTypes() async { + try { + final result = await _apiService.call('getEventTypes', {}); + final eventTypes = result['eventTypes'] as List?; + if (eventTypes == null) return []; + return eventTypes.map((e) => e as Map).toList(); + } catch (e) { + throw Exception( + 'Erreur lors de la récupération des types d\'événements: $e'); + } + } + + /// Crée un type d'événement + Future createEventType({ + required String name, + required double defaultPrice, + }) async { + try { + final result = await _apiService.call('createEventType', { + 'name': name, + 'defaultPrice': defaultPrice, + }); + return result['id'] as String; + } catch (e) { + throw Exception('Erreur lors de la création du type d\'événement: $e'); + } + } + + /// Met à jour un type d'événement + Future updateEventType({ + required String eventTypeId, + String? name, + double? defaultPrice, + }) async { + try { + final data = {'eventTypeId': eventTypeId}; + if (name != null) data['name'] = name; + if (defaultPrice != null) data['defaultPrice'] = defaultPrice; + + await _apiService.call('updateEventType', data); + } catch (e) { + throw Exception('Erreur lors de la mise à jour du type d\'événement: $e'); + } + } + + /// Supprime un type d'événement + Future deleteEventType(String eventTypeId) async { + try { + await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId}); + } catch (e) { + throw Exception('Erreur lors de la suppression du type d\'événement: $e'); + } + } + + /// Crée une option + Future createOption(String code, Map data) async { + try { + final requestData = { + 'id': code, + 'code': code, + ...data + }; + final result = await _apiService.call('createOption', requestData); + return result['id'] as String? ?? code; + } catch (e) { + throw Exception('Erreur lors de la création de l\'option: $e'); + } + } + + /// Met à jour une option + Future updateOption(String optionId, Map data) async { + try { + final requestData = {'optionId': optionId, 'data': data}; + await _apiService.call('updateOption', requestData); + } catch (e) { + throw Exception('Erreur lors de la mise à jour de l\'option: $e'); + } + } + + /// Supprime une option + Future deleteOption(String optionId) async { + try { + await _apiService.call('deleteOption', {'optionId': optionId}); + } catch (e) { + throw Exception('Erreur lors de la suppression de l\'option: $e'); + } + } +} diff --git a/em2rp/lib/repositories/user_repository.dart b/em2rp/lib/repositories/user_repository.dart new file mode 100644 index 0000000..5c006f1 --- /dev/null +++ b/em2rp/lib/repositories/user_repository.dart @@ -0,0 +1,99 @@ +import 'package:em2rp/services/api_service.dart'; + +/// Repository pour gérer toutes les opérations sur les utilisateurs et les rôles. +class UserRepository { + final ApiService _apiService; + + UserRepository(this._apiService); + + /// Récupère l'utilisateur actuellement authentifié avec son rôle + Future> getCurrentUser() async { + try { + print('[UserRepository] Calling getCurrentUser API...'); + final result = await _apiService.call('getCurrentUser', {}); + print('[UserRepository] Current user loaded successfully'); + return result['user'] as Map; + } catch (e) { + print('[UserRepository] Error getting current user: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'utilisateur actuel: $e'); + } + } + + /// Récupère tous les utilisateurs (selon permissions) + Future>> getUsers() async { + try { + final result = await _apiService.call('getUsers', {}); + final users = result['users'] as List?; + if (users == null) return []; + return users.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des utilisateurs: $e'); + } + } + + /// Récupère un utilisateur spécifique + Future> getUser(String userId) async { + try { + final result = await _apiService.call('getUser', {'userId': userId}); + return result['user'] as Map; + } catch (e) { + throw Exception('Erreur lors de la récupération de l\'utilisateur: $e'); + } + } + + /// Supprime un utilisateur (Auth + Firestore) + Future deleteUser(String userId) async { + try { + await _apiService.call('deleteUser', {'userId': userId}); + } catch (e) { + throw Exception('Erreur lors de la suppression de l\'utilisateur: $e'); + } + } + + /// Met à jour un utilisateur + Future updateUser(String userId, Map data) async { + try { + await _apiService.call('updateUser', { + 'userId': userId, + 'data': data, + }); + } catch (e) { + throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e'); + } + } + + /// Crée un utilisateur avec invitation par email + Future> createUserWithInvite({ + required String email, + required String firstName, + required String lastName, + String? phoneNumber, + required String roleId, + }) async { + try { + final result = await _apiService.call('createUserWithInvite', { + 'email': email, + 'firstName': firstName, + 'lastName': lastName, + 'phoneNumber': phoneNumber ?? '', + 'roleId': roleId, + }); + return result; + } catch (e) { + throw Exception('Erreur lors de la création de l\'utilisateur: $e'); + } + } + + /// Récupère tous les rôles + Future>> getRoles() async { + try { + final result = await _apiService.call('getRoles', {}); + final roles = result['roles'] as List?; + if (roles == null) return []; + return roles.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la récupération des rôles: $e'); + } + } +} diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index bd25e52..e3f7235 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -1,50 +1,55 @@ import 'package:em2rp/services/api_service.dart'; -import 'package:em2rp/utils/debug_log.dart'; +import 'package:em2rp/repositories/event_repository.dart'; +import 'package:em2rp/repositories/equipment_repository.dart'; +import 'package:em2rp/repositories/container_repository.dart'; +import 'package:em2rp/repositories/alert_repository.dart'; +import 'package:em2rp/repositories/user_repository.dart'; +import 'package:em2rp/repositories/option_repository.dart'; -/// Service générique pour les opérations de lecture de données via Cloud Functions +/// Service façade pour rétrocompatibilité. +/// Délègue les opérations aux Repositories de domaine respectifs. class DataService { - final ApiService _apiService; + final EventRepository eventRepository; + final EquipmentRepository equipmentRepository; + final ContainerRepository containerRepository; + final AlertRepository alertRepository; + final UserRepository userRepository; + final OptionRepository optionRepository; - DataService(this._apiService); + DataService(ApiService apiService) + : eventRepository = EventRepository(apiService), + equipmentRepository = EquipmentRepository(apiService), + containerRepository = ContainerRepository(apiService), + alertRepository = AlertRepository(apiService), + userRepository = UserRepository(apiService), + optionRepository = OptionRepository(apiService); - /// Récupère toutes les options - Future>> getOptions() async { - try { - final result = await _apiService.call('getOptions', {}); - final options = result['options'] as List?; - if (options == null) return []; - return options.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des options: $e'); - } - } + // ============================================================================ + // OPTIONS & METADATA (delegated to OptionRepository) + // ============================================================================ + Future>> getOptions() => optionRepository.getOptions(); - /// Récupère tous les types d'événements - Future>> getEventTypes() async { - try { - final result = await _apiService.call('getEventTypes', {}); - final eventTypes = result['eventTypes'] as List?; - if (eventTypes == null) return []; - return eventTypes.map((e) => e as Map).toList(); - } catch (e) { - throw Exception( - 'Erreur lors de la récupération des types d\'événements: $e'); - } - } + Future>> getEventTypes() => optionRepository.getEventTypes(); - /// Récupère tous les rôles - Future>> getRoles() async { - try { - final result = await _apiService.call('getRoles', {}); - final roles = result['roles'] as List?; - if (roles == null) return []; - return roles.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des rôles: $e'); - } - } + Future createEventType({required String name, required double defaultPrice}) => + optionRepository.createEventType(name: name, defaultPrice: defaultPrice); - /// Met à jour les équipements d'un événement + Future updateEventType({required String eventTypeId, String? name, double? defaultPrice}) => + optionRepository.updateEventType(eventTypeId: eventTypeId, name: name, defaultPrice: defaultPrice); + + Future deleteEventType(String eventTypeId) => optionRepository.deleteEventType(eventTypeId); + + Future createOption(String code, Map data) => + optionRepository.createOption(code, data); + + Future updateOption(String optionId, Map data) => + optionRepository.updateOption(optionId, data); + + Future deleteOption(String optionId) => optionRepository.deleteOption(optionId); + + // ============================================================================ + // EVENTS (delegated to EventRepository) + // ============================================================================ Future updateEventEquipment({ required String eventId, List>? assignedEquipment, @@ -52,394 +57,45 @@ class DataService { String? loadingStatus, String? unloadingStatus, String? returnStatus, - }) async { - try { - final data = {'eventId': eventId}; + }) => + eventRepository.updateEventEquipment( + eventId: eventId, + assignedEquipment: assignedEquipment, + preparationStatus: preparationStatus, + loadingStatus: loadingStatus, + unloadingStatus: unloadingStatus, + returnStatus: returnStatus, + ); - if (assignedEquipment != null) - data['assignedEquipment'] = assignedEquipment; - if (preparationStatus != null) - data['preparationStatus'] = preparationStatus; - if (loadingStatus != null) data['loadingStatus'] = loadingStatus; - if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus; - if (returnStatus != null) data['returnStatus'] = returnStatus; + Future updateEvent(String eventId, Map data) => + eventRepository.updateEvent(eventId, data); - await _apiService.call('updateEventEquipment', data); - } catch (e) { - throw Exception( - 'Erreur lors de la mise à jour des équipements de l\'événement: $e'); - } - } + Future deleteEvent(String eventId) => eventRepository.deleteEvent(eventId); - /// Met à jour uniquement le statut d'un équipement - Future updateEquipmentStatusOnly({ - required String equipmentId, - String? status, - int? availableQuantity, - }) async { - try { - final data = {'equipmentId': equipmentId}; + Future>> getEventsByEventType(String eventTypeId) => + eventRepository.getEventsByEventType(eventTypeId); - if (status != null) data['status'] = status; - if (availableQuantity != null) - data['availableQuantity'] = availableQuantity; + Future> getEvents({String? userId}) => + eventRepository.getEvents(userId: userId); - await _apiService.call('updateEquipmentStatusOnly', data); - } catch (e) { - throw Exception( - 'Erreur lors de la mise à jour du statut de l\'équipement: $e'); - } - } + Future> getEventsByMonth({required String userId, required int year, required int month}) => + eventRepository.getEventsByMonth(userId: userId, year: year, month: month); - /// Met à jour un événement - Future updateEvent(String eventId, Map data) async { - try { - // Correction : fusionner eventId et les champs de data à la racine - final requestData = {'eventId': eventId, ...data}; - await _apiService.call('updateEvent', requestData); - } catch (e) { - throw Exception('Erreur lors de la mise à jour de l\'événement: $e'); - } - } + Future>> searchEvents({required String userId, required String query, int limit = 20}) => + eventRepository.searchEvents(userId: userId, query: query, limit: limit); - /// Supprime un événement - Future deleteEvent(String eventId) async { - try { - await _apiService.call('deleteEvent', {'eventId': eventId}); - } catch (e) { - throw Exception('Erreur lors de la suppression de l\'événement: $e'); - } - } - - /// Crée un équipement - Future createEquipment( - String equipmentId, Map data) async { - try { - // S'assurer que l'ID est dans les données - final equipmentData = Map.from(data); - equipmentData['id'] = equipmentId; - - await _apiService.call('createEquipment', equipmentData); - } catch (e) { - throw Exception('Erreur lors de la création de l\'équipement: $e'); - } - } - - /// Met à jour un équipement - Future updateEquipment( - String equipmentId, Map data) async { - try { - await _apiService.call('updateEquipment', { - 'equipmentId': equipmentId, - 'data': data, - }); - } catch (e) { - throw Exception('Erreur lors de la mise à jour de l\'équipement: $e'); - } - } - - /// Supprime un équipement - Future deleteEquipment(String equipmentId, - {bool forceDelete = false}) async { - try { - await _apiService.call('deleteEquipment', { - 'equipmentId': equipmentId, - 'forceDelete': forceDelete, - }); - } on ApiException { - rethrow; - } catch (e) { - throw Exception('Erreur lors de la suppression de l\'équipement: $e'); - } - } - - /// Récupère les événements utilisant un type d'événement donné - Future>> getEventsByEventType( - String eventTypeId) async { - try { - final result = await _apiService - .call('getEventsByEventType', {'eventTypeId': eventTypeId}); - final events = result['events'] as List?; - if (events == null) return []; - return events.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des événements: $e'); - } - } - - /// Crée un type d'événement - Future createEventType({ - required String name, - required double defaultPrice, - }) async { - try { - final result = await _apiService.call('createEventType', { - 'name': name, - 'defaultPrice': defaultPrice, - }); - return result['id'] as String; - } catch (e) { - throw Exception('Erreur lors de la création du type d\'événement: $e'); - } - } - - /// Met à jour un type d'événement - Future updateEventType({ - required String eventTypeId, - String? name, - double? defaultPrice, - }) async { - try { - final data = {'eventTypeId': eventTypeId}; - if (name != null) data['name'] = name; - if (defaultPrice != null) data['defaultPrice'] = defaultPrice; - - await _apiService.call('updateEventType', data); - } catch (e) { - throw Exception('Erreur lors de la mise à jour du type d\'événement: $e'); - } - } - - /// Supprime un type d'événement - Future deleteEventType(String eventTypeId) async { - try { - await _apiService.call('deleteEventType', {'eventTypeId': eventTypeId}); - } catch (e) { - throw Exception('Erreur lors de la suppression du type d\'événement: $e'); - } - } - - /// Crée une option - Future createOption(String code, Map data) async { - try { - final requestData = { - 'id': code, // Ajouter l'ID en utilisant le code comme identifiant - 'code': code, - ...data - }; - final result = await _apiService.call('createOption', requestData); - return result['id'] as String? ?? code; - } catch (e) { - throw Exception('Erreur lors de la création de l\'option: $e'); - } - } - - /// Met à jour une option - Future updateOption(String optionId, Map data) async { - try { - final requestData = {'optionId': optionId, 'data': data}; - await _apiService.call('updateOption', requestData); - } catch (e) { - throw Exception('Erreur lors de la mise à jour de l\'option: $e'); - } - } - - /// Supprime une option - Future deleteOption(String optionId) async { - try { - await _apiService.call('deleteOption', {'optionId': optionId}); - } catch (e) { - throw Exception('Erreur lors de la suppression de l\'option: $e'); - } - } + Future> getEventWithDetails(String eventId) => + eventRepository.getEventWithDetails(eventId); // ============================================================================ - // LECTURE DES DONNÉES (avec permissions côté serveur) + // EQUIPMENTS & AVAILABILITY (delegated to EquipmentRepository) // ============================================================================ + Future>> getEquipments() => + equipmentRepository.getEquipments(); - /// Récupère tous les événements (filtrés selon permissions) - /// Retourne { events: List, users: Map } - Future> getEvents({String? userId}) async { - try { - final data = {}; - if (userId != null) data['userId'] = userId; + Future>> getEquipmentsByIds(List equipmentIds) => + equipmentRepository.getEquipmentsByIds(equipmentIds); - final result = await _apiService.call('getEvents', data); - - // Extraire events et users - final events = result['events'] as List? ?? []; - final users = result['users'] as Map? ?? {}; - - return { - 'events': events.map((e) => e as Map).toList(), - 'users': users, - }; - } catch (e) { - throw Exception('Erreur lors de la récupération des événements: $e'); - } - } - - /// Récupère les événements d'un mois spécifique (lazy loading optimisé) - Future> getEventsByMonth({ - required String userId, - required int year, - required int month, - }) async { - try { - print('[DataService] Calling getEventsByMonth for $year-$month'); - final result = await _apiService.call('getEventsByMonth', { - 'userId': userId, - 'year': year, - 'month': month, - }); - - // Extraire events et users - final events = result['events'] as List? ?? []; - final users = result['users'] as Map? ?? {}; - - print( - '[DataService] Events loaded for $year-$month: ${events.length} events'); - - return { - 'events': events.map((e) => e as Map).toList(), - 'users': users, - }; - } catch (e) { - print('[DataService] Error getting events by month: $e'); - throw Exception( - 'Erreur lors de la récupération des événements du mois: $e'); - } - } - - /// Recherche des événements accessibles à l'utilisateur. - Future>> searchEvents({ - required String userId, - required String query, - int limit = 20, - }) async { - try { - final result = await _apiService.call('searchEvents', { - 'userId': userId, - 'query': query, - 'limit': limit, - }); - - final events = result['events'] as List?; - if (events == null) { - return []; - } - - return events.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la recherche d\'événements: $e'); - } - } - - /// Récupère un événement avec tous les détails (équipements complets + containers avec enfants) - Future> getEventWithDetails(String eventId) async { - try { - print('[DataService] Getting event with details: $eventId'); - final result = await _apiService.call('getEventWithDetails', { - 'eventId': eventId, - }); - - final event = result['event'] as Map?; - final equipments = result['equipments'] as Map? ?? {}; - final containers = result['containers'] as Map? ?? {}; - - if (event == null) { - throw Exception('Event not found'); - } - - print( - '[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); - - return { - 'event': event, - 'equipments': equipments, - 'containers': containers, - }; - } catch (e) { - print('[DataService] Error getting event with details: $e'); - throw Exception( - 'Erreur lors de la récupération de l\'événement avec détails: $e'); - } - } - - /// Récupère tous les équipements (avec masquage des prix selon permissions) - Future>> getEquipments() async { - try { - print('[DataService] Calling getEquipments API...'); - final result = await _apiService.call('getEquipments', {}); - print('[DataService] API call successful, parsing result...'); - final equipments = result['equipments'] as List?; - if (equipments == null) { - print('[DataService] No equipments in result'); - return []; - } - print('[DataService] Found ${equipments.length} equipments'); - return equipments.map((e) => e as Map).toList(); - } catch (e) { - print('[DataService] Error getting equipments: $e'); - throw Exception('Erreur lors de la récupération des équipements: $e'); - } - } - - /// Récupère plusieurs équipements par leurs IDs - Future>> getEquipmentsByIds( - List equipmentIds) async { - try { - if (equipmentIds.isEmpty) return []; - - print( - '[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); - final result = await _apiService.call('getEquipmentsByIds', { - 'equipmentIds': equipmentIds, - }); - final equipments = result['equipments'] as List?; - if (equipments == null) { - print('[DataService] No equipments in result'); - return []; - } - print('[DataService] Found ${equipments.length} equipments by IDs'); - return equipments.map((e) => e as Map).toList(); - } catch (e) { - print('[DataService] Error getting equipments by IDs: $e'); - throw Exception('Erreur lors de la récupération des équipements: $e'); - } - } - - /// Récupère tous les conteneurs - Future>> getContainers() async { - try { - final result = await _apiService.call('getContainers', {}); - final containers = result['containers'] as List?; - if (containers == null) return []; - return containers.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des conteneurs: $e'); - } - } - - /// Récupère plusieurs containers par leurs IDs - Future>> getContainersByIds( - List containerIds) async { - try { - if (containerIds.isEmpty) return []; - - print( - '[DataService] Getting containers by IDs: ${containerIds.length} items'); - final result = await _apiService.call('getContainersByIds', { - 'containerIds': containerIds, - }); - final containers = result['containers'] as List?; - if (containers == null) { - print('[DataService] No containers in result'); - return []; - } - print('[DataService] Found ${containers.length} containers by IDs'); - return containers.map((e) => e as Map).toList(); - } catch (e) { - print('[DataService] Error getting containers by IDs: $e'); - throw Exception('Erreur lors de la récupération des containers: $e'); - } - } - - // ============================================================================ - // EQUIPMENTS & CONTAINERS - Pagination - // ============================================================================ - - /// Récupère les équipements avec pagination et filtrage Future> getEquipmentsPaginated({ int limit = 20, String? startAfter, @@ -448,44 +104,92 @@ class DataService { String? searchQuery, String sortBy = 'id', String sortOrder = 'asc', - }) async { - try { - final params = { - 'limit': limit, - 'sortBy': sortBy, - 'sortOrder': sortOrder, - }; - - if (startAfter != null) params['startAfter'] = startAfter; - if (category != null) params['category'] = category; - if (status != null) params['status'] = status; - if (searchQuery != null && searchQuery.isNotEmpty) { - params['searchQuery'] = searchQuery; - } - - final result = - await (_apiService as FirebaseFunctionsApiService).callPaginated( - 'getEquipmentsPaginated', - params, + }) => + equipmentRepository.getEquipmentsPaginated( + limit: limit, + startAfter: startAfter, + category: category, + status: status, + searchQuery: searchQuery, + sortBy: sortBy, + sortOrder: sortOrder, ); - return { - 'equipments': (result['equipments'] as List?) - ?.map((e) => e as Map) - .toList() ?? - [], - 'hasMore': result['hasMore'] as bool? ?? false, - 'lastVisible': result['lastVisible'] as String?, - 'total': result['total'] as int? ?? 0, - }; - } catch (e) { - DebugLog.error('[DataService] Error in getEquipmentsPaginated', e); - throw Exception( - 'Erreur lors de la récupération paginée des équipements: $e'); - } - } + Future createEquipment(String equipmentId, Map data) => + equipmentRepository.createEquipment(equipmentId, data); + + Future updateEquipment(String equipmentId, Map data) => + equipmentRepository.updateEquipment(equipmentId, data); + + Future deleteEquipment(String equipmentId, {bool forceDelete = false}) => + equipmentRepository.deleteEquipment(equipmentId, forceDelete: forceDelete); + + Future updateEquipmentStatusOnly({required String equipmentId, String? status, int? availableQuantity}) => + equipmentRepository.updateEquipmentStatusOnly( + equipmentId: equipmentId, + status: status, + availableQuantity: availableQuantity, + ); + + Future>> searchEquipmentsForAssistant({required String query, int limit = 12}) => + equipmentRepository.searchEquipmentsForAssistant(query: query, limit: limit); + + Future> checkEquipmentAvailabilityForAssistant({ + required String equipmentId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) => + equipmentRepository.checkEquipmentAvailabilityForAssistant( + equipmentId: equipmentId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + Future> checkEquipmentAvailability({ + required String equipmentId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) => + equipmentRepository.checkEquipmentAvailability( + equipmentId: equipmentId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + + Future> getConflictingEquipmentIds({ + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + int installationTime = 0, + int disassemblyTime = 0, + }) => + equipmentRepository.getConflictingEquipmentIds( + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + installationTime: installationTime, + disassemblyTime: disassemblyTime, + ); + + Future>> getMaintenances({String? equipmentId}) => + equipmentRepository.getMaintenances(equipmentId: equipmentId); + + Future deleteMaintenance(String maintenanceId) => + equipmentRepository.deleteMaintenance(maintenanceId); + + // ============================================================================ + // CONTAINERS (delegated to ContainerRepository) + // ============================================================================ + Future>> getContainers() => + containerRepository.getContainers(); + + Future>> getContainersByIds(List containerIds) => + containerRepository.getContainersByIds(containerIds); - /// Récupère les containers avec pagination et filtrage Future> getContainersPaginated({ int limit = 20, String? startAfter, @@ -495,445 +199,71 @@ class DataService { String? category, String sortBy = 'id', String sortOrder = 'asc', - }) async { - try { - final params = { - 'limit': limit, - 'sortBy': sortBy, - 'sortOrder': sortOrder, - }; - - if (startAfter != null) params['startAfter'] = startAfter; - if (type != null) params['type'] = type; - if (status != null) params['status'] = status; - if (category != null) params['category'] = category; - if (searchQuery != null && searchQuery.isNotEmpty) { - params['searchQuery'] = searchQuery; - } - - final result = - await (_apiService as FirebaseFunctionsApiService).callPaginated( - 'getContainersPaginated', - params, - ); - - return { - 'containers': (result['containers'] as List?) - ?.map((e) => e as Map) - .toList() ?? - [], - 'hasMore': result['hasMore'] as bool? ?? false, - 'lastVisible': result['lastVisible'] as String?, - 'total': result['total'] as int? ?? 0, - }; - } catch (e) { - DebugLog.error('[DataService] Error in getContainersPaginated', e); - throw Exception( - 'Erreur lors de la récupération paginée des containers: $e'); - } - } - - /// Recherche rapide (autocomplétion) - Future>> quickSearch( - String query, { - int limit = 10, - bool includeEquipments = true, - bool includeContainers = true, - }) async { - try { - return await (_apiService as FirebaseFunctionsApiService).quickSearch( - query, + }) => + containerRepository.getContainersPaginated( limit: limit, - includeEquipments: includeEquipments, - includeContainers: includeContainers, - ); - } catch (e) { - DebugLog.error('[DataService] Error in quickSearch', e); - return []; - } - } - - /// Recherche des équipements pour l'assistant IA avec fallback paginé. - Future>> searchEquipmentsForAssistant({ - required String query, - int limit = 12, - }) async { - final normalizedQuery = query.trim(); - if (normalizedQuery.isEmpty) { - return []; - } - - try { - final quickResults = await quickSearch( - normalizedQuery, - limit: limit, - includeEquipments: true, - includeContainers: false, + startAfter: startAfter, + type: type, + status: status, + searchQuery: searchQuery, + category: category, + sortBy: sortBy, + sortOrder: sortOrder, ); - final equipmentResults = quickResults - .where((item) => - (item['type']?.toString().toLowerCase() ?? '') == 'equipment') - .map(_normalizeAssistantEquipment) - .toList(); + Future>> getContainersByEquipment(String equipmentId) => + containerRepository.getContainersByEquipment(equipmentId); - if (equipmentResults.isNotEmpty) { - return equipmentResults; - } - - final paginated = await getEquipmentsPaginated( - limit: limit, - searchQuery: normalizedQuery, - sortBy: 'id', - sortOrder: 'asc', - ); - - final equipments = - paginated['equipments'] as List>? ?? []; - return equipments.map(_normalizeAssistantEquipment).toList(); - } catch (e) { - DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e); - throw Exception('Erreur lors de la recherche de matériel: $e'); - } - } - - /// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA. - Future> checkEquipmentAvailabilityForAssistant({ - required String equipmentId, - required DateTime startDate, - required DateTime endDate, - String? excludeEventId, - }) async { - try { - final result = await checkEquipmentAvailability( - equipmentId: equipmentId, - startDate: startDate, - endDate: endDate, - excludeEventId: excludeEventId, - ); - - final available = result['available'] as bool? ?? true; - final conflicts = (result['conflicts'] as List? ?? const []) - .whereType>() - .map((conflict) { - final eventData = - conflict['eventData'] as Map? ?? const {}; - final eventName = - (eventData['Name'] ?? conflict['eventName'] ?? '').toString(); - return { - 'eventId': conflict['eventId']?.toString() ?? '', - 'eventName': eventName, - 'overlapDays': conflict['overlapDays'] as int? ?? 0, - }; - }).toList(); - - return { - 'equipmentId': equipmentId, - 'available': available, - 'conflictCount': conflicts.length, - 'conflicts': conflicts, - }; - } catch (e) { - DebugLog.error( - '[DataService] Error in checkEquipmentAvailabilityForAssistant', e); - throw Exception('Erreur lors de la vérification de disponibilité: $e'); - } - } - - /// Retourne des événements passés, idéalement filtrés par type d'événement. - Future>> getPastEventsForAssistant({ - String? eventTypeId, - int limit = 10, - }) async { - try { - final now = DateTime.now(); - final events = eventTypeId != null && eventTypeId.isNotEmpty - ? await getEventsByEventType(eventTypeId) - : (await getEvents())['events'] as List>? ?? []; - - final pastEvents = events.where((event) { - final endDate = _parseEventDate(event['EndDateTime']); - return endDate != null && endDate.isBefore(now); - }).toList(); - - pastEvents.sort((a, b) { - final aDate = _parseEventDate(a['StartDateTime']) ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = _parseEventDate(b['StartDateTime']) ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); - }); - - return pastEvents.take(limit).map((event) { - final assignedEquipment = - event['assignedEquipment'] as List? ?? const []; - return { - 'id': event['id']?.toString() ?? '', - 'name': (event['Name'] ?? '').toString(), - 'startDate': event['StartDateTime']?.toString() ?? '', - 'endDate': event['EndDateTime']?.toString() ?? '', - 'assignedEquipment': assignedEquipment, - 'assignedEquipmentCount': assignedEquipment.length, - }; - }).toList(); - } catch (e) { - DebugLog.error('[DataService] Error in getPastEventsForAssistant', e); - throw Exception( - 'Erreur lors de la récupération des événements passés: $e'); - } - } - - Map _normalizeAssistantEquipment(Map item) { - return { - 'id': (item['id'] ?? '').toString(), - 'name': (item['name'] ?? item['id'] ?? '').toString(), - 'category': (item['category'] ?? '').toString(), - 'status': (item['status'] ?? '').toString(), - 'brand': item['brand']?.toString(), - 'model': item['model']?.toString(), - 'availableQuantity': item['availableQuantity'], - 'totalQuantity': item['totalQuantity'], - }; - } - - DateTime? _parseEventDate(dynamic rawValue) { - if (rawValue is String) { - return DateTime.tryParse(rawValue); - } - return null; - } - - // ============================================================================ - // USER - Current User - // ============================================================================ - - /// Récupère l'utilisateur actuellement authentifié avec son rôle - Future> getCurrentUser() async { - try { - print('[DataService] Calling getCurrentUser API...'); - final result = await _apiService.call('getCurrentUser', {}); - print('[DataService] Current user loaded successfully'); - return result['user'] as Map; - } catch (e) { - print('[DataService] Error getting current user: $e'); - throw Exception( - 'Erreur lors de la récupération de l\'utilisateur actuel: $e'); - } - } - - // ============================================================================ - // ALERTS - // ============================================================================ - - /// Récupère toutes les alertes - Future>> getAlerts() async { - try { - final result = await _apiService.call('getAlerts', {}); - final alerts = result['alerts'] as List?; - if (alerts == null) return []; - return alerts.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des alertes: $e'); - } - } - - /// Marque une alerte comme lue - Future markAlertAsRead(String alertId) async { - try { - await _apiService.call('markAlertAsRead', {'alertId': alertId}); - } catch (e) { - throw Exception('Erreur lors du marquage de l\'alerte comme lue: $e'); - } - } - - /// Supprime une alerte - Future deleteAlert(String alertId) async { - try { - await _apiService.call('deleteAlert', {'alertId': alertId}); - } catch (e) { - throw Exception('Erreur lors de la suppression de l\'alerte: $e'); - } - } - - // ============================================================================ - // EQUIPMENT AVAILABILITY - // ============================================================================ - - /// Vérifie la disponibilité d'un équipement - Future> checkEquipmentAvailability({ - required String equipmentId, - required DateTime startDate, - required DateTime endDate, - String? excludeEventId, - }) async { - try { - final result = await _apiService.call('checkEquipmentAvailability', { - 'equipmentId': equipmentId, - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - if (excludeEventId != null) 'excludeEventId': excludeEventId, - }); - return result; - } catch (e) { - throw Exception('Erreur lors de la vérification de disponibilité: $e'); - } - } - - /// Vérifie la disponibilité d'un container Future> checkContainerAvailability({ required String containerId, required DateTime startDate, required DateTime endDate, String? excludeEventId, - }) async { - try { - final result = await _apiService.call('checkContainerAvailability', { - 'containerId': containerId, - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - if (excludeEventId != null) 'excludeEventId': excludeEventId, - }); - return result; - } catch (e) { - throw Exception('Erreur lors de la vérification de disponibilité du container: $e'); - } - } - - /// Récupère tous les IDs d'équipements et conteneurs en conflit pour une période - /// Optimisé : une seule requête au lieu d'une par équipement - Future> getConflictingEquipmentIds({ - required DateTime startDate, - required DateTime endDate, - String? excludeEventId, - int installationTime = 0, - int disassemblyTime = 0, - }) async { - try { - final result = await _apiService.call('getConflictingEquipmentIds', { - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - if (excludeEventId != null) 'excludeEventId': excludeEventId, - 'installationTime': installationTime, - 'disassemblyTime': disassemblyTime, - }); - return result; - } catch (e) { - throw Exception( - 'Erreur lors de la récupération des équipements en conflit: $e'); - } - } + }) => + containerRepository.checkContainerAvailability( + containerId: containerId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); // ============================================================================ - // MAINTENANCES + // USERS (delegated to UserRepository) // ============================================================================ + Future> getCurrentUser() => userRepository.getCurrentUser(); - /// Récupère toutes les maintenances - Future>> getMaintenances( - {String? equipmentId}) async { - try { - final data = {}; - if (equipmentId != null) data['equipmentId'] = equipmentId; + Future>> getUsers() => userRepository.getUsers(); - final result = await _apiService.call('getMaintenances', data); - final maintenances = result['maintenances'] as List?; - if (maintenances == null) return []; - return maintenances.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des maintenances: $e'); - } - } + Future> getUser(String userId) => userRepository.getUser(userId); - /// Supprime une maintenance - Future deleteMaintenance(String maintenanceId) async { - try { - await _apiService - .call('deleteMaintenance', {'maintenanceId': maintenanceId}); - } catch (e) { - throw Exception('Erreur lors de la suppression de la maintenance: $e'); - } - } + Future deleteUser(String userId) => userRepository.deleteUser(userId); - /// Récupère les containers contenant un équipement - Future>> getContainersByEquipment( - String equipmentId) async { - try { - final result = await _apiService.call('getContainersByEquipment', { - 'equipmentId': equipmentId, - }); - final containers = result['containers'] as List?; - if (containers == null) return []; - return containers.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des containers: $e'); - } - } + Future updateUser(String userId, Map data) => + userRepository.updateUser(userId, data); - // ============================================================================ - // USERS - // ============================================================================ - - /// Récupère tous les utilisateurs (selon permissions) - Future>> getUsers() async { - try { - final result = await _apiService.call('getUsers', {}); - final users = result['users'] as List?; - if (users == null) return []; - return users.map((e) => e as Map).toList(); - } catch (e) { - throw Exception('Erreur lors de la récupération des utilisateurs: $e'); - } - } - - /// Récupère un utilisateur spécifique - Future> getUser(String userId) async { - try { - final result = await _apiService.call('getUser', {'userId': userId}); - return result['user'] as Map; - } catch (e) { - throw Exception('Erreur lors de la récupération de l\'utilisateur: $e'); - } - } - - /// Supprime un utilisateur (Auth + Firestore) - Future deleteUser(String userId) async { - try { - await _apiService.call('deleteUser', {'userId': userId}); - } catch (e) { - throw Exception('Erreur lors de la suppression de l\'utilisateur: $e'); - } - } - - /// Met à jour un utilisateur - Future updateUser(String userId, Map data) async { - try { - await _apiService.call('updateUser', { - 'userId': userId, - 'data': data, - }); - } catch (e) { - throw Exception('Erreur lors de la mise à jour de l\'utilisateur: $e'); - } - } - - /// Crée un utilisateur avec invitation par email Future> createUserWithInvite({ required String email, required String firstName, required String lastName, String? phoneNumber, required String roleId, - }) async { - try { - final result = await _apiService.call('createUserWithInvite', { - 'email': email, - 'firstName': firstName, - 'lastName': lastName, - 'phoneNumber': phoneNumber ?? '', - 'roleId': roleId, - }); - return result; - } catch (e) { - throw Exception('Erreur lors de la création de l\'utilisateur: $e'); - } - } + }) => + userRepository.createUserWithInvite( + email: email, + firstName: firstName, + lastName: lastName, + phoneNumber: phoneNumber, + roleId: roleId, + ); + + Future>> getRoles() => userRepository.getRoles(); + + // ============================================================================ + // ALERTS (delegated to AlertRepository) + // ============================================================================ + Future>> getAlerts() => alertRepository.getAlerts(); + + Future markAlertAsRead(String alertId) => alertRepository.markAlertAsRead(alertId); + + Future deleteAlert(String alertId) => alertRepository.deleteAlert(alertId); } diff --git a/em2rp/lib/services/event_availability_service.dart b/em2rp/lib/services/event_availability_service.dart index 7d149a0..0cb78c5 100644 --- a/em2rp/lib/services/event_availability_service.dart +++ b/em2rp/lib/services/event_availability_service.dart @@ -171,20 +171,37 @@ class EventAvailabilityService { for (var conflictData in containerConflicts) { final conflict = conflictData as Map; final eventId = conflict['eventId'] as String; - final eventDoc = await _dataService.getEvents(); - final eventData = (eventDoc['events'] as List).firstWhere((e) => e['id'] == eventId, orElse: () => null); - if (eventData != null) { - final event = EventModel.fromMap(eventData as Map, eventId); - conflicts.add(AvailabilityConflict( - equipmentId: container.id, - equipmentName: container.name, - conflictingEvent: event, - overlapDays: conflict['overlapDays'] as int? ?? 0, - type: ConflictType.containerFullyUsed, - containerId: container.id, - containerName: container.name, - )); - } + final eventName = conflict['eventName'] as String? ?? ''; + final startDateStr = conflict['startDate'] as String?; + final endDateStr = conflict['endDate'] as String?; + + final event = EventModel( + id: eventId, + name: eventName, + description: '', + startDateTime: startDateStr != null ? DateTime.tryParse(startDateStr) ?? DateTime.now() : DateTime.now(), + endDateTime: endDateStr != null ? DateTime.tryParse(endDateStr) ?? DateTime.now() : DateTime.now(), + basePrice: 0.0, + installationTime: 0, + disassemblyTime: 0, + eventTypeId: '', + customerId: '', + address: '', + latitude: 0.0, + longitude: 0.0, + workforce: const [], + documents: const [], + ); + + conflicts.add(AvailabilityConflict( + equipmentId: container.id, + equipmentName: container.name, + conflictingEvent: event, + overlapDays: conflict['overlapDays'] as int? ?? 0, + type: ConflictType.containerFullyUsed, + containerId: container.id, + containerName: container.name, + )); } } } diff --git a/em2rp/web/version.json b/em2rp/web/version.json index ae1bbac..b81bffc 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.2.1", + "version": "1.2.3", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, - "releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.", - "timestamp": "2026-05-25T21:50:50.578Z" + "releaseNotes": "Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.", + "timestamp": "2026-05-26T13:34:16.390Z" } \ No newline at end of file