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.
This commit is contained in:
ElPoyo
2026-01-13 01:40:28 +01:00
parent f38d75362c
commit 2bcd1ca4c3
16 changed files with 916 additions and 374 deletions

View File

@@ -3,7 +3,7 @@
* Architecture backend sécurisée avec authentification et permissions
*/
const { onRequest } = require("firebase-functions/v2/https");
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');
@@ -257,6 +257,54 @@ exports.getEquipment = onRequest(httpOptions, withCors(async (req, res) => {
}
}));
// Récupérer plusieurs équipements par leurs IDs
exports.getEquipmentsByIds = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
const hasViewAccess = await auth.hasPermission(decodedToken.uid, 'view_equipment');
const hasManageAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
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
// ============================================================================
@@ -366,20 +414,19 @@ exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, r
return;
}
const { equipmentId } = req.body.data || req.query;
const { equipmentId } = req.body.data || {};
if (!equipmentId) {
res.status(400).json({ error: 'equipmentId is required' });
return;
}
// Récupérer tous les containers qui contiennent cet équipement
const containersSnapshot = await db.collection('containers')
const snapshot = await db.collection('containers')
.where('equipmentIds', 'array-contains', equipmentId)
.get();
const containers = [];
containersSnapshot.forEach(doc => {
snapshot.forEach(doc => {
let data = { id: doc.id, ...doc.data() };
data = helpers.serializeTimestamps(data);
data = helpers.serializeReferences(data);
@@ -393,6 +440,53 @@ exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, r
}
}));
// 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
// ============================================================================
@@ -1345,11 +1439,14 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
return;
}
const userData = userDoc.data();
let userData = userDoc.data();
userData = helpers.serializeTimestamps(userData);
userData = helpers.serializeReferences(userData);
res.status(200).json({
users: [{
id: userDoc.id,
...helpers.serializeTimestamps(userData)
...userData
}]
});
return;
@@ -1357,10 +1454,15 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
// Admin : tous les utilisateurs
const snapshot = await db.collection('users').get();
const users = snapshot.docs.map(doc => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data())
}));
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) {
@@ -1370,46 +1472,200 @@ exports.getUsers = onRequest(httpOptions, withCors(async (req, res) => {
}));
// ============================================================================
// CONTAINERS - Récupération par équipement
// 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
// ============================================================================
/**
* Récupère tous les containers contenant un équipement spécifique
* Accessible à tous les utilisateurs authentifiés
* Supprime un utilisateur (Auth + Firestore)
* Permissions: 'delete_user' OU propriétaire
*/
exports.getContainersByEquipment = onRequest(httpOptions, withCors(async (req, res) => {
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 l'authentification
const user = await auth.authenticateUser(req);
// Vérifier les permissions
const callerDoc = await db.collection("users").doc(auth.uid).get();
const callerData = callerDoc.data();
const equipmentId = req.body.data?.equipmentId;
if (!equipmentId) {
res.status(400).json({ error: 'equipmentId is required' });
return;
if (!callerData) {
throw new Error("Caller user not found");
}
logger.info(`Fetching containers for equipment: ${equipmentId}`);
// 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;
}
// Requête pour trouver tous les containers contenant cet équipement
const containersSnapshot = await db.collection('containers')
.where('equipmentIds', 'array-contains', equipmentId)
.get();
// 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");
}
const containers = containersSnapshot.docs.map(doc => ({
id: doc.id,
...helpers.serializeTimestamps(doc.data())
}));
if (!canDelete) {
throw new Error("Unauthorized: Missing delete_user permission");
}
logger.info(`Found ${containers.length} container(s) for equipment ${equipmentId}`);
// 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)
}
res.status(200).json({
containers,
count: containers.length
});
// 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 fetching containers by equipment:", error);
res.status(500).json({ error: error.message });
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");
}
});