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:
277
em2rp/functions/sendAlertEmail.js
Normal file
277
em2rp/functions/sendAlertEmail.js
Normal file
@@ -0,0 +1,277 @@
|
||||
const functions = require('firebase-functions');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
|
||||
/**
|
||||
* Envoie un email d'alerte à un utilisateur
|
||||
* Appelé par le client Dart via callable function
|
||||
*/
|
||||
exports.sendAlertEmail = functions.https.onCall(async (data, context) => {
|
||||
// Vérifier l'authentification
|
||||
if (!context.auth) {
|
||||
throw new functions.https.HttpsError(
|
||||
'unauthenticated',
|
||||
'L\'utilisateur doit être authentifié',
|
||||
);
|
||||
}
|
||||
|
||||
const {alertId, userId, templateType} = data;
|
||||
|
||||
if (!alertId || !userId) {
|
||||
throw new functions.https.HttpsError(
|
||||
'invalid-argument',
|
||||
'alertId et userId sont requis',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer l'alerte depuis Firestore
|
||||
const alertDoc = await admin.firestore()
|
||||
.collection('alerts')
|
||||
.doc(alertId)
|
||||
.get();
|
||||
|
||||
if (!alertDoc.exists) {
|
||||
throw new functions.https.HttpsError(
|
||||
'not-found',
|
||||
'Alerte introuvable',
|
||||
);
|
||||
}
|
||||
|
||||
const alert = alertDoc.data();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await admin.firestore()
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
throw new functions.https.HttpsError(
|
||||
'not-found',
|
||||
'Utilisateur introuvable',
|
||||
);
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email de l'utilisateur
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
console.log(`Email désactivé pour l'utilisateur ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'email_disabled'};
|
||||
}
|
||||
|
||||
// Vérifier la préférence pour ce type d'alerte
|
||||
const alertType = alert.type;
|
||||
const shouldSend = checkAlertPreference(alertType, prefs);
|
||||
if (!shouldSend) {
|
||||
console.log(`Type d'alerte ${alertType} désactivé pour ${userId}`);
|
||||
return {success: true, skipped: true, reason: 'alert_type_disabled'};
|
||||
}
|
||||
|
||||
// Préparer les données pour le template
|
||||
const templateData = await prepareTemplateData(alert, user);
|
||||
|
||||
// Rendre le template HTML
|
||||
const html = await renderTemplate(
|
||||
templateType || 'alert-individual',
|
||||
templateData,
|
||||
);
|
||||
|
||||
// Configurer le transporteur SMTP
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
|
||||
// Envoyer l'email
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alert),
|
||||
html: html,
|
||||
// Fallback texte brut
|
||||
text: alert.message,
|
||||
});
|
||||
|
||||
console.log('Email envoyé:', info.messageId);
|
||||
|
||||
// Marquer l'email comme envoyé dans l'alerte
|
||||
await alertDoc.ref.update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
skipped: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi email:', error);
|
||||
throw new functions.https.HttpsError(
|
||||
'internal',
|
||||
`Erreur lors de l'envoi de l'email: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur souhaite recevoir ce type d'alerte
|
||||
*/
|
||||
function checkAlertPreference(alertType, preferences) {
|
||||
const typeMapping = {
|
||||
'EVENT_CREATED': 'eventsNotifications',
|
||||
'EVENT_MODIFIED': 'eventsNotifications',
|
||||
'EVENT_CANCELLED': 'eventsNotifications',
|
||||
'LOST': 'equipmentNotifications',
|
||||
'EQUIPMENT_MISSING': 'equipmentNotifications',
|
||||
'MAINTENANCE_REMINDER': 'maintenanceNotifications',
|
||||
'STOCK_LOW': 'stockNotifications',
|
||||
};
|
||||
|
||||
const prefKey = typeMapping[alertType];
|
||||
return prefKey ? (preferences[prefKey] !== false) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare les données pour le template
|
||||
*/
|
||||
async function prepareTemplateData(alert, user) {
|
||||
const data = {
|
||||
userName: `${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
'Utilisateur',
|
||||
alertTitle: getAlertTitle(alert.type),
|
||||
alertMessage: alert.message,
|
||||
isCritical: alert.severity === 'CRITICAL',
|
||||
actionUrl: `${EMAIL_CONFIG.appUrl}${alert.actionUrl || '/alerts'}`,
|
||||
appUrl: EMAIL_CONFIG.appUrl,
|
||||
unsubscribeUrl: `${EMAIL_CONFIG.appUrl}/my_account?tab=notifications`,
|
||||
year: new Date().getFullYear(),
|
||||
subject: getEmailSubject(alert),
|
||||
};
|
||||
|
||||
// Ajouter des détails selon le type d'alerte
|
||||
if (alert.eventId) {
|
||||
try {
|
||||
const eventDoc = await admin.firestore()
|
||||
.collection('events')
|
||||
.doc(alert.eventId)
|
||||
.get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
data.eventName = event.Name;
|
||||
if (event.StartDateTime) {
|
||||
const date = event.StartDateTime.toDate();
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération événement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (alert.equipmentId) {
|
||||
try {
|
||||
const eqDoc = await admin.firestore()
|
||||
.collection('equipments')
|
||||
.doc(alert.equipmentId)
|
||||
.get();
|
||||
|
||||
if (eqDoc.exists) {
|
||||
data.equipmentName = eqDoc.data().name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération équipement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre de l'email selon le type d'alerte
|
||||
*/
|
||||
function getEmailSubject(alert) {
|
||||
const subjects = {
|
||||
'EVENT_CREATED': '📅 Nouvel événement créé',
|
||||
'EVENT_MODIFIED': '📝 Événement modifié',
|
||||
'EVENT_CANCELLED': '❌ Événement annulé',
|
||||
'LOST': '🔴 Alerte critique : Équipement perdu',
|
||||
'EQUIPMENT_MISSING': '⚠️ Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': '🔧 Rappel de maintenance',
|
||||
'STOCK_LOW': '📦 Stock faible',
|
||||
};
|
||||
|
||||
return subjects[alert.type] || '🔔 Nouvelle alerte - EM2 Events';
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le titre pour le corps de l'email
|
||||
*/
|
||||
function getAlertTitle(type) {
|
||||
const titles = {
|
||||
'EVENT_CREATED': 'Nouvel événement créé',
|
||||
'EVENT_MODIFIED': 'Événement modifié',
|
||||
'EVENT_CANCELLED': 'Événement annulé',
|
||||
'LOST': 'Équipement perdu',
|
||||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||||
'MAINTENANCE_REMINDER': 'Maintenance requise',
|
||||
'STOCK_LOW': 'Stock faible',
|
||||
};
|
||||
|
||||
return titles[type] || 'Nouvelle alerte';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend un template HTML avec Handlebars
|
||||
*/
|
||||
async function renderTemplate(templateName, data) {
|
||||
try {
|
||||
// Lire le template de base
|
||||
const basePath = path.join(__dirname, 'templates', 'base-template.html');
|
||||
const baseTemplate = await fs.readFile(basePath, 'utf8');
|
||||
|
||||
// Lire le template de contenu
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'templates',
|
||||
`${templateName}.html`,
|
||||
);
|
||||
const contentTemplate = await fs.readFile(contentPath, 'utf8');
|
||||
|
||||
// Compiler les templates
|
||||
const compileContent = handlebars.compile(contentTemplate);
|
||||
const compileBase = handlebars.compile(baseTemplate);
|
||||
|
||||
// Rendre le contenu
|
||||
const renderedContent = compileContent(data);
|
||||
|
||||
// Rendre le template de base avec le contenu
|
||||
return compileBase({
|
||||
...data,
|
||||
content: renderedContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur rendu template:', error);
|
||||
// Fallback vers un template simple
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${data.alertTitle}</h2>
|
||||
<p>${data.alertMessage}</p>
|
||||
<a href="${data.actionUrl}">Voir l'alerte</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user