Files
EM2_ERP/em2rp/functions/sendAlertEmail.js
ElPoyo beaabceda4 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.
2026-01-15 23:15:25 +01:00

278 lines
7.7 KiB
JavaScript

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>
`;
}
}