Files
EM2_ERP/em2rp/functions/index.js
ElPoyo 8cd4854924 refactor: Amélioration des performances et migration des Cloud Functions
Cette mise à jour majeure vise à améliorer significativement les performances de l'application, en particulier au démarrage, et à standardiser l'infrastructure backend. Les principaux changements incluent la migration de toutes les Cloud Functions vers une région européenne (`europe-west9`), l'optimisation du chargement des données, et l'introduction d'un moniteur de performance pour le débogage.

**Changements Backend (Cloud Functions) :**

-   **Migration de la Région :**
    -   Toutes les Cloud Functions ont été déplacées de `us-central1` à `europe-west9` (Paris) pour réduire la latence pour les utilisateurs européens. Cela concerne les appels depuis le frontend (ex: `api_config.dart`, `email_service.dart`) et les définitions des fonctions elles-mêmes (`index.js`, etc.).
-   **Standardisation des Fonctions :**
    -   La plupart des fonctions `onCall` (v1) ont été migrées vers le format `onRequest` (v2) avec une gestion d'authentification et de CORS unifiée, améliorant la robustesse et la cohérence.
    -   Les triggers Firestore (`onDocumentCreated`, `onDocumentUpdated`) et les tâches planifiées (`onSchedule`) ont été mis à jour pour spécifier explicitement la région `europe-west9`.
-   **Mise à jour des Index Firestore :**
    -   Les index `firestore.indexes.json` ont été mis à jour pour supporter les nouvelles requêtes de l'application et optimiser les performances de filtrage.

**Améliorations des Performances Frontend :**

-   **Chargement Asynchrone et Mis en Cache :**
    -   Le chargement des données utilisateur (`LocalUserProvider`) et des événements (`EventProvider`) a été optimisé pour utiliser un cache local à court terme (5 minutes pour l'utilisateur, 30 secondes pour les événements).
    -   Les données ne sont rechargées que si le cache a expiré ou si un rechargement est forcé, évitant des appels réseau redondants et accélérant la navigation.
-   **Démarrage de l'Application Optimisé :**
    -   Le processus de connexion automatique (`main.dart`) a été revu. L'application navigue désormais immédiatement vers la page demandée sans attendre la fin du chargement des données utilisateur, qui s'effectue en arrière-plan.
    -   Un écran de chargement plus esthétique avec le logo de l'entreprise a été ajouté, remplaçant l'indicateur de chargement simple.
-   **Chargement de la Page Calendrier :**
    -   Le chargement et la sélection de l'événement par défaut sur la page `CalendarPage` sont maintenant entièrement asynchrones, rendant l'affichage de la page quasi instantané.

**Nouveaux Outils et Améliorations UX :**

-   **Moniteur de Performance :**
    -   Ajout d'un nouvel outil `PerformanceMonitor` (`lib/utils/performance_monitor.dart`) pour mesurer précisément le temps d'exécution des opérations critiques (appels API, parsing, etc.) en mode débogage. Il aide à identifier les goulots d'étranglement.
-   **Amélioration du Formulaire de Connexion :**
    -   Les champs "Email" et "Mot de passe" sur la page de connexion (`LoginPage`) supportent désormais l'autocomplétion du navigateur (`AutofillGroup`).
    -   Appuyer sur "Entrée" dans l'un des champs déclenche désormais la connexion, améliorant l'ergonomie.

**Mise à jour de la version :**

-   La version de l'application a été incrémentée à `1.0.9`.
2026-02-09 10:14:52 +01:00

4059 lines
137 KiB
JavaScript

/**
* EM2RP Cloud Functions
* Architecture backend sécurisée avec authentification et permissions
*/
// Charger les variables d'environnement depuis .env
require('dotenv').config();
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');
// Utilitaires
const auth = require('./utils/auth');
const helpers = require('./utils/helpers');
// Initialisation sécurisée
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
};
// ============================================================================
// 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');
}
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('');
return;
}
// Exécuter le handler
try {
await handler(req, res);
} catch (error) {
logger.error("Unhandled error:", error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
};
};
// ============================================================================
// STORAGE - Move Event File
// ============================================================================
exports.moveEventFileV2 = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { sourcePath, destinationPath } = req.body.data || {};
if (!sourcePath || !destinationPath) {
res.status(400).json({ error: 'Source and destination paths are required.' });
return;
}
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
await bucket.file(sourcePath).delete();
const [url] = await bucket.file(destinationPath).getSignedUrl({
action: 'read',
expires: '03-01-2500',
});
res.status(200).json({ url });
} catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENT - CRUD
// ============================================================================
// Créer un équipement (admin ou manage_equipment)
exports.createEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const equipmentData = req.body.data;
const equipmentId = equipmentData.id;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier unicité de l'ID
const existingDoc = await db.collection('equipments').doc(equipmentId).get();
if (existingDoc.exists) {
res.status(409).json({ error: 'Equipment ID already exists' });
return;
}
// Convertir les timestamps
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
'createdAt', 'updatedAt', 'purchaseDate', 'lastMaintenanceDate', 'nextMaintenanceDate'
]);
await db.collection('equipments').doc(equipmentId).set(dataToSave);
res.status(201).json({ id: equipmentId, message: 'Equipment created successfully' });
} catch (error) {
logger.error("Error creating equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un équipement
exports.updateEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { equipmentId, data } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
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 = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { equipmentId } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier si l'équipement est utilisé dans des événements actifs
const eventsSnapshot = await db.collection('events')
.where('status', '!=', 'CANCELLED')
.get();
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
res.status(409).json({
error: 'Cannot delete equipment: it is assigned to active events',
eventId: eventDoc.id
});
return;
}
}
await db.collection('equipments').doc(equipmentId).delete();
res.status(200).json({ message: 'Equipment deleted successfully' });
} catch (error) {
logger.error("Error deleting equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer un équipement par ID
exports.getEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { equipmentId } = req.body.data || req.query;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
const doc = await db.collection('equipments').doc(equipmentId).get();
if (!doc.exists) {
res.status(404).json({ error: 'Equipment not found' });
return;
}
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
res.status(200).json({ equipment: data });
} catch (error) {
logger.error("Error getting equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { equipmentIds } = req.body.data || {};
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
res.status(400).json({ error: 'equipmentIds array is required and must not be empty' });
return;
}
// Limiter à 100 équipements max par requête
if (equipmentIds.length > 100) {
res.status(400).json({ error: 'Maximum 100 equipment IDs per request' });
return;
}
// Récupérer tous les documents en parallèle
const promises = equipmentIds.map(id => db.collection('equipments').doc(id).get());
const docs = await Promise.all(promises);
const equipments = [];
for (const doc of docs) {
if (doc.exists) {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
equipments.push(data);
}
}
res.status(200).json({ equipments });
} catch (error) {
logger.error("Error getting equipments by IDs:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - CRUD
// ============================================================================
// Créer un container
exports.createContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const containerData = req.body.data;
const containerId = containerData.id;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
const existingDoc = await db.collection('containers').doc(containerId).get();
if (existingDoc.exists) {
res.status(409).json({ error: 'Container ID already exists' });
return;
}
const dataToSave = helpers.deserializeTimestamps(containerData, ['createdAt', 'updatedAt']);
await db.collection('containers').doc(containerId).set(dataToSave);
res.status(201).json({ id: containerId, message: 'Container created successfully' });
} catch (error) {
logger.error("Error creating container:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un container
exports.updateContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId, data } = req.body.data;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
await db.collection('containers').doc(containerId).update(data);
res.status(200).json({ message: 'Container updated successfully' });
} catch (error) {
logger.error("Error updating container:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un container
exports.deleteContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId } = req.body.data;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
// Récupérer le container pour obtenir les équipements
const containerDoc = await db.collection('containers').doc(containerId).get();
if (containerDoc.exists) {
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Retirer le container des parentBoxIds de chaque équipement
for (const equipmentId of equipmentIds) {
try {
const equipmentDoc = await db.collection('equipments').doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = (equipmentData.parentBoxIds || []).filter(boxId => boxId !== containerId);
await db.collection('equipments').doc(equipmentId).update({
parentBoxIds: parentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} when deleting container:`, err);
}
}
}
await db.collection('containers').doc(containerId).delete();
res.status(200).json({ message: 'Container deleted successfully' });
} catch (error) {
logger.error("Error deleting container:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer les containers contenant un équipement
exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { equipmentId } = req.body.data || {};
if (!equipmentId) {
res.status(400).json({ error: 'equipmentId is required' });
return;
}
const snapshot = await db.collection('containers')
.where('equipmentIds', 'array-contains', equipmentId)
.get();
const containers = [];
snapshot.forEach(doc => {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
});
res.status(200).json({ containers });
} catch (error) {
logger.error("Error getting containers by equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer plusieurs containers par leurs IDs
exports.getContainersByIds = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { containerIds } = req.body.data || {};
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
res.status(400).json({ error: 'containerIds array is required and must not be empty' });
return;
}
// Limiter à 100 conteneurs max par requête
if (containerIds.length > 100) {
res.status(400).json({ error: 'Maximum 100 container IDs per request' });
return;
}
// Récupérer tous les documents en parallèle
const promises = containerIds.map(id => db.collection('containers').doc(id).get());
const docs = await Promise.all(promises);
const containers = [];
for (const doc of docs) {
if (doc.exists) {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
containers.push(data);
}
}
res.status(200).json({ containers });
} catch (error) {
logger.error("Error getting containers by IDs:", error);
res.status(500).json({ error: error.message });
}
}));
/**
* Ajouter un équipement à un container
*/
exports.addEquipmentToContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId, equipmentId, userId } = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({ error: 'containerId and equipmentId are required' });
return;
}
// Récupérer le container
const containerDoc = await db.collection('containers').doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({ success: false, message: 'Container non trouvé' });
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Vérifier si l'équipement n'est pas déjà dans ce container
if (equipmentIds.includes(equipmentId)) {
res.status(400).json({ success: false, message: 'Cet équipement est déjà dans ce container' });
return;
}
// Récupérer l'équipement
const equipmentDoc = await db.collection('equipments').doc(equipmentId).get();
if (!equipmentDoc.exists) {
res.status(404).json({ success: false, message: 'Équipement non trouvé' });
return;
}
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
// Vérifier les autres containers
const warnings = [];
if (parentBoxIds.length > 0) {
const otherContainersPromises = parentBoxIds.map(boxId =>
db.collection('containers').doc(boxId).get()
);
const otherContainersDocs = await Promise.all(otherContainersPromises);
const otherNames = otherContainersDocs
.filter(doc => doc.exists)
.map(doc => doc.data().name);
if (otherNames.length > 0) {
warnings.push(`Attention : cet équipement est également dans les boites suivants : ${otherNames.join(", ")}`);
}
}
// Mettre à jour le container
await db.collection('containers').doc(containerId).update({
equipmentIds: [...equipmentIds, equipmentId],
updatedAt: admin.firestore.Timestamp.now(),
});
// Mettre à jour l'équipement
await db.collection('equipments').doc(equipmentId).update({
parentBoxIds: [...parentBoxIds, containerId],
updatedAt: admin.firestore.Timestamp.now(),
});
// Ajouter une entrée dans l'historique
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: 'equipment_added',
equipmentId: equipmentId,
newValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100); // Garder les 100 dernières entrées
await db.collection('containers').doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({
success: true,
message: 'Équipement ajouté avec succès',
warnings: warnings.length > 0 ? warnings[0] : null,
});
} catch (error) {
logger.error("Error adding equipment to container:", error);
res.status(500).json({ success: false, message: error.message });
}
}));
/**
* Retirer un équipement d'un container
*/
exports.removeEquipmentFromContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId, equipmentId, userId } = req.body.data;
if (!containerId || !equipmentId) {
res.status(400).json({ error: 'containerId and equipmentId are required' });
return;
}
// Récupérer le container
const containerDoc = await db.collection('containers').doc(containerId).get();
if (!containerDoc.exists) {
res.status(404).json({ error: 'Container non trouvé' });
return;
}
const containerData = containerDoc.data();
const equipmentIds = containerData.equipmentIds || [];
// Retirer l'équipement du container
const updatedEquipmentIds = equipmentIds.filter(id => id !== equipmentId);
await db.collection('containers').doc(containerId).update({
equipmentIds: updatedEquipmentIds,
updatedAt: admin.firestore.Timestamp.now(),
});
// Mettre à jour l'équipement
const equipmentDoc = await db.collection('equipments').doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const parentBoxIds = equipmentData.parentBoxIds || [];
const updatedParentBoxIds = parentBoxIds.filter(id => id !== containerId);
await db.collection('equipments').doc(equipmentId).update({
parentBoxIds: updatedParentBoxIds,
updatedAt: admin.firestore.Timestamp.now(),
});
}
// Ajouter une entrée dans l'historique
const history = containerData.history || [];
const historyEntry = {
timestamp: admin.firestore.Timestamp.now(),
action: 'equipment_removed',
equipmentId: equipmentId,
previousValue: equipmentId,
userId: userId || decodedToken.uid,
};
const updatedHistory = [...history, historyEntry].slice(-100);
await db.collection('containers').doc(containerId).update({
history: updatedHistory,
});
res.status(200).json({ success: true });
} catch (error) {
logger.error("Error removing equipment from container:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENTS - CRUD
// ============================================================================
// Créer un événement
exports.createEvent = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'edit_event');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires edit_event permission' });
return;
}
const eventData = req.body.data;
// 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 });
}
}));
// ============================================================================
// MAINTENANCES - CRUD
// ============================================================================
// Créer une maintenance
exports.createMaintenance = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_maintenances');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_maintenances permission' });
return;
}
const maintenanceData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
'scheduledDate', 'completedDate', 'createdAt', 'updatedAt'
]);
const docRef = await db.collection('maintenances').add(dataToSave);
const maintenanceId = docRef.id;
// Mettre à jour les équipements concernés
if (maintenanceData.equipmentIds && Array.isArray(maintenanceData.equipmentIds)) {
for (const equipmentId of maintenanceData.equipmentIds) {
try {
const equipmentDoc = await db.collection('equipments').doc(equipmentId).get();
if (equipmentDoc.exists) {
const equipmentData = equipmentDoc.data();
const maintenanceIds = equipmentData.maintenanceIds || [];
if (!maintenanceIds.includes(maintenanceId)) {
maintenanceIds.push(maintenanceId);
await db.collection('equipments').doc(equipmentId).update({
maintenanceIds: maintenanceIds,
});
}
}
// Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte
if (maintenanceData.scheduledDate) {
const scheduledDate = maintenanceData.scheduledDate.toDate ?
maintenanceData.scheduledDate.toDate() :
new Date(maintenanceData.scheduledDate);
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
if (scheduledDate <= sevenDaysFromNow) {
// Vérifier si une alerte existe déjà
const existingAlerts = await db.collection('alerts')
.where('equipmentId', '==', equipmentId)
.where('type', '==', 'maintenanceDue')
.where('isRead', '==', false)
.get();
let alertExists = false;
for (const alertDoc of existingAlerts.docs) {
const alertData = alertDoc.data();
if (alertData.message && alertData.message.includes(maintenanceData.name || '')) {
alertExists = true;
break;
}
}
if (!alertExists) {
const equipmentName = equipmentDoc.exists ?
(equipmentDoc.data().name || equipmentId) :
equipmentId;
const daysUntil = Math.ceil((scheduledDate - new Date()) / (1000 * 60 * 60 * 24));
await db.collection('alerts').add({
type: 'maintenanceDue',
message: `Maintenance "${maintenanceData.name || 'Sans nom'}" prévue dans ${daysUntil} jour(s) pour ${equipmentName}`,
equipmentId: equipmentId,
createdAt: admin.firestore.Timestamp.now(),
isRead: false,
});
}
}
}
} catch (err) {
logger.error(`Error updating equipment ${equipmentId} for maintenance:`, err);
}
}
}
res.status(201).json({ id: maintenanceId, message: 'Maintenance created successfully' });
} catch (error) {
logger.error("Error creating maintenance:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour une maintenance
exports.updateMaintenance = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_maintenances');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_maintenances permission' });
return;
}
const { maintenanceId, data } = req.body.data;
if (!maintenanceId) {
res.status(400).json({ error: 'Maintenance ID is required' });
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
'scheduledDate', 'completedDate'
]);
await db.collection('maintenances').doc(maintenanceId).update(dataToSave);
res.status(200).json({ message: 'Maintenance updated successfully' });
} catch (error) {
logger.error("Error updating maintenance:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// OPTIONS - CRUD
// ============================================================================
// Créer une option
exports.createOption = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const optionData = req.body.data;
const optionId = optionData.id;
if (!optionId) {
res.status(400).json({ error: 'Option ID is required' });
return;
}
await db.collection('options').doc(optionId).set(optionData);
res.status(201).json({ id: optionId, message: 'Option created successfully' });
} catch (error) {
logger.error("Error creating option:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour une option
exports.updateOption = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const { optionId, data } = req.body.data;
if (!optionId) {
res.status(400).json({ error: 'Option ID is required' });
return;
}
delete data.id;
await db.collection('options').doc(optionId).update(data);
res.status(200).json({ message: 'Option updated successfully' });
} catch (error) {
logger.error("Error updating option:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer une option
exports.deleteOption = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const { optionId } = req.body.data;
if (!optionId) {
res.status(400).json({ error: 'Option ID is required' });
return;
}
await db.collection('options').doc(optionId).delete();
res.status(200).json({ message: 'Option deleted successfully' });
} catch (error) {
logger.error("Error deleting option:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// USERS - CRUD
// ============================================================================
// Créer un utilisateur
exports.createUser = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const userData = req.body.data;
const userId = userData.uid;
if (!userId) {
res.status(400).json({ error: 'User ID is required' });
return;
}
await db.collection('users').doc(userId).set(userData);
res.status(201).json({ id: userId, message: 'User created successfully' });
} catch (error) {
logger.error("Error creating user:", error);
res.status(500).json({ error: error.message });
}
}));
// Créer un utilisateur avec invitation par email
exports.createUserWithInvite = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const { email, firstName, lastName, phoneNumber, roleId } = req.body.data;
if (!email || !firstName || !lastName || !roleId) {
res.status(400).json({ error: 'email, firstName, lastName, and roleId are required' });
return;
}
// Générer un mot de passe temporaire aléatoire
const tempPassword = Math.random().toString(36).slice(-12) + 'Aa1!';
// Créer l'utilisateur dans Firebase Auth
let userRecord;
try {
userRecord = await admin.auth().createUser({
email: email,
password: tempPassword,
emailVerified: false,
displayName: `${firstName} ${lastName}`,
});
} catch (authError) {
logger.error("Error creating user in Auth:", authError);
res.status(500).json({ error: `Failed to create user in Auth: ${authError.message}` });
return;
}
// Créer le document utilisateur dans Firestore
try {
await db.collection('users').doc(userRecord.uid).set({
firstName: firstName,
lastName: lastName,
email: email,
phoneNumber: phoneNumber || '',
profilePhotoUrl: '',
role: db.collection('roles').doc(roleId),
createdAt: admin.firestore.FieldValue.serverTimestamp(),
createdBy: decodedToken.uid,
});
} catch (firestoreError) {
// Si la création Firestore échoue, supprimer l'utilisateur Auth
logger.error("Error creating user in Firestore:", firestoreError);
try {
await admin.auth().deleteUser(userRecord.uid);
} catch (cleanupError) {
logger.error("Error cleaning up Auth user:", cleanupError);
}
res.status(500).json({ error: `Failed to create user in Firestore: ${firestoreError.message}` });
return;
}
// Envoyer l'email de réinitialisation du mot de passe
// Utilisation de l'API REST de Firebase Auth pour déclencher l'envoi automatique
try {
const axios = require('axios');
const firebaseApiKey = 'AIzaSyARQL4P-t5l-cNjQNP9cMokQrLJ8BorF0U'; // Web API Key
await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=${firebaseApiKey}`,
{
requestType: 'PASSWORD_RESET',
email: email,
}
);
logger.info(`Password reset email sent to ${email}`);
} catch (emailError) {
logger.warn(`Could not send password reset email to ${email}: ${emailError.message}`);
// Ne pas faire échouer la requête si l'email ne peut pas être envoyé
}
logger.info(`User ${userRecord.uid} created by ${decodedToken.uid}`);
res.status(201).json({
id: userRecord.uid,
message: 'User created successfully. Password reset email sent.',
});
} catch (error) {
logger.error("Error in createUserWithInvite:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un utilisateur
exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId, data } = req.body.data;
if (!userId) {
res.status(400).json({ error: 'User ID is required' });
return;
}
// Vérifier si l'utilisateur met à jour son propre profil ou est admin
const isOwnProfile = decodedToken.uid === userId;
const isAdminUser = await auth.isAdmin(decodedToken.uid);
const hasEditPermission = await auth.hasPermission(decodedToken.uid, 'edit_user');
if (!isOwnProfile && !isAdminUser && !hasEditPermission) {
res.status(403).json({ error: 'Forbidden: Cannot edit other users' });
return;
}
// Si mise à jour propre profil, limiter les champs modifiables
if (isOwnProfile && !isAdminUser) {
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl', '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 });
}
}));
// Supprimer un utilisateur
exports.deleteUser = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAdminUser) {
res.status(403).json({ error: 'Forbidden: Admin access required' });
return;
}
const { userId } = req.body.data;
if (!userId) {
res.status(400).json({ error: 'User ID is required' });
return;
}
// Empêcher un admin de se supprimer lui-même
if (decodedToken.uid === userId) {
res.status(400).json({ error: 'Cannot delete your own account' });
return;
}
// Supprimer le document utilisateur dans Firestore
await db.collection('users').doc(userId).delete();
// Optionnel: Supprimer l'utilisateur de Firebase Auth
// Note: Cela nécessite le SDK Admin et des privilèges élevés
try {
await admin.auth().deleteUser(userId);
} catch (authError) {
logger.warn(`Could not delete user from Auth: ${authError.message}`);
// On continue même si la suppression Auth échoue
}
res.status(200).json({ message: 'User deleted successfully' });
} catch (error) {
logger.error("Error deleting user:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENT STATUS - Batch Update
// ============================================================================
// Mettre à jour le statut de plusieurs équipements (pour préparation/retour)
exports.updateEquipmentStatus = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { eventId, updates } = req.body.data;
if (!eventId || !updates || !Array.isArray(updates)) {
res.status(400).json({ error: 'Event ID and updates array are required' });
return;
}
// Vérifier que l'utilisateur est assigné à l'événement ou est admin
const isAssigned = await auth.isAssignedToEvent(decodedToken.uid, eventId);
const isAdminUser = await auth.isAdmin(decodedToken.uid);
if (!isAssigned && !isAdminUser) {
res.status(403).json({ error: 'Forbidden: Not assigned to this event' });
return;
}
// Batch update
const batch = db.batch();
for (const update of updates) {
const { equipmentId, status } = update;
if (equipmentId && status) {
const equipmentRef = db.collection('equipments').doc(equipmentId);
batch.update(equipmentRef, { status });
}
}
await batch.commit();
res.status(200).json({ message: 'Equipment statuses updated successfully' });
} catch (error) {
logger.error("Error updating equipment statuses:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// OPTIONS - Read (public pour utilisateurs authentifiés)
// ============================================================================
exports.getOptions = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req); // Juste vérifier l'auth
const snapshot = await db.collection('options').get();
const options = snapshot.docs.map(doc => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data())
}));
res.status(200).json({ options });
} catch (error) {
logger.error("Error fetching options:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENT TYPES - Read (public pour utilisateurs authentifiés)
// ============================================================================
exports.getEventTypes = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req); // Juste vérifier l'auth
const snapshot = await db.collection('eventTypes').get();
const eventTypes = snapshot.docs.map(doc => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data())
}));
res.status(200).json({ eventTypes });
} catch (error) {
logger.error("Error fetching event types:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// ROLES - Read (public pour utilisateurs authentifiés)
// ============================================================================
exports.getRoles = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req); // Juste vérifier l'auth
const snapshot = await db.collection('roles').get();
const roles = snapshot.docs.map(doc => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data())
}));
res.status(200).json({ roles });
} catch (error) {
logger.error("Error fetching roles:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENT EQUIPMENT - Update equipment status and quantities
// ============================================================================
exports.updateEventEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { eventId, assignedEquipment, preparationStatus, loadingStatus, unloadingStatus, returnStatus } = req.body.data;
if (!eventId) {
res.status(400).json({ error: 'Event ID is required' });
return;
}
// Vérifier les permissions
const eventDoc = await db.collection('events').doc(eventId).get();
if (!eventDoc.exists) {
res.status(404).json({ error: 'Event not found' });
return;
}
const eventData = eventDoc.data();
const isAdminUser = await auth.hasPermission(decodedToken.uid, 'edit_event');
// Vérifier si l'utilisateur est assigné en vérifiant workforce de manière sécurisée
let isAssigned = false;
if (eventData.workforce && Array.isArray(eventData.workforce)) {
isAssigned = eventData.workforce.some(ref => {
if (!ref || !ref.path) return false;
return ref.path.endsWith(decodedToken.uid) || ref.path === `/users/${decodedToken.uid}`;
});
}
if (!isAssigned && !isAdminUser) {
res.status(403).json({ error: 'Forbidden: Not assigned to this event' });
return;
}
// Préparer les données à mettre à jour
const updateData = {};
if (assignedEquipment) {
// Convertir les timestamps dans assignedEquipment
updateData.assignedEquipment = assignedEquipment.map(eq =>
helpers.deserializeTimestamps(eq, [])
);
}
if (preparationStatus) updateData.preparationStatus = preparationStatus;
if (loadingStatus) updateData.loadingStatus = loadingStatus;
if (unloadingStatus) updateData.unloadingStatus = unloadingStatus;
if (returnStatus) updateData.returnStatus = returnStatus;
// Mettre à jour l'événement
await db.collection('events').doc(eventId).update(updateData);
res.status(200).json({ message: 'Event equipment updated successfully' });
} catch (error) {
logger.error("Error updating event equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENT STATUS - Update individual equipment status
// ============================================================================
exports.updateEquipmentStatusOnly = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { equipmentId, status, availableQuantity } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier les permissions
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const updateData = { updatedAt: admin.firestore.Timestamp.now() };
if (status) updateData.status = status;
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
await db.collection('equipments').doc(equipmentId).update(updateData);
res.status(200).json({ message: 'Equipment status updated successfully' });
} catch (error) {
logger.error("Error updating equipment status:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENT TYPES - CRUD Operations
// ============================================================================
// Récupérer les événements utilisant un type d'événement
exports.getEventsByEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req);
const { eventTypeId } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
const eventsSnapshot = await db.collection('events')
.where('eventTypeId', '==', eventTypeId)
.get();
const events = eventsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().name,
startDateTime: doc.data().StartDateTime,
}));
res.status(200).json({ events });
} catch (error) {
logger.error("Error fetching events by type:", error);
res.status(500).json({ error: error.message });
}
}));
// Créer un type d'événement (admin uniquement)
exports.createEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { name, defaultPrice } = req.body.data;
if (!name || defaultPrice === undefined) {
res.status(400).json({ error: 'Name and defaultPrice are required' });
return;
}
// Vérifier l'unicité du nom
const existingSnapshot = await db.collection('eventTypes')
.where('name', '==', name)
.get();
if (!existingSnapshot.empty) {
res.status(409).json({ error: 'Event type name already exists' });
return;
}
const eventTypeData = {
name,
defaultPrice,
createdAt: admin.firestore.Timestamp.now(),
};
const docRef = await db.collection('eventTypes').add(eventTypeData);
res.status(201).json({ id: docRef.id, message: 'Event type created successfully' });
} catch (error) {
logger.error("Error creating event type:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un type d'événement (admin uniquement)
exports.updateEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { eventTypeId, name, defaultPrice } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
// Vérifier que le document existe
const docRef = db.collection('eventTypes').doc(eventTypeId);
const doc = await docRef.get();
if (!doc.exists) {
res.status(404).json({ error: 'Event type not found' });
return;
}
// Vérifier l'unicité du nom (sauf pour le document actuel)
if (name) {
const existingSnapshot = await db.collection('eventTypes')
.where('name', '==', name)
.get();
const hasDuplicate = existingSnapshot.docs.some(d => d.id !== eventTypeId);
if (hasDuplicate) {
res.status(409).json({ error: 'Event type name already exists' });
return;
}
}
const updateData = {};
if (name) updateData.name = name;
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
await docRef.update(updateData);
res.status(200).json({ message: 'Event type updated successfully' });
} catch (error) {
logger.error("Error updating event type:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un type d'événement (admin uniquement)
exports.deleteEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { eventTypeId } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
// Vérifier qu'aucun événement futur n'utilise ce type
const eventsSnapshot = await db.collection('events')
.where('eventTypeId', '==', eventTypeId)
.get();
const now = admin.firestore.Timestamp.now();
const futureEvents = eventsSnapshot.docs.filter(doc => {
const startDate = doc.data().StartDateTime;
return startDate && startDate > now;
});
if (futureEvents.length > 0) {
res.status(409).json({
error: 'Cannot delete event type with future events',
futureEventsCount: futureEvents.length
});
return;
}
await db.collection('eventTypes').doc(eventTypeId).delete();
res.status(200).json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error("Error deleting event type:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENTS - Read with permissions
// ============================================================================
exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId } = req.body.data || {};
// Vérifier si l'utilisateur peut voir tous les événements
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
let eventsSnapshot;
if (canViewAll) {
// Admin : tous les événements
eventsSnapshot = await db.collection('events').get();
} else {
// Utilisateur normal : seulement ses événements assignés
const userRef = db.collection('users').doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection('events')
.where('workforce', 'array-contains', userRef)
.get();
}
// Collecter tous les UIDs utilisateurs uniques
const userIdsSet = new Set();
eventsSnapshot.docs.forEach(doc => {
const data = doc.data();
if (data.workforce && Array.isArray(data.workforce)) {
data.workforce.forEach(userRef => {
if (userRef && userRef.id) {
userIdsSet.add(userRef.id);
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
userIdsSet.add(userRef.split('/')[1]);
}
});
}
});
// Récupérer tous les utilisateurs en une seule fois
const usersMap = {};
if (userIdsSet.size > 0) {
const userIds = Array.from(userIdsSet);
// Récupérer par batch (Firestore limite à 10 par requête 'in')
const batchSize = 10;
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
const usersSnapshot = await db.collection('users')
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
.get();
usersSnapshot.docs.forEach(userDoc => {
const userData = userDoc.data();
// Stocker uniquement les données publiques
usersMap[userDoc.id] = {
uid: userDoc.id,
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || '',
phoneNumber: userData.phoneNumber || '',
profilePhotoUrl: userData.profilePhotoUrl || '',
};
});
}
}
// Sérialiser les événements avec workforce comme liste d'UIDs
const events = eventsSnapshot.docs.map(doc => {
const data = doc.data();
// Convertir workforce en liste d'UIDs
let workforceUids = [];
if (data.workforce && Array.isArray(data.workforce)) {
workforceUids = data.workforce.map(userRef => {
if (userRef && userRef.id) {
return userRef.id;
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
return userRef.split('/')[1];
}
return null;
}).filter(uid => uid !== null);
}
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: workforceUids, // Liste d'UIDs au lieu de DocumentReference
};
});
// Retourner events + users map
res.status(200).json({
events,
users: usersMap // Map UID -> données utilisateur
});
} catch (error) {
logger.error("Error fetching events:", error);
res.status(500).json({ error: error.message });
}
}));
/**
* 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',
retryCount: 2,
memory: '512MiB'
}, async (context) => {
logger.info('[Scheduler] Démarrage sendDailyDigest');
try {
await sendDailyDigest();
logger.info('[Scheduler] sendDailyDigest terminé avec succès');
} catch (error) {
logger.error('[Scheduler] Erreur sendDailyDigest:', error);
throw error;
}
});
// ==================== 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'
}, 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')}`,
eventId: eventId,
eventName: eventData.name,
eventDate: eventData.startDate,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
metadata: {
eventId: eventId,
eventName: eventData.name,
eventDate: eventData.startDate,
},
assignedTo: [], // Sera rempli automatiquement par la fonction createAlert
});
// 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);
}
});
/**
* 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'
}, 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
);
});
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')}`,
eventId: eventId,
eventName: after.name,
eventDate: after.startDate,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
isRead: false,
metadata: {
eventId: eventId,
eventName: after.name,
eventDate: after.startDate,
},
assignedTo: [member.userId], // Alerte ciblée uniquement pour ce membre
});
logger.info(`[onEventUpdated] Alerte créée pour ${member.userId}`);
}
}
} catch (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'
}, async (event) => {
const alertId = event.params.alertId;
const alertData = event.data.data();
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
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({
pendingEmailSend: true,
});
logger.info(`[onAlertCreated] Alerte marquée pour envoi email`);
}
}
} catch (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 searchQuery = params.searchQuery?.toLowerCase() || 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}`);
// Construire la requête Firestore
let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// 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);
}
}
// 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;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur
if (searchQuery) {
equipments = equipments.filter(eq => {
const searchableText = [
eq.name || '',
eq.id || '',
eq.model || '',
eq.brand || '',
eq.subCategory || ''
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
}
// Pour la limite finale après filtrage textuel
const limitedEquipments = equipments.slice(0, limit);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
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 });
}
}));