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:
255
em2rp/lib/services/alert_service.dart
Normal file
255
em2rp/lib/services/alert_service.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../models/alert_model.dart';
|
||||
import '../utils/debug_log.dart';
|
||||
import 'api_service.dart' show FirebaseFunctionsApiService;
|
||||
/// Service de gestion des alertes
|
||||
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions
|
||||
/// Toute la logique métier est gérée côté backend
|
||||
class AlertService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
/// Stream des alertes pour l'utilisateur connecté
|
||||
Stream<List<AlertModel>> getAlertsStream() {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
DebugLog.info('[AlertService] Pas d\'utilisateur connecté');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}');
|
||||
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
final alerts = snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
|
||||
DebugLog.info('[AlertService] ${alerts.length} alertes actives');
|
||||
return alerts;
|
||||
});
|
||||
}
|
||||
|
||||
/// Récupère les alertes non lues
|
||||
Future<List<AlertModel>> getUnreadAlerts() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return [];
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque une alerte comme lue
|
||||
Future<void> markAsRead(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId marquée comme lue');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque toutes les alertes comme lues
|
||||
Future<void> markAllAsRead() async {
|
||||
final user = _auth.currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: user.uid)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.get();
|
||||
|
||||
final batch = _firestore.batch();
|
||||
for (var doc in snapshot.docs) {
|
||||
batch.update(doc.reference, {
|
||||
'isRead': true,
|
||||
'readAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur marquage alertes', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive une alerte
|
||||
Future<void> archiveAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).update({
|
||||
'status': 'ARCHIVED',
|
||||
'archivedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
DebugLog.info('[AlertService] Alerte $alertId archivée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur archivage alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte manuelle (appelée par l'utilisateur)
|
||||
/// Cette méthode appelle la Cloud Function createAlert
|
||||
Future<String> createManualAlert({
|
||||
required AlertType type,
|
||||
required AlertSeverity severity,
|
||||
required String message,
|
||||
String? title,
|
||||
String? equipmentId,
|
||||
String? eventId,
|
||||
String? actionUrl,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ===');
|
||||
DebugLog.info('[AlertService] Type: $type');
|
||||
DebugLog.info('[AlertService] Severity: $severity');
|
||||
|
||||
final apiService = FirebaseFunctionsApiService();
|
||||
final result = await apiService.call(
|
||||
'createAlert',
|
||||
{
|
||||
'type': alertTypeToString(type),
|
||||
'severity': severity.name.toUpperCase(),
|
||||
'title': title,
|
||||
'message': message,
|
||||
'equipmentId': equipmentId,
|
||||
'eventId': eventId,
|
||||
'actionUrl': actionUrl,
|
||||
'metadata': metadata ?? {},
|
||||
},
|
||||
);
|
||||
|
||||
final alertId = result['alertId'] as String;
|
||||
DebugLog.info('[AlertService] ✓ Alerte créée: $alertId');
|
||||
|
||||
return alertId;
|
||||
} catch (e, stackTrace) {
|
||||
DebugLog.error('[AlertService] ❌ Erreur création alerte', e);
|
||||
DebugLog.error('[AlertService] Stack', stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream des alertes pour un utilisateur spécifique
|
||||
Stream<List<AlertModel>> alertsStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/// Récupère les alertes pour un utilisateur
|
||||
Future<List<AlertModel>> getAlertsForUser(String userId) async {
|
||||
try {
|
||||
final snapshot = await _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => AlertModel.fromFirestore(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur récupération alertes', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream du nombre d'alertes non lues pour un utilisateur
|
||||
Stream<int> unreadCountStreamForUser(String userId) {
|
||||
return _firestore
|
||||
.collection('alerts')
|
||||
.where('assignedTo', arrayContains: userId)
|
||||
.where('isRead', isEqualTo: false)
|
||||
.where('status', isEqualTo: 'ACTIVE')
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.docs.length);
|
||||
}
|
||||
|
||||
/// Supprime une alerte
|
||||
Future<void> deleteAlert(String alertId) async {
|
||||
try {
|
||||
await _firestore.collection('alerts').doc(alertId).delete();
|
||||
DebugLog.info('[AlertService] Alerte $alertId supprimée');
|
||||
} catch (e) {
|
||||
DebugLog.error('[AlertService] Erreur suppression alerte', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une alerte de création d'événement
|
||||
Future<void> createEventCreatedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required DateTime eventDate,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventCreated,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'eventDate': eventDate.toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une alerte de modification d'événement
|
||||
Future<void> createEventModifiedAlert({
|
||||
required String eventId,
|
||||
required String eventName,
|
||||
required String modification,
|
||||
}) async {
|
||||
await createManualAlert(
|
||||
type: AlertType.eventModified,
|
||||
severity: AlertSeverity.info,
|
||||
message: 'Événement "$eventName" modifié: $modification',
|
||||
eventId: eventId,
|
||||
metadata: {
|
||||
'eventName': eventName,
|
||||
'modification': modification,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
150
em2rp/lib/services/email_service.dart
Normal file
150
em2rp/lib/services/email_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:cloud_functions/cloud_functions.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
/// Service d'envoi d'emails via Cloud Functions
|
||||
class EmailService {
|
||||
final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1');
|
||||
|
||||
/// Envoie un email d'alerte à un utilisateur
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userId] : ID de l'utilisateur destinataire
|
||||
/// [templateType] : Type de template à utiliser (par défaut: 'alert-individual')
|
||||
Future<bool> sendAlertEmail({
|
||||
required AlertModel alert,
|
||||
required String userId,
|
||||
String templateType = 'alert-individual',
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier que l'utilisateur est authentifié
|
||||
final currentUser = FirebaseAuth.instance.currentUser;
|
||||
if (currentUser == null) {
|
||||
DebugLog.error('[EmailService] Utilisateur non authentifié');
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId');
|
||||
|
||||
final result = await _functions.httpsCallable('sendAlertEmail').call({
|
||||
'alertId': alert.id,
|
||||
'userId': userId,
|
||||
'templateType': templateType,
|
||||
});
|
||||
|
||||
final data = result.data as Map<String, dynamic>;
|
||||
final success = data['success'] as bool? ?? false;
|
||||
final skipped = data['skipped'] as bool? ?? false;
|
||||
|
||||
if (skipped) {
|
||||
final reason = data['reason'] as String? ?? 'unknown';
|
||||
DebugLog.info('[EmailService] Email non envoyé: $reason');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DebugLog.info('[EmailService] Email envoyé avec succès');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
DebugLog.error('[EmailService] Erreur envoi email', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un email d'alerte à plusieurs utilisateurs
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||
Future<Map<String, bool>> sendAlertEmailToMultipleUsers({
|
||||
required AlertModel alert,
|
||||
required List<String> userIds,
|
||||
String templateType = 'alert-individual',
|
||||
}) async {
|
||||
final results = <String, bool>{};
|
||||
|
||||
DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs');
|
||||
|
||||
// Envoyer en parallèle (max 5 à la fois pour éviter surcharge)
|
||||
final batches = <List<String>>[];
|
||||
for (var i = 0; i < userIds.length; i += 5) {
|
||||
batches.add(userIds.sublist(
|
||||
i,
|
||||
i + 5 > userIds.length ? userIds.length : i + 5,
|
||||
));
|
||||
}
|
||||
|
||||
for (final batch in batches) {
|
||||
final futures = batch.map((userId) => sendAlertEmail(
|
||||
alert: alert,
|
||||
userId: userId,
|
||||
templateType: templateType,
|
||||
));
|
||||
|
||||
final batchResults = await Future.wait(futures);
|
||||
|
||||
for (var i = 0; i < batch.length; i++) {
|
||||
results[batch[i]] = batchResults[i];
|
||||
}
|
||||
}
|
||||
|
||||
final successCount = results.values.where((v) => v).length;
|
||||
DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés');
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Détermine si une alerte doit être envoyée immédiatement ou en digest
|
||||
///
|
||||
/// [alert] : L'alerte à vérifier
|
||||
/// Returns: true si immédiat, false si digest
|
||||
bool shouldSendImmediate(AlertModel alert) {
|
||||
// Les alertes critiques sont envoyées immédiatement
|
||||
if (alert.severity == AlertSeverity.critical) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Types d'alertes toujours immédiates
|
||||
const immediateTypes = [
|
||||
AlertType.lost, // Équipement perdu
|
||||
AlertType.eventCancelled, // Événement annulé
|
||||
];
|
||||
|
||||
return immediateTypes.contains(alert.type);
|
||||
}
|
||||
|
||||
/// Envoie un email d'alerte en tenant compte des préférences
|
||||
///
|
||||
/// [alert] : L'alerte à envoyer
|
||||
/// [userIds] : Liste des IDs des utilisateurs destinataires
|
||||
Future<void> sendAlertWithPreferences({
|
||||
required AlertModel alert,
|
||||
required List<String> userIds,
|
||||
}) async {
|
||||
if (userIds.isEmpty) {
|
||||
DebugLog.warning('[EmailService] Aucun utilisateur à notifier');
|
||||
return;
|
||||
}
|
||||
|
||||
final immediate = shouldSendImmediate(alert);
|
||||
|
||||
if (immediate) {
|
||||
DebugLog.info('[EmailService] Envoi immédiat (alerte critique)');
|
||||
await sendAlertEmailToMultipleUsers(
|
||||
alert: alert,
|
||||
userIds: userIds,
|
||||
templateType: 'alert-individual',
|
||||
);
|
||||
} else {
|
||||
DebugLog.info('[EmailService] Ajout au digest (alerte non critique)');
|
||||
// Les alertes non critiques seront envoyées dans le digest quotidien
|
||||
// La Cloud Function sendDailyDigest s'en occupera
|
||||
// Rien à faire ici, les alertes sont déjà dans Firestore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:em2rp/models/event_type_model.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/alert_service.dart';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
class EventFormService {
|
||||
@@ -109,7 +110,24 @@ class EventFormService {
|
||||
static Future<String> createEvent(EventModel event) async {
|
||||
try {
|
||||
final result = await _apiService.call('createEvent', event.toMap());
|
||||
return result['id'] as String;
|
||||
final eventId = result['id'] as String;
|
||||
|
||||
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||
try {
|
||||
await AlertService().createEventCreatedAlert(
|
||||
eventId: eventId,
|
||||
eventName: event.name,
|
||||
eventDate: event.startDateTime,
|
||||
);
|
||||
developer.log('Alert created for new event: $eventId', name: 'EventFormService');
|
||||
} catch (alertError) {
|
||||
// Ne pas bloquer la création de l'événement si l'alerte échoue
|
||||
developer.log('Warning: Could not create alert for event',
|
||||
name: 'EventFormService',
|
||||
error: alertError);
|
||||
}
|
||||
|
||||
return eventId;
|
||||
} catch (e) {
|
||||
developer.log('Error creating event', name: 'EventFormService', error: e);
|
||||
rethrow;
|
||||
@@ -129,6 +147,24 @@ class EventFormService {
|
||||
await _apiService.call('updateEvent', eventData);
|
||||
|
||||
developer.log('Event updated successfully', name: 'EventFormService');
|
||||
|
||||
// NOUVEAU : Créer alerte automatique pour les utilisateurs assignés
|
||||
try {
|
||||
final currentUserId = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (currentUserId != null) {
|
||||
await AlertService().createEventModifiedAlert(
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
modification: 'Informations modifiées',
|
||||
);
|
||||
developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService');
|
||||
}
|
||||
} catch (alertError) {
|
||||
// Ne pas bloquer la modification de l'événement si l'alerte échoue
|
||||
developer.log('Warning: Could not create alert for event modification',
|
||||
name: 'EventFormService',
|
||||
error: alertError);
|
||||
}
|
||||
} catch (e) {
|
||||
developer.log('Error updating event', name: 'EventFormService', error: e);
|
||||
rethrow;
|
||||
|
||||
Reference in New Issue
Block a user