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:
@@ -1,10 +1,27 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Type d'alerte
|
||||
enum AlertType {
|
||||
lowStock, // Stock faible
|
||||
maintenanceDue, // Maintenance à venir
|
||||
conflict, // Conflit disponibilité
|
||||
lost // Équipement perdu
|
||||
lowStock, // Stock faible
|
||||
maintenanceDue, // Maintenance à venir
|
||||
conflict, // Conflit disponibilité
|
||||
lost, // Équipement perdu
|
||||
eventCreated, // Événement créé
|
||||
eventModified, // Événement modifié
|
||||
eventCancelled, // Événement annulé
|
||||
eventAssigned, // Assigné à un événement
|
||||
maintenanceReminder, // Rappel maintenance périodique
|
||||
equipmentMissing, // Équipement manquant à une étape
|
||||
quantityMismatch, // Quantité incorrecte
|
||||
damaged, // Équipement endommagé
|
||||
workforceAdded, // Ajouté à la workforce d'un événement
|
||||
}
|
||||
|
||||
/// Gravité de l'alerte
|
||||
enum AlertSeverity {
|
||||
info, // Information (bleu)
|
||||
warning, // Avertissement (orange)
|
||||
critical, // Critique (rouge)
|
||||
}
|
||||
|
||||
String alertTypeToString(AlertType type) {
|
||||
@@ -17,6 +34,24 @@ String alertTypeToString(AlertType type) {
|
||||
return 'CONFLICT';
|
||||
case AlertType.lost:
|
||||
return 'LOST';
|
||||
case AlertType.eventCreated:
|
||||
return 'EVENT_CREATED';
|
||||
case AlertType.eventModified:
|
||||
return 'EVENT_MODIFIED';
|
||||
case AlertType.eventCancelled:
|
||||
return 'EVENT_CANCELLED';
|
||||
case AlertType.eventAssigned:
|
||||
return 'EVENT_ASSIGNED';
|
||||
case AlertType.maintenanceReminder:
|
||||
return 'MAINTENANCE_REMINDER';
|
||||
case AlertType.equipmentMissing:
|
||||
return 'EQUIPMENT_MISSING';
|
||||
case AlertType.quantityMismatch:
|
||||
return 'QUANTITY_MISMATCH';
|
||||
case AlertType.damaged:
|
||||
return 'DAMAGED';
|
||||
case AlertType.workforceAdded:
|
||||
return 'WORKFORCE_ADDED';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,26 +65,88 @@ AlertType alertTypeFromString(String? type) {
|
||||
return AlertType.conflict;
|
||||
case 'LOST':
|
||||
return AlertType.lost;
|
||||
case 'EVENT_CREATED':
|
||||
return AlertType.eventCreated;
|
||||
case 'EVENT_MODIFIED':
|
||||
return AlertType.eventModified;
|
||||
case 'EVENT_CANCELLED':
|
||||
return AlertType.eventCancelled;
|
||||
case 'EVENT_ASSIGNED':
|
||||
return AlertType.eventAssigned;
|
||||
case 'MAINTENANCE_REMINDER':
|
||||
return AlertType.maintenanceReminder;
|
||||
case 'EQUIPMENT_MISSING':
|
||||
return AlertType.equipmentMissing;
|
||||
case 'QUANTITY_MISMATCH':
|
||||
return AlertType.quantityMismatch;
|
||||
case 'DAMAGED':
|
||||
return AlertType.damaged;
|
||||
case 'WORKFORCE_ADDED':
|
||||
return AlertType.workforceAdded;
|
||||
default:
|
||||
return AlertType.conflict;
|
||||
}
|
||||
}
|
||||
|
||||
String alertSeverityToString(AlertSeverity severity) {
|
||||
switch (severity) {
|
||||
case AlertSeverity.info:
|
||||
return 'INFO';
|
||||
case AlertSeverity.warning:
|
||||
return 'WARNING';
|
||||
case AlertSeverity.critical:
|
||||
return 'CRITICAL';
|
||||
}
|
||||
}
|
||||
|
||||
AlertSeverity alertSeverityFromString(String? severity) {
|
||||
switch (severity) {
|
||||
case 'INFO':
|
||||
return AlertSeverity.info;
|
||||
case 'WARNING':
|
||||
return AlertSeverity.warning;
|
||||
case 'CRITICAL':
|
||||
return AlertSeverity.critical;
|
||||
default:
|
||||
return AlertSeverity.info;
|
||||
}
|
||||
}
|
||||
|
||||
class AlertModel {
|
||||
final String id; // ID généré automatiquement
|
||||
final AlertType type; // Type d'alerte
|
||||
final String message; // Message de l'alerte
|
||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||
final DateTime createdAt; // Date de création
|
||||
final bool isRead; // Statut lu/non lu
|
||||
final String id; // ID généré automatiquement
|
||||
final AlertType type; // Type d'alerte
|
||||
final AlertSeverity severity; // Gravité de l'alerte
|
||||
final String message; // Message de l'alerte
|
||||
final List<String> assignedToUserIds; // Utilisateurs concernés
|
||||
final String? eventId; // ID de l'événement concerné (optionnel)
|
||||
final String? equipmentId; // ID de l'équipement concerné (optionnel)
|
||||
final String? createdByUserId; // Qui a déclenché l'alerte
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime? dueDate; // Date d'échéance (pour maintenance)
|
||||
final String? actionUrl; // URL de redirection (deep link)
|
||||
final bool isRead; // Statut lu/non lu
|
||||
final bool isResolved; // Résolue ou non
|
||||
final String? resolution; // Message de résolution
|
||||
final DateTime? resolvedAt; // Date de résolution
|
||||
final String? resolvedByUserId; // Qui a résolu
|
||||
|
||||
AlertModel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.severity = AlertSeverity.info,
|
||||
required this.message,
|
||||
this.assignedToUserIds = const [],
|
||||
this.eventId,
|
||||
this.equipmentId,
|
||||
this.createdByUserId,
|
||||
required this.createdAt,
|
||||
this.dueDate,
|
||||
this.actionUrl,
|
||||
this.isRead = false,
|
||||
this.isResolved = false,
|
||||
this.resolution,
|
||||
this.resolvedAt,
|
||||
this.resolvedByUserId,
|
||||
});
|
||||
|
||||
factory AlertModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
@@ -61,42 +158,116 @@ class AlertModel {
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
// Parser les assignedToUserIds (peut être List ou null)
|
||||
List<String> parseUserIds(dynamic value) {
|
||||
if (value == null) return [];
|
||||
if (value is List) return value.map((e) => e.toString()).toList();
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertModel(
|
||||
id: id,
|
||||
type: alertTypeFromString(map['type']),
|
||||
severity: alertSeverityFromString(map['severity']),
|
||||
message: map['message'] ?? '',
|
||||
assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']),
|
||||
eventId: map['eventId'],
|
||||
equipmentId: map['equipmentId'],
|
||||
createdByUserId: map['createdByUserId'] ?? map['createdBy'],
|
||||
createdAt: _parseDate(map['createdAt']),
|
||||
dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null,
|
||||
actionUrl: map['actionUrl'],
|
||||
isRead: map['isRead'] ?? false,
|
||||
isResolved: map['isResolved'] ?? false,
|
||||
resolution: map['resolution'],
|
||||
resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null,
|
||||
resolvedByUserId: map['resolvedByUserId'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory depuis un document Firestore
|
||||
factory AlertModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Document vide: ${doc.id}');
|
||||
}
|
||||
return AlertModel.fromMap(data, doc.id);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'type': alertTypeToString(type),
|
||||
'severity': alertSeverityToString(severity),
|
||||
'message': message,
|
||||
'equipmentId': equipmentId,
|
||||
'assignedToUserIds': assignedToUserIds,
|
||||
if (eventId != null) 'eventId': eventId,
|
||||
if (equipmentId != null) 'equipmentId': equipmentId,
|
||||
if (createdByUserId != null) 'createdByUserId': createdByUserId,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!),
|
||||
if (actionUrl != null) 'actionUrl': actionUrl,
|
||||
'isRead': isRead,
|
||||
'isResolved': isResolved,
|
||||
if (resolution != null) 'resolution': resolution,
|
||||
if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!),
|
||||
if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId,
|
||||
};
|
||||
}
|
||||
|
||||
AlertModel copyWith({
|
||||
String? id,
|
||||
AlertType? type,
|
||||
AlertSeverity? severity,
|
||||
String? message,
|
||||
List<String>? assignedToUserIds,
|
||||
String? eventId,
|
||||
String? equipmentId,
|
||||
String? createdByUserId,
|
||||
DateTime? createdAt,
|
||||
DateTime? dueDate,
|
||||
String? actionUrl,
|
||||
bool? isRead,
|
||||
bool? isResolved,
|
||||
String? resolution,
|
||||
DateTime? resolvedAt,
|
||||
String? resolvedByUserId,
|
||||
}) {
|
||||
return AlertModel(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
severity: severity ?? this.severity,
|
||||
message: message ?? this.message,
|
||||
assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds,
|
||||
eventId: eventId ?? this.eventId,
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
createdByUserId: createdByUserId ?? this.createdByUserId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
actionUrl: actionUrl ?? this.actionUrl,
|
||||
isRead: isRead ?? this.isRead,
|
||||
isResolved: isResolved ?? this.isResolved,
|
||||
resolution: resolution ?? this.resolution,
|
||||
resolvedAt: resolvedAt ?? this.resolvedAt,
|
||||
resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un événement
|
||||
bool get isEventAlert =>
|
||||
type == AlertType.eventCreated ||
|
||||
type == AlertType.eventModified ||
|
||||
type == AlertType.eventCancelled ||
|
||||
type == AlertType.eventAssigned;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour la maintenance
|
||||
bool get isMaintenanceAlert =>
|
||||
type == AlertType.maintenanceDue ||
|
||||
type == AlertType.maintenanceReminder;
|
||||
|
||||
/// Helper : Retourne true si l'alerte est pour un équipement
|
||||
bool get isEquipmentAlert =>
|
||||
type == AlertType.lost ||
|
||||
type == AlertType.equipmentMissing ||
|
||||
type == AlertType.lowStock;
|
||||
}
|
||||
|
||||
|
||||
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
88
em2rp/lib/models/notification_preferences_model.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
/// Préférences de notifications pour un utilisateur
|
||||
class NotificationPreferences {
|
||||
final bool emailEnabled; // Recevoir emails
|
||||
final bool pushEnabled; // Recevoir notifications push
|
||||
final bool inAppEnabled; // Recevoir alertes in-app
|
||||
|
||||
// Préférences par type d'alerte
|
||||
final bool eventsNotifications; // Alertes événements
|
||||
final bool maintenanceNotifications; // Alertes maintenance
|
||||
final bool stockNotifications; // Alertes stock
|
||||
final bool equipmentNotifications; // Alertes équipement
|
||||
|
||||
// Token FCM (pour push)
|
||||
final String? fcmToken;
|
||||
|
||||
const NotificationPreferences({
|
||||
this.emailEnabled = true, // ✓ Activé par défaut
|
||||
this.pushEnabled = false,
|
||||
this.inAppEnabled = true,
|
||||
this.eventsNotifications = true,
|
||||
this.maintenanceNotifications = true,
|
||||
this.stockNotifications = true,
|
||||
this.equipmentNotifications = true,
|
||||
this.fcmToken,
|
||||
});
|
||||
|
||||
/// Valeurs par défaut pour un nouvel utilisateur
|
||||
factory NotificationPreferences.defaults() {
|
||||
return const NotificationPreferences(
|
||||
emailEnabled: true, // ✓ Activé par défaut
|
||||
pushEnabled: false,
|
||||
inAppEnabled: true,
|
||||
eventsNotifications: true,
|
||||
maintenanceNotifications: true,
|
||||
stockNotifications: true,
|
||||
equipmentNotifications: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory NotificationPreferences.fromMap(Map<String, dynamic> map) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut
|
||||
pushEnabled: map['pushEnabled'] ?? false,
|
||||
inAppEnabled: map['inAppEnabled'] ?? true,
|
||||
eventsNotifications: map['eventsNotifications'] ?? true,
|
||||
maintenanceNotifications: map['maintenanceNotifications'] ?? true,
|
||||
stockNotifications: map['stockNotifications'] ?? true,
|
||||
equipmentNotifications: map['equipmentNotifications'] ?? true,
|
||||
fcmToken: map['fcmToken'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'emailEnabled': emailEnabled,
|
||||
'pushEnabled': pushEnabled,
|
||||
'inAppEnabled': inAppEnabled,
|
||||
'eventsNotifications': eventsNotifications,
|
||||
'maintenanceNotifications': maintenanceNotifications,
|
||||
'stockNotifications': stockNotifications,
|
||||
'equipmentNotifications': equipmentNotifications,
|
||||
if (fcmToken != null) 'fcmToken': fcmToken,
|
||||
};
|
||||
}
|
||||
|
||||
NotificationPreferences copyWith({
|
||||
bool? emailEnabled,
|
||||
bool? pushEnabled,
|
||||
bool? inAppEnabled,
|
||||
bool? eventsNotifications,
|
||||
bool? maintenanceNotifications,
|
||||
bool? stockNotifications,
|
||||
bool? equipmentNotifications,
|
||||
String? fcmToken,
|
||||
}) {
|
||||
return NotificationPreferences(
|
||||
emailEnabled: emailEnabled ?? this.emailEnabled,
|
||||
pushEnabled: pushEnabled ?? this.pushEnabled,
|
||||
inAppEnabled: inAppEnabled ?? this.inAppEnabled,
|
||||
eventsNotifications: eventsNotifications ?? this.eventsNotifications,
|
||||
maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications,
|
||||
stockNotifications: stockNotifications ?? this.stockNotifications,
|
||||
equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications,
|
||||
fcmToken: fcmToken ?? this.fcmToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/notification_preferences_model.dart';
|
||||
|
||||
class UserModel {
|
||||
final String uid;
|
||||
@@ -8,6 +9,7 @@ class UserModel {
|
||||
final String profilePhotoUrl;
|
||||
final String email;
|
||||
final String phoneNumber;
|
||||
final NotificationPreferences? notificationPreferences;
|
||||
|
||||
UserModel({
|
||||
required this.uid,
|
||||
@@ -17,6 +19,7 @@ class UserModel {
|
||||
required this.profilePhotoUrl,
|
||||
required this.email,
|
||||
required this.phoneNumber,
|
||||
this.notificationPreferences,
|
||||
});
|
||||
|
||||
// Convertit une Map (Firestore) en UserModel
|
||||
@@ -57,6 +60,9 @@ class UserModel {
|
||||
profilePhotoUrl: data['profilePhotoUrl'] ?? '',
|
||||
email: data['email'] ?? '',
|
||||
phoneNumber: data['phoneNumber'] ?? '',
|
||||
notificationPreferences: data['notificationPreferences'] != null
|
||||
? NotificationPreferences.fromMap(data['notificationPreferences'] as Map<String, dynamic>)
|
||||
: NotificationPreferences.defaults(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +75,8 @@ class UserModel {
|
||||
'profilePhotoUrl': profilePhotoUrl,
|
||||
'email': email,
|
||||
'phoneNumber': phoneNumber,
|
||||
if (notificationPreferences != null)
|
||||
'notificationPreferences': notificationPreferences!.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +87,7 @@ class UserModel {
|
||||
String? profilePhotoUrl,
|
||||
String? email,
|
||||
String? phoneNumber,
|
||||
NotificationPreferences? notificationPreferences,
|
||||
}) {
|
||||
return UserModel(
|
||||
uid: uid, // L'UID ne change pas
|
||||
@@ -88,6 +97,7 @@ class UserModel {
|
||||
profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
|
||||
email: email ?? this.email,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
notificationPreferences: notificationPreferences ?? this.notificationPreferences,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user