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.
268 lines
8.6 KiB
JavaScript
268 lines
8.6 KiB
JavaScript
/**
|
||
* Fonction schedulée : Envoie quotidienne d'un résumé des alertes non lues
|
||
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||
*/
|
||
|
||
const admin = require('firebase-admin');
|
||
const logger = require('firebase-functions/logger');
|
||
const nodemailer = require('nodemailer');
|
||
const { getSmtpConfig } = require('./utils/emailConfig');
|
||
|
||
/**
|
||
* Fonction principale : envoie le digest quotidien
|
||
*/
|
||
async function sendDailyDigest() {
|
||
const db = admin.firestore();
|
||
|
||
logger.info('[sendDailyDigest] ===== DÉBUT ENVOI DIGEST QUOTIDIEN =====');
|
||
|
||
try {
|
||
// 1. Récupérer tous les utilisateurs avec email activé
|
||
const usersSnapshot = await db.collection('users').get();
|
||
const eligibleUsers = [];
|
||
|
||
usersSnapshot.forEach((doc) => {
|
||
const user = doc.data();
|
||
const prefs = user.notificationPreferences || {};
|
||
|
||
// Vérifier si l'utilisateur a activé les emails
|
||
if (prefs.emailEnabled !== false && user.email) {
|
||
eligibleUsers.push({
|
||
uid: doc.id,
|
||
email: user.email,
|
||
firstName: user.firstName || 'Utilisateur',
|
||
lastName: user.lastName || '',
|
||
});
|
||
}
|
||
});
|
||
|
||
logger.info(`[sendDailyDigest] ${eligibleUsers.length} utilisateurs éligibles`);
|
||
|
||
// 2. Pour chaque utilisateur, récupérer ses alertes non lues des dernières 24h
|
||
const now = admin.firestore.Timestamp.now();
|
||
const yesterday = admin.firestore.Timestamp.fromMillis(now.toMillis() - 24 * 60 * 60 * 1000);
|
||
|
||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||
let emailsSent = 0;
|
||
|
||
for (const user of eligibleUsers) {
|
||
try {
|
||
// Récupérer les alertes non lues de l'utilisateur créées dans les dernières 24h
|
||
const alertsSnapshot = await db.collection('alerts')
|
||
.where('assignedTo', 'array-contains', user.uid)
|
||
.where('isRead', '==', false)
|
||
.where('createdAt', '>=', yesterday)
|
||
.orderBy('createdAt', 'desc')
|
||
.get();
|
||
|
||
if (alertsSnapshot.empty) {
|
||
continue; // Pas d'alertes non lues pour cet utilisateur
|
||
}
|
||
|
||
const alerts = [];
|
||
alertsSnapshot.forEach((doc) => {
|
||
alerts.push({ id: doc.id, ...doc.data() });
|
||
});
|
||
|
||
logger.info(`[sendDailyDigest] ${user.email}: ${alerts.length} alertes non lues`);
|
||
|
||
// 3. Envoyer l'email de digest
|
||
const sent = await sendDigestEmail(transporter, user, alerts);
|
||
if (sent) {
|
||
emailsSent++;
|
||
}
|
||
} catch (error) {
|
||
logger.error(`[sendDailyDigest] Erreur pour ${user.email}:`, error);
|
||
}
|
||
}
|
||
|
||
logger.info(`[sendDailyDigest] ✓ ${emailsSent}/${eligibleUsers.length} emails envoyés`);
|
||
logger.info('[sendDailyDigest] ===== FIN DIGEST QUOTIDIEN =====');
|
||
|
||
return { success: true, emailsSent };
|
||
} catch (error) {
|
||
logger.error('[sendDailyDigest] Erreur globale:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Envoie l'email de digest à un utilisateur
|
||
*/
|
||
async function sendDigestEmail(transporter, user, alerts) {
|
||
try {
|
||
// Grouper les alertes par sévérité
|
||
const criticalAlerts = alerts.filter(a => a.severity === 'CRITICAL');
|
||
const warningAlerts = alerts.filter(a => a.severity === 'WARNING');
|
||
const infoAlerts = alerts.filter(a => a.severity === 'INFO');
|
||
|
||
// Construire le HTML
|
||
const html = buildDigestHtml(user, {
|
||
critical: criticalAlerts,
|
||
warning: warningAlerts,
|
||
info: infoAlerts,
|
||
});
|
||
|
||
// Envoyer l'email
|
||
await transporter.sendMail({
|
||
from: `"EM2RP Notifications" <${process.env.SMTP_USER}>`,
|
||
to: user.email,
|
||
subject: `📬 ${alerts.length} nouvelle(s) alerte(s) EM2RP`,
|
||
html,
|
||
});
|
||
|
||
logger.info(`[sendDigestEmail] ✓ Email envoyé à ${user.email}`);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error(`[sendDigestEmail] Erreur pour ${user.email}:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Construit le HTML du digest
|
||
*/
|
||
function buildDigestHtml(user, alertsByType) {
|
||
const totalAlerts = alertsByType.critical.length + alertsByType.warning.length + alertsByType.info.length;
|
||
|
||
let alertsHtml = '';
|
||
|
||
// Alertes critiques
|
||
if (alertsByType.critical.length > 0) {
|
||
alertsHtml += `
|
||
<div style="margin-bottom: 24px;">
|
||
<h3 style="color: #dc2626; margin: 0 0 12px 0;">
|
||
🔴 Alertes critiques (${alertsByType.critical.length})
|
||
</h3>
|
||
${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Alertes warning
|
||
if (alertsByType.warning.length > 0) {
|
||
alertsHtml += `
|
||
<div style="margin-bottom: 24px;">
|
||
<h3 style="color: #f59e0b; margin: 0 0 12px 0;">
|
||
⚠️ Avertissements (${alertsByType.warning.length})
|
||
</h3>
|
||
${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Alertes info
|
||
if (alertsByType.info.length > 0) {
|
||
alertsHtml += `
|
||
<div style="margin-bottom: 24px;">
|
||
<h3 style="color: #3b82f6; margin: 0 0 12px 0;">
|
||
ℹ️ Informations (${alertsByType.info.length})
|
||
</h3>
|
||
${alertsByType.info.map(alert => formatAlertItem(alert)).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
</head>
|
||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||
<!-- En-tête -->
|
||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px; border-radius: 12px 12px 0 0; text-align: center;">
|
||
<h1 style="color: white; margin: 0; font-size: 28px;">📬 Résumé quotidien</h1>
|
||
<p style="color: rgba(255,255,255,0.9); margin: 8px 0 0 0; font-size: 16px;">
|
||
Bonjour ${user.firstName},
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Contenu -->
|
||
<div style="background-color: white; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px 0;">
|
||
Vous avez <strong>${totalAlerts} nouvelle(s) alerte(s)</strong> dans les dernières 24 heures.
|
||
</p>
|
||
|
||
${alertsHtml}
|
||
|
||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; text-align: center;">
|
||
<a href="https://app.em2event.fr/#/alerts"
|
||
style="display: inline-block; background-color: #667eea; color: white; padding: 12px 32px; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||
Voir toutes les alertes
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pied de page -->
|
||
<div style="text-align: center; padding: 24px; color: #6b7280; font-size: 14px;">
|
||
<p style="margin: 0 0 8px 0;">EM2RP - Gestion d'événements</p>
|
||
<p style="margin: 0;">
|
||
<a href="https://app.em2event.fr/#/settings" style="color: #667eea; text-decoration: none;">
|
||
Gérer mes préférences de notification
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Formate un item d'alerte pour l'email
|
||
*/
|
||
function formatAlertItem(alert) {
|
||
const date = alert.createdAt?.toDate ?
|
||
new Date(alert.createdAt.toDate()).toLocaleString('fr-FR', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}) :
|
||
'Date inconnue';
|
||
|
||
// Type d'alerte en français
|
||
const typeLabels = {
|
||
'EQUIPMENT_MISSING': 'Équipement manquant',
|
||
'LOST': 'Équipement perdu',
|
||
'DAMAGED': 'Équipement endommagé',
|
||
'QUANTITY_MISMATCH': 'Écart de quantité',
|
||
'EVENT_CREATED': 'Événement créé',
|
||
'EVENT_MODIFIED': 'Événement modifié',
|
||
'WORKFORCE_ADDED': 'Ajout à la workforce',
|
||
};
|
||
|
||
const typeLabel = typeLabels[alert.type] || alert.type;
|
||
|
||
return `
|
||
<div style="background-color: #f9fafb; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid ${getSeverityColor(alert.severity)};">
|
||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||
<strong style="color: #111827; font-size: 15px;">${typeLabel}</strong>
|
||
<span style="color: #6b7280; font-size: 13px;">${date}</span>
|
||
</div>
|
||
<p style="color: #4b5563; margin: 0; font-size: 14px; line-height: 1.5;">
|
||
${alert.message || 'Aucun message'}
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Retourne la couleur selon la sévérité
|
||
*/
|
||
function getSeverityColor(severity) {
|
||
switch (severity) {
|
||
case 'CRITICAL': return '#dc2626';
|
||
case 'WARNING': return '#f59e0b';
|
||
case 'INFO': return '#3b82f6';
|
||
default: return '#6b7280';
|
||
}
|
||
}
|
||
|
||
module.exports = { sendDailyDigest };
|
||
|