Files
EM2_ERP/em2rp/functions/index.js
ElPoyo 2bcd1ca4c3 feat: Ajout de la gestion des utilisateurs et optimisation du chargement des données
Cette mise à jour introduit la gestion complète des utilisateurs (création, mise à jour, suppression) via des Cloud Functions et optimise de manière significative le chargement des données dans toute l'application.

**Features :**
- **Gestion des utilisateurs (Backend & Frontend) :**
    - Ajout des Cloud Functions `getUser`, `updateUser` et `deleteUser` pour gérer les utilisateurs de manière sécurisée, en respectant les permissions des rôles.
    - L'authentification passe désormais par `onCall` pour plus de sécurité.
- **Optimisation du chargement des données :**
    - Introduction de nouvelles Cloud Functions `getEquipmentsByIds` et `getContainersByIds` pour récupérer uniquement les documents nécessaires, réduisant ainsi la charge sur le client et Firestore.
    - Les fournisseurs (`EquipmentProvider`, `ContainerProvider`) ont été refactorisés pour utiliser un chargement à la demande (`ensureLoaded`) et mettre en cache les données récupérées.
    - Les écrans de détails et de préparation d'événements n'utilisent plus de `Stream` globaux, mais chargent les équipements et boites spécifiques via ces nouvelles fonctions, améliorant considérablement les performances.

**Refactorisation et Améliorations :**
- **Backend (Cloud Functions) :**
    - Le service de vérification de disponibilité (`checkEquipmentAvailability`) est désormais une Cloud Function, déplaçant la logique métier côté serveur.
    - La récupération des données (utilisateurs, événements, alertes) a été centralisée derrière des Cloud Functions, remplaçant les appels directs à Firestore depuis le client.
    - Amélioration de la sérialisation des données (timestamps, références) dans les réponses des fonctions.
- **Frontend (Flutter) :**
    - `LocalUserProvider` charge désormais les informations de l'utilisateur connecté via la fonction `getCurrentUser`, incluant son rôle et ses permissions en un seul appel.
    - `AlertProvider` utilise des fonctions pour charger et manipuler les alertes, abandonnant le `Stream` Firestore.
    - `EventAvailabilityService` utilise maintenant la Cloud Function `checkEquipmentAvailability` au lieu d'une logique client complexe.
    - Correction de la gestion des références de rôles (`roles/ADMIN`) et des `DocumentReference` pour les utilisateurs dans l'ensemble de l'application.
    - Le service d'export ICS (`IcsExportService`) a été simplifié, partant du principe que les données nécessaires (utilisateurs, options) sont déjà chargées dans l'application.
2026-01-13 01:40:28 +01:00

1672 lines
53 KiB
JavaScript

/**
* EM2RP Cloud Functions
* Architecture backend sécurisée avec authentification et permissions
*/
const { onRequest, onCall } = require("firebase-functions/v2/https");
const logger = require("firebase-functions/logger");
const admin = require('firebase-admin');
const { Storage } = require('@google-cloud/storage');
// Utilitaires
const auth = require('./utils/auth');
const helpers = require('./utils/helpers');
// Initialisation
admin.initializeApp();
const storage = new Storage();
const db = admin.firestore();
// Configuration commune pour toutes les fonctions HTTP
const httpOptions = {
cors: true,
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
};
// ============================================================================
// CORS Middleware
// ============================================================================
const setCorsHeaders = (res, req) => {
// Permettre toutes les origines en développement/production
const origin = req.headers.origin || req.headers.referer || '*';
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.set('Access-Control-Max-Age', '3600');
};
// Wrapper pour les fonctions avec CORS
const withCors = (handler) => {
return async (req, res) => {
// Définir les headers CORS pour toutes les requêtes
setCorsHeaders(res, req);
// Gérer les requêtes preflight OPTIONS
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
// Exécuter le handler
try {
await handler(req, res);
} catch (error) {
logger.error("Unhandled error:", error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
};
};
// ============================================================================
// STORAGE - Move Event File
// ============================================================================
exports.moveEventFileV2 = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { sourcePath, destinationPath } = req.body.data || {};
if (!sourcePath || !destinationPath) {
res.status(400).json({ error: 'Source and destination paths are required.' });
return;
}
const bucketName = admin.storage().bucket().name;
const bucket = storage.bucket(bucketName);
await bucket.file(sourcePath).copy(bucket.file(destinationPath));
await bucket.file(sourcePath).delete();
const [url] = await bucket.file(destinationPath).getSignedUrl({
action: 'read',
expires: '03-01-2500',
});
res.status(200).json({ url });
} catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENT - CRUD
// ============================================================================
// Créer un équipement (admin ou manage_equipment)
exports.createEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const equipmentData = req.body.data;
const equipmentId = equipmentData.id;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier unicité de l'ID
const existingDoc = await db.collection('equipments').doc(equipmentId).get();
if (existingDoc.exists) {
res.status(409).json({ error: 'Equipment ID already exists' });
return;
}
// Convertir les timestamps
const dataToSave = helpers.deserializeTimestamps(equipmentData, [
'createdAt', 'updatedAt', 'purchaseDate', 'lastMaintenanceDate', 'nextMaintenanceDate'
]);
await db.collection('equipments').doc(equipmentId).set(dataToSave);
res.status(201).json({ id: equipmentId, message: 'Equipment created successfully' });
} catch (error) {
logger.error("Error creating equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un équipement
exports.updateEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { equipmentId, data } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Empêcher la modification de l'ID
delete data.id;
// Ajouter updatedAt
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
'purchaseDate', 'lastMaintenanceDate', 'nextMaintenanceDate'
]);
await db.collection('equipments').doc(equipmentId).update(dataToSave);
res.status(200).json({ message: 'Equipment updated successfully' });
} catch (error) {
logger.error("Error updating equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un équipement
exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { equipmentId } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier si l'équipement est utilisé dans des événements actifs
const eventsSnapshot = await db.collection('events')
.where('status', '!=', 'CANCELLED')
.get();
for (const eventDoc of eventsSnapshot.docs) {
const eventData = eventDoc.data();
const assignedEquipment = eventData.assignedEquipment || [];
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
res.status(409).json({
error: 'Cannot delete equipment: it is assigned to active events',
eventId: eventDoc.id
});
return;
}
}
await db.collection('equipments').doc(equipmentId).delete();
res.status(200).json({ message: 'Equipment deleted successfully' });
} catch (error) {
logger.error("Error deleting equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer un équipement par ID
exports.getEquipment = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { equipmentId } = req.body.data || req.query;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
const doc = await db.collection('equipments').doc(equipmentId).get();
if (!doc.exists) {
res.status(404).json({ error: 'Equipment not found' });
return;
}
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
res.status(200).json({ equipment: data });
} catch (error) {
logger.error("Error getting equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasViewAccess && !hasManageAccess) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const { equipmentIds } = req.body.data || {};
if (!equipmentIds || !Array.isArray(equipmentIds) || equipmentIds.length === 0) {
res.status(400).json({ error: 'equipmentIds array is required and must not be empty' });
return;
}
// Limiter à 100 équipements max par requête
if (equipmentIds.length > 100) {
res.status(400).json({ error: 'Maximum 100 equipment IDs per request' });
return;
}
// Récupérer tous les documents en parallèle
const promises = equipmentIds.map(id => db.collection('equipments').doc(id).get());
const docs = await Promise.all(promises);
const equipments = [];
for (const doc of docs) {
if (doc.exists) {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
// Masquer les prix si pas de permission manage_equipment
data = helpers.maskSensitiveFields(data, hasManageAccess);
equipments.push(data);
}
}
res.status(200).json({ equipments });
} catch (error) {
logger.error("Error getting equipments by IDs:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - CRUD
// ============================================================================
// Créer un container
exports.createContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const containerData = req.body.data;
const containerId = containerData.id;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
const existingDoc = await db.collection('containers').doc(containerId).get();
if (existingDoc.exists) {
res.status(409).json({ error: 'Container ID already exists' });
return;
}
const dataToSave = helpers.deserializeTimestamps(containerData, ['createdAt', 'updatedAt']);
await db.collection('containers').doc(containerId).set(dataToSave);
res.status(201).json({ id: containerId, message: 'Container created successfully' });
} catch (error) {
logger.error("Error creating container:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un container
exports.updateContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId, data } = req.body.data;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
await db.collection('containers').doc(containerId).update(data);
res.status(200).json({ message: 'Container updated successfully' });
} catch (error) {
logger.error("Error updating container:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un container
exports.deleteContainer = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const { containerId } = req.body.data;
if (!containerId) {
res.status(400).json({ error: 'Container ID is required' });
return;
}
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 });
}
}));
// ============================================================================
// EVENTS - CRUD
// ============================================================================
// Créer un événement
exports.createEvent = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'edit_event');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires edit_event permission' });
return;
}
const eventData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(eventData, [
'startDateTime', 'endDateTime', 'createdAt', 'updatedAt'
]);
const docRef = await db.collection('events').add(dataToSave);
res.status(201).json({ id: docRef.id, message: 'Event created successfully' });
} catch (error) {
logger.error("Error creating event:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un événement
exports.updateEvent = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'edit_event');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires edit_event permission' });
return;
}
const requestData = req.body.data;
const eventId = requestData.eventId;
if (!eventId) {
res.status(400).json({ error: 'Event ID is required' });
return;
}
// Extraire eventId et préparer les données à sauvegarder
const { eventId: _, ...data } = requestData;
if (!data || Object.keys(data).length === 0) {
res.status(400).json({ error: 'No data to update' });
return;
}
delete data.id;
data.updatedAt = admin.firestore.Timestamp.now();
const dataToSave = helpers.deserializeTimestamps(data, [
'startDateTime', 'endDateTime'
]);
await db.collection('events').doc(eventId).update(dataToSave);
res.status(200).json({ message: 'Event updated successfully' });
} catch (error) {
logger.error("Error updating event:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un événement
exports.deleteEvent = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'delete_event');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires delete_event permission' });
return;
}
const { eventId } = req.body.data;
if (!eventId) {
res.status(400).json({ error: 'Event ID is required' });
return;
}
await db.collection('events').doc(eventId).delete();
res.status(200).json({ message: 'Event deleted successfully' });
} catch (error) {
logger.error("Error deleting event:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// MAINTENANCES - CRUD
// ============================================================================
// Créer une maintenance
exports.createMaintenance = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_maintenances');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_maintenances permission' });
return;
}
const maintenanceData = req.body.data;
const dataToSave = helpers.deserializeTimestamps(maintenanceData, [
'scheduledDate', 'completedDate', 'createdAt', 'updatedAt'
]);
const docRef = await db.collection('maintenances').add(dataToSave);
res.status(201).json({ id: docRef.id, 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 });
}
}));
// Mettre à jour un utilisateur
exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId, data } = req.body.data;
if (!userId) {
res.status(400).json({ error: 'User ID is required' });
return;
}
// Vérifier si l'utilisateur met à jour son propre profil ou est admin
const isOwnProfile = decodedToken.uid === userId;
const isAdminUser = await auth.isAdmin(decodedToken.uid);
const hasEditPermission = await auth.hasPermission(decodedToken.uid, 'edit_user');
if (!isOwnProfile && !isAdminUser && !hasEditPermission) {
res.status(403).json({ error: 'Forbidden: Cannot edit other users' });
return;
}
// Si mise à jour propre profil, limiter les champs modifiables
if (isOwnProfile && !isAdminUser) {
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl'];
const filteredData = {};
for (const field of allowedFields) {
if (data[field] !== undefined) {
filteredData[field] = data[field];
}
}
await db.collection('users').doc(userId).update(filteredData);
} else {
delete data.uid;
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 });
}
}));
// ============================================================================
// 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');
const isAssigned = eventData.workforce?.some(ref => ref.path.endsWith(decodedToken.uid));
if (!isAssigned && !isAdminUser) {
res.status(403).json({ error: 'Forbidden: Not assigned to this event' });
return;
}
// Préparer les données à mettre à jour
const updateData = {};
if (assignedEquipment) {
// Convertir les timestamps dans assignedEquipment
updateData.assignedEquipment = assignedEquipment.map(eq =>
helpers.deserializeTimestamps(eq, [])
);
}
if (preparationStatus) updateData.preparationStatus = preparationStatus;
if (loadingStatus) updateData.loadingStatus = loadingStatus;
if (unloadingStatus) updateData.unloadingStatus = unloadingStatus;
if (returnStatus) updateData.returnStatus = returnStatus;
// Mettre à jour l'événement
await db.collection('events').doc(eventId).update(updateData);
res.status(200).json({ message: 'Event equipment updated successfully' });
} catch (error) {
logger.error("Error updating event equipment:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENT STATUS - Update individual equipment status
// ============================================================================
exports.updateEquipmentStatusOnly = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { equipmentId, status, availableQuantity } = req.body.data;
if (!equipmentId) {
res.status(400).json({ error: 'Equipment ID is required' });
return;
}
// Vérifier les permissions
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
if (!hasAccess) {
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
return;
}
const updateData = { updatedAt: admin.firestore.Timestamp.now() };
if (status) updateData.status = status;
if (availableQuantity !== undefined) updateData.availableQuantity = availableQuantity;
await db.collection('equipments').doc(equipmentId).update(updateData);
res.status(200).json({ message: 'Equipment status updated successfully' });
} catch (error) {
logger.error("Error updating equipment status:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENT TYPES - CRUD Operations
// ============================================================================
// Récupérer les événements utilisant un type d'événement
exports.getEventsByEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req);
const { eventTypeId } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
const eventsSnapshot = await db.collection('events')
.where('eventTypeId', '==', eventTypeId)
.get();
const events = eventsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().name,
startDateTime: doc.data().StartDateTime,
}));
res.status(200).json({ events });
} catch (error) {
logger.error("Error fetching events by type:", error);
res.status(500).json({ error: error.message });
}
}));
// Créer un type d'événement (admin uniquement)
exports.createEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { name, defaultPrice } = req.body.data;
if (!name || defaultPrice === undefined) {
res.status(400).json({ error: 'Name and defaultPrice are required' });
return;
}
// Vérifier l'unicité du nom
const existingSnapshot = await db.collection('eventTypes')
.where('name', '==', name)
.get();
if (!existingSnapshot.empty) {
res.status(409).json({ error: 'Event type name already exists' });
return;
}
const eventTypeData = {
name,
defaultPrice,
createdAt: admin.firestore.Timestamp.now(),
};
const docRef = await db.collection('eventTypes').add(eventTypeData);
res.status(201).json({ id: docRef.id, message: 'Event type created successfully' });
} catch (error) {
logger.error("Error creating event type:", error);
res.status(500).json({ error: error.message });
}
}));
// Mettre à jour un type d'événement (admin uniquement)
exports.updateEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { eventTypeId, name, defaultPrice } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
// Vérifier que le document existe
const docRef = db.collection('eventTypes').doc(eventTypeId);
const doc = await docRef.get();
if (!doc.exists) {
res.status(404).json({ error: 'Event type not found' });
return;
}
// Vérifier l'unicité du nom (sauf pour le document actuel)
if (name) {
const existingSnapshot = await db.collection('eventTypes')
.where('name', '==', name)
.get();
const hasDuplicate = existingSnapshot.docs.some(d => d.id !== eventTypeId);
if (hasDuplicate) {
res.status(409).json({ error: 'Event type name already exists' });
return;
}
}
const updateData = {};
if (name) updateData.name = name;
if (defaultPrice !== undefined) updateData.defaultPrice = defaultPrice;
await docRef.update(updateData);
res.status(200).json({ message: 'Event type updated successfully' });
} catch (error) {
logger.error("Error updating event type:", error);
res.status(500).json({ error: error.message });
}
}));
// Supprimer un type d'événement (admin uniquement)
exports.deleteEventType = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const isAdmin = await auth.hasPermission(decodedToken.uid, 'edit_data');
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin permission required' });
return;
}
const { eventTypeId } = req.body.data;
if (!eventTypeId) {
res.status(400).json({ error: 'Event type ID is required' });
return;
}
// Vérifier qu'aucun événement futur n'utilise ce type
const eventsSnapshot = await db.collection('events')
.where('eventTypeId', '==', eventTypeId)
.get();
const now = admin.firestore.Timestamp.now();
const futureEvents = eventsSnapshot.docs.filter(doc => {
const startDate = doc.data().StartDateTime;
return startDate && startDate > now;
});
if (futureEvents.length > 0) {
res.status(409).json({
error: 'Cannot delete event type with future events',
futureEventsCount: futureEvents.length
});
return;
}
await db.collection('eventTypes').doc(eventTypeId).delete();
res.status(200).json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error("Error deleting event type:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EVENTS - Read with permissions
// ============================================================================
exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { userId } = req.body.data || {};
// Vérifier si l'utilisateur peut voir tous les événements
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
let eventsSnapshot;
if (canViewAll) {
// Admin : tous les événements
eventsSnapshot = await db.collection('events').get();
} else {
// Utilisateur normal : seulement ses événements assignés
const userRef = db.collection('users').doc(userId || decodedToken.uid);
eventsSnapshot = await db.collection('events')
.where('workforce', 'array-contains', userRef)
.get();
}
// Collecter tous les UIDs utilisateurs uniques
const userIdsSet = new Set();
eventsSnapshot.docs.forEach(doc => {
const data = doc.data();
if (data.workforce && Array.isArray(data.workforce)) {
data.workforce.forEach(userRef => {
if (userRef && userRef.id) {
userIdsSet.add(userRef.id);
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
userIdsSet.add(userRef.split('/')[1]);
}
});
}
});
// Récupérer tous les utilisateurs en une seule fois
const usersMap = {};
if (userIdsSet.size > 0) {
const userIds = Array.from(userIdsSet);
// Récupérer par batch (Firestore limite à 10 par requête 'in')
const batchSize = 10;
for (let i = 0; i < userIds.length; i += batchSize) {
const batch = userIds.slice(i, i + batchSize);
const usersSnapshot = await db.collection('users')
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
.get();
usersSnapshot.docs.forEach(userDoc => {
const userData = userDoc.data();
// Stocker uniquement les données publiques
usersMap[userDoc.id] = {
uid: userDoc.id,
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || '',
phoneNumber: userData.phoneNumber || '',
profilePhotoUrl: userData.profilePhotoUrl || '',
};
});
}
}
// Sérialiser les événements avec workforce comme liste d'UIDs
const events = eventsSnapshot.docs.map(doc => {
const data = doc.data();
// Convertir workforce en liste d'UIDs
let workforceUids = [];
if (data.workforce && Array.isArray(data.workforce)) {
workforceUids = data.workforce.map(userRef => {
if (userRef && userRef.id) {
return userRef.id;
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
return userRef.split('/')[1];
}
return null;
}).filter(uid => uid !== null);
}
return {
id: doc.id,
...helpers.serializeTimestamps(data),
workforce: workforceUids, // Liste d'UIDs au lieu de DocumentReference
};
});
// Retourner events + users map
res.status(200).json({
events,
users: usersMap // Map UID -> données utilisateur
});
} catch (error) {
logger.error("Error fetching events:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// EQUIPMENTS - Read with permissions
// ============================================================================
exports.getEquipments = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canManage && !canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
const snapshot = await db.collection('equipments').get();
const equipments = snapshot.docs.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
res.status(200).json({ equipments });
} catch (error) {
logger.error("Error fetching equipments:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - Read with permissions
// ============================================================================
exports.getContainers = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
const snapshot = await db.collection('containers').get();
const containers = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt'])
};
});
res.status(200).json({ containers });
} catch (error) {
logger.error("Error fetching containers:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// MAINTENANCES - Read with permissions
// ============================================================================
exports.getMaintenances = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const { equipmentId } = req.body.data || {};
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
let query = db.collection('maintenances');
// Filtrer par équipement si spécifié
if (equipmentId) {
query = query.where('equipmentIds', 'array-contains', equipmentId);
}
const snapshot = await query.get();
const maintenances = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['scheduledDate', 'completedDate', 'createdAt', 'updatedAt'])
};
});
res.status(200).json({ maintenances });
} catch (error) {
logger.error("Error fetching maintenances:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// ALERTS - Read with permissions
// ============================================================================
exports.getAlerts = onRequest(httpOptions, withCors(async (req, res) => {
try {
await auth.authenticateUser(req);
const snapshot = await db.collection('alerts')
.orderBy('createdAt', 'desc')
.limit(100)
.get();
const alerts = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['createdAt'])
};
});
res.status(200).json({ alerts });
} catch (error) {
logger.error("Error fetching alerts:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// USERS - Read with permissions
// ============================================================================
exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_users');
if (!canViewAll) {
// Si pas admin, ne retourner que l'utilisateur lui-même
const userDoc = await db.collection('users').doc(decodedToken.uid).get();
if (!userDoc.exists) {
res.status(404).json({ error: 'User not found' });
return;
}
let userData = userDoc.data();
userData = helpers.serializeTimestamps(userData);
userData = helpers.serializeReferences(userData);
res.status(200).json({
users: [{
id: userDoc.id,
...userData
}]
});
return;
}
// Admin : tous les utilisateurs
const snapshot = await db.collection('users').get();
const users = snapshot.docs.map(doc => {
let data = doc.data();
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
return {
id: doc.id,
...data
};
});
res.status(200).json({ users });
} catch (error) {
logger.error("Error fetching users:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// USER - Récupération individuelle
// ============================================================================
/**
* Récupère un utilisateur spécifique par son ID
* Tout utilisateur authentifié peut accéder aux données publiques
*/
exports.getUser = onCall(async (request) => {
try {
await authenticateUser(request);
const db = getFirestore();
const { userId } = request.data;
if (!userId) {
throw new Error("userId is required");
}
const userDoc = await db.collection("users").doc(userId).get();
if (!userDoc.exists) {
throw new Error("User not found");
}
const user = userDoc.data();
// Données publiques accessibles à tous
const userData = {
id: userDoc.id,
uid: user.uid || userDoc.id,
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
phoneNumber: user.phoneNumber || "",
profilePhotoUrl: user.profilePhotoUrl || "",
};
// Inclure le rôle si disponible
if (user.role) {
const roleDoc = await user.role.get();
if (roleDoc.exists) {
userData.role = {
id: roleDoc.id,
...roleDoc.data(),
};
}
}
return { user: userData };
} catch (error) {
logger.error("Error fetching user:", error);
throw new Error(error.message || "Failed to fetch user");
}
});
// ============================================================================
// USER MANAGEMENT - Delete & Update
// ============================================================================
/**
* Supprime un utilisateur (Auth + Firestore)
* Permissions: 'delete_user' OU propriétaire
*/
exports.deleteUser = onCall(async (request) => {
const { auth, data } = request;
if (!auth) {
throw new Error("Unauthorized: Authentication required");
}
const { userId } = data;
if (!userId) {
throw new Error("userId is required");
}
try {
// Vérifier les permissions
const callerDoc = await db.collection("users").doc(auth.uid).get();
const callerData = callerDoc.data();
if (!callerData) {
throw new Error("Caller user not found");
}
// Vérifier si l'utilisateur a la permission delete_user
let canDelete = false;
if (callerData.role) {
const roleDoc = await callerData.role.get();
const roleData = roleDoc.data();
canDelete = roleData?.permissions?.includes("delete_user") || false;
}
// Ou si c'est le propriétaire (mais on ne peut pas se supprimer soi-même)
if (userId === auth.uid) {
throw new Error("Cannot delete your own account");
}
if (!canDelete) {
throw new Error("Unauthorized: Missing delete_user permission");
}
// Supprimer de Firebase Auth
try {
await admin.auth().deleteUser(userId);
} catch (authError) {
logger.warn(`Could not delete user from Auth: ${authError.message}`);
// Continuer même si Auth échoue (l'utilisateur peut ne plus exister dans Auth)
}
// Supprimer de Firestore
await db.collection("users").doc(userId).delete();
logger.info(`User ${userId} deleted by ${auth.uid}`);
return { success: true, message: "User deleted successfully" };
} catch (error) {
logger.error("Error deleting user:", error);
throw new Error(error.message || "Failed to delete user");
}
});
/**
* Met à jour un utilisateur
* Permissions: 'edit_user' OU propriétaire (modifications limitées)
*/
exports.updateUser = onCall(async (request) => {
const { auth, data } = request;
if (!auth) {
throw new Error("Unauthorized: Authentication required");
}
const { userId, userData } = data;
if (!userId || !userData) {
throw new Error("userId and userData are required");
}
try {
// Vérifier les permissions
const callerDoc = await db.collection("users").doc(auth.uid).get();
const callerData = callerDoc.data();
if (!callerData) {
throw new Error("Caller user not found");
}
let canEditAll = false;
if (callerData.role) {
const roleDoc = await callerData.role.get();
const roleData = roleDoc.data();
canEditAll = roleData?.permissions?.includes("edit_user") || false;
}
const isOwner = userId === auth.uid;
// Si pas de permission edit_user et pas propriétaire, refuser
if (!canEditAll && !isOwner) {
throw new Error("Unauthorized: Missing edit_user permission");
}
// Préparer les données à mettre à jour
const updateData = {
firstName: userData.firstName,
lastName: userData.lastName,
email: userData.email,
phoneNumber: userData.phoneNumber || "",
};
// Seuls ceux avec edit_user peuvent changer le rôle
if (userData.role) {
if (!canEditAll) {
throw new Error("Unauthorized: Cannot change role without edit_user permission");
}
// Créer la référence au rôle
updateData.role = db.collection("roles").doc(userData.role);
}
// Mettre à jour Firestore
await db.collection("users").doc(userId).update(updateData);
// Mettre à jour Firebase Auth si email a changé (seulement avec edit_user)
if (userData.email && canEditAll) {
try {
await admin.auth().updateUser(userId, {
email: userData.email,
});
} catch (authError) {
logger.warn(`Could not update email in Auth: ${authError.message}`);
}
}
logger.info(`User ${userId} updated by ${auth.uid}`);
return { success: true, message: "User updated successfully" };
} catch (error) {
logger.error("Error updating user:", error);
throw new Error(error.message || "Failed to update user");
}
});