feat: Intégration d'un système complet d'alertes et de notifications par email
Cette mise à jour majeure introduit un système de notifications robuste, centré sur la création d'alertes et l'envoi d'emails via des Cloud Functions. Elle inclut la gestion des préférences utilisateur, la création automatique d'alertes lors d'événements critiques et une nouvelle interface dédiée.
**Backend (Cloud Functions) :**
- **Nouveau service d'alerting (`createAlert`, `processEquipmentValidation`) :**
- `createAlert` : Nouvelle fonction pour créer une alerte. Elle détermine les utilisateurs à notifier (admins, workforce d'événement) et gère la persistance dans Firestore.
- `processEquipmentValidation` : Endpoint appelé lors de la validation du matériel (chargement/déchargement). Il analyse l'état de l'équipement (`LOST`, `MISSING`, `DAMAGED`) et crée automatiquement les alertes correspondantes.
- **Système d'envoi d'emails (`sendAlertEmail`, `sendDailyDigest`) :**
- `sendAlertEmail` : Cloud Function `onCall` pour envoyer un email d'alerte individuel. Elle respecte les préférences de notification de l'utilisateur (canal email, type d'alerte).
- `sendDailyDigest` : Tâche planifiée (tous les jours à 8h) qui envoie un email récapitulatif des alertes non lues des dernières 24 heures aux utilisateurs concernés.
- Ajout de templates HTML (`base-template`, `alert-individual`, `alert-digest`) avec `Handlebars` pour des emails riches.
- Configuration centralisée du SMTP via des variables d'environnement (`.env`).
- **Triggers Firestore (`onEventCreated`, `onEventUpdated`) :**
- Des triggers créent désormais des alertes d'information lorsqu'un événement est créé ou que de nouveaux membres sont ajoutés à la workforce.
- **Règles Firestore :**
- Mises à jour pour autoriser les utilisateurs authentifiés à créer et modifier leurs propres alertes (marquer comme lue, supprimer), tout en sécurisant les accès.
**Frontend (Flutter) :**
- **Nouvel `AlertService` et `EmailService` :**
- `AlertService` : Centralise la logique de création, lecture et gestion des alertes côté client en appelant les nouvelles Cloud Functions.
- `EmailService` : Service pour déclencher l'envoi d'emails via la fonction `sendAlertEmail`. Il contient la logique pour déterminer si une notification doit être immédiate (critique) ou différée (digest).
- **Nouvelle page de Notifications (`/alerts`) :**
- Interface dédiée pour lister toutes les alertes de l'utilisateur, avec des onglets pour filtrer par catégorie (Toutes, Événement, Maintenance, Équipement).
- Permet de marquer les alertes comme lues, de les supprimer et de tout marquer comme lu.
- **Intégration dans l'UI :**
- Ajout d'un badge de notification dans la `CustomAppBar` affichant le nombre d'alertes non lues en temps réel.
- Le `AutoLoginWrapper` gère désormais la redirection vers des routes profondes (ex: `/alerts`) depuis une URL.
- **Gestion des Préférences de Notification :**
- Ajout d'un widget `NotificationPreferencesWidget` dans la page "Mon Compte".
- Les utilisateurs peuvent désormais activer/désactiver les notifications par email, ainsi que filtrer par type d'alerte (événements, maintenance, etc.).
- Le `UserModel` et `LocalUserProvider` ont été étendus pour gérer ce nouveau modèle de préférences.
- **Création d'alertes contextuelles :**
- Le service `EventFormService` crée maintenant automatiquement une alerte lorsqu'un événement est créé ou modifié.
- La page de préparation d'événement (`EventPreparationPage`) appelle `processEquipmentValidation` à la fin de chaque étape pour une détection automatisée des anomalies.
**Dépendances et CI/CD :**
- Ajout des dépendances `cloud_functions` et `timeago` (Flutter), et `nodemailer`, `handlebars`, `dotenv` (Node.js).
- Ajout de scripts de déploiement PowerShell (`deploy_functions.ps1`, `deploy_firestore_rules.ps1`) pour simplifier les mises en production.
This commit is contained in:
@@ -3,7 +3,12 @@
|
||||
* 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');
|
||||
@@ -12,14 +17,16 @@ const { Storage } = require('@google-cloud/storage');
|
||||
const auth = require('./utils/auth');
|
||||
const helpers = require('./utils/helpers');
|
||||
|
||||
// Initialisation
|
||||
admin.initializeApp();
|
||||
// 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: true,
|
||||
cors: false,
|
||||
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
|
||||
};
|
||||
@@ -28,10 +35,16 @@ const httpOptions = {
|
||||
// CORS Middleware
|
||||
// ============================================================================
|
||||
const setCorsHeaders = (res, req) => {
|
||||
// Permettre toutes les origines en développement/production
|
||||
const origin = req.headers.origin || req.headers.referer || '*';
|
||||
// Utiliser l'origin de la requête pour permettre les credentials
|
||||
const origin = req.headers.origin || '*';
|
||||
|
||||
res.set('Access-Control-Allow-Origin', origin);
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// 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');
|
||||
@@ -43,7 +56,7 @@ const withCors = (handler) => {
|
||||
// Définir les headers CORS pour toutes les requêtes
|
||||
setCorsHeaders(res, req);
|
||||
|
||||
// Gérer les requêtes preflight OPTIONS
|
||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).send('');
|
||||
return;
|
||||
@@ -1165,7 +1178,7 @@ exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
|
||||
// Si mise à jour propre profil, limiter les champs modifiables
|
||||
if (isOwnProfile && !isAdminUser) {
|
||||
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl'];
|
||||
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl', 'notificationPreferences'];
|
||||
const filteredData = {};
|
||||
|
||||
for (const field of allowedFields) {
|
||||
@@ -1890,79 +1903,7 @@ exports.deleteAlert = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Créer une nouvelle alerte
|
||||
*/
|
||||
exports.createAlert = 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 { type, title, message, severity, equipmentId } = req.body.data;
|
||||
|
||||
if (!type || !message) {
|
||||
res.status(400).json({ error: 'type and message are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si une alerte similaire existe déjà (éviter les doublons)
|
||||
const existingAlertsQuery = await db.collection('alerts')
|
||||
.where('type', '==', type)
|
||||
.where('isRead', '==', false)
|
||||
.get();
|
||||
|
||||
let alertExists = false;
|
||||
if (equipmentId) {
|
||||
// Pour les alertes liées à un équipement, vérifier aussi l'equipmentId
|
||||
alertExists = existingAlertsQuery.docs.some(doc =>
|
||||
doc.data().equipmentId === equipmentId
|
||||
);
|
||||
} else {
|
||||
// Pour les autres alertes, vérifier le message
|
||||
alertExists = existingAlertsQuery.docs.some(doc =>
|
||||
doc.data().message === message
|
||||
);
|
||||
}
|
||||
|
||||
if (alertExists) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Alert already exists',
|
||||
skipped: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer la nouvelle alerte
|
||||
const alertData = {
|
||||
type: type,
|
||||
title: title || 'Alerte',
|
||||
message: message,
|
||||
severity: severity || 'MEDIUM',
|
||||
isRead: false,
|
||||
createdAt: admin.firestore.Timestamp.now(),
|
||||
};
|
||||
|
||||
if (equipmentId) {
|
||||
alertData.equipmentId = equipmentId;
|
||||
}
|
||||
|
||||
const alertRef = await db.collection('alerts').add(alertData);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
alertId: alertRef.id
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error creating 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
|
||||
@@ -3442,3 +3383,176 @@ exports.completeMaintenance = onRequest(httpOptions, withCors(async (req, res) =
|
||||
}
|
||||
}));
|
||||
|
||||
// ==================== 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',
|
||||
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('events/{eventId}', 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('events/{eventId}', 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('alerts/{alertId}', 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;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user