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:
4
em2rp/functions/.gitignore
vendored
4
em2rp/functions/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
*.local
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
|
||||
267
em2rp/functions/createAlert.js
Normal file
267
em2rp/functions/createAlert.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const {onRequest} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require('./utils/emailTemplates');
|
||||
const auth = require('./utils/auth');
|
||||
|
||||
// Configuration CORS
|
||||
const setCorsHeaders = (res, req) => {
|
||||
// Utiliser l'origin de la requête pour permettre les credentials
|
||||
const origin = req.headers.origin || '*';
|
||||
|
||||
res.set('Access-Control-Allow-Origin', origin);
|
||||
|
||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||
if (origin !== '*') {
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||
res.set('Access-Control-Max-Age', '3600');
|
||||
};
|
||||
|
||||
const withCors = (handler) => {
|
||||
return async (req, res) => {
|
||||
setCorsHeaders(res, req);
|
||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).send('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (error) {
|
||||
logger.error("Unhandled error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: error.message});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Crée une alerte et envoie les notifications
|
||||
* Gère tout le processus côté backend de A à Z
|
||||
*/
|
||||
exports.createAlert = onRequest({cors: false, invoker: 'public'}, withCors(async (req, res) => {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const data = req.body.data || req.body;
|
||||
|
||||
|
||||
const {
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
equipmentId,
|
||||
eventId,
|
||||
actionUrl,
|
||||
metadata,
|
||||
} = data;
|
||||
|
||||
// Validation des données
|
||||
if (!type || !severity || !message) {
|
||||
res.status(400).json({error: 'type, severity et message sont requis'});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Déterminer les utilisateurs à notifier
|
||||
const userIds = await determineTargetUsers(type, severity, eventId);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
res.status(400).json({error: 'Aucun utilisateur à notifier'});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Créer l'alerte dans Firestore
|
||||
const alertRef = admin.firestore().collection('alerts').doc();
|
||||
const alertData = {
|
||||
id: alertRef.id,
|
||||
type,
|
||||
severity,
|
||||
title: title || getAlertTitle(type),
|
||||
message,
|
||||
equipmentId: equipmentId || null,
|
||||
eventId: eventId || null,
|
||||
actionUrl: actionUrl || null,
|
||||
metadata: metadata || {},
|
||||
assignedTo: userIds,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
createdBy: decodedToken.uid,
|
||||
isRead: false,
|
||||
emailSent: false,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
await alertRef.set(alertData);
|
||||
|
||||
// 3. Envoyer les emails si alerte critique
|
||||
let emailResults = {};
|
||||
if (severity === 'CRITICAL') {
|
||||
emailResults = await sendAlertEmails(alertRef.id, alertData, userIds);
|
||||
|
||||
// Mettre à jour le statut d'envoi
|
||||
await alertRef.update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
emailResults,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
alertId: alertRef.id,
|
||||
usersNotified: userIds.length,
|
||||
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[createAlert] Erreur:', error);
|
||||
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||
*/
|
||||
async function determineTargetUsers(alertType, severity, eventId) {
|
||||
const db = admin.firestore();
|
||||
const targetUserIds = new Set();
|
||||
|
||||
// 1. Récupérer TOUS les utilisateurs pour déterminer lesquels sont admins
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
|
||||
allUsersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
if (user.role) {
|
||||
// Le rôle peut être une référence Firestore ou une string
|
||||
let rolePath = '';
|
||||
if (typeof user.role === 'string') {
|
||||
rolePath = user.role;
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
}
|
||||
|
||||
// Vérifier si c'est un admin (path = "roles/ADMIN")
|
||||
if (rolePath === 'roles/ADMIN' || rolePath === 'ADMIN') {
|
||||
targetUserIds.add(doc.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Si un événement est lié, ajouter tous les membres de la workforce
|
||||
if (eventId) {
|
||||
try {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
const workforce = event.workforce || [];
|
||||
|
||||
workforce.forEach((member) => {
|
||||
if (member.userId) {
|
||||
targetUserIds.add(member.userId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[determineTargetUsers] Événement ${eventId} introuvable`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[determineTargetUsers] Erreur récupération événement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(targetUserIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie les emails d'alerte à tous les utilisateurs
|
||||
*/
|
||||
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||
const results = {};
|
||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
||||
|
||||
// Envoyer les emails en parallèle (batch de 5)
|
||||
const batches = [];
|
||||
for (let i = 0; i < userIds.length; i += 5) {
|
||||
batches.push(userIds.slice(i, i + 5));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (userId) => {
|
||||
try {
|
||||
const sent = await sendSingleEmail(transporter, alertId, alertData, userId);
|
||||
results[userId] = sent;
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||
results[userId] = false;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email à un utilisateur spécifique
|
||||
*/
|
||||
async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
||||
const db = admin.firestore();
|
||||
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier la préférence pour ce type d'alerte
|
||||
if (!checkAlertPreference(alertData.type, prefs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Préparer les données du template
|
||||
const templateData = await prepareTemplateData(alertData, user);
|
||||
|
||||
// Rendre le template
|
||||
const html = await renderTemplate('alert-individual', templateData);
|
||||
|
||||
// Envoyer l'email
|
||||
await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alertData),
|
||||
html: html,
|
||||
text: alertData.message,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[sendSingleEmail] Erreur envoi à ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* Architecture backend sécurisée avec authentification et permissions
|
||||
*/
|
||||
|
||||
// Charger les variables d'environnement depuis .env
|
||||
require('dotenv').config();
|
||||
|
||||
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||
const { onDocumentCreated, onDocumentUpdated } = require("firebase-functions/v2/firestore");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const admin = require('firebase-admin');
|
||||
const { Storage } = require('@google-cloud/storage');
|
||||
@@ -12,14 +17,16 @@ const { Storage } = require('@google-cloud/storage');
|
||||
const auth = require('./utils/auth');
|
||||
const helpers = require('./utils/helpers');
|
||||
|
||||
// Initialisation
|
||||
admin.initializeApp();
|
||||
// Initialisation sécurisée
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp();
|
||||
}
|
||||
const storage = new Storage();
|
||||
const db = admin.firestore();
|
||||
|
||||
// Configuration commune pour toutes les fonctions HTTP
|
||||
const httpOptions = {
|
||||
cors: true,
|
||||
cors: false,
|
||||
invoker: 'public', // Permet les invocations non authentifiées (l'auth est gérée par notre token Firebase)
|
||||
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
||||
};
|
||||
@@ -28,10 +35,16 @@ const httpOptions = {
|
||||
// CORS Middleware
|
||||
// ============================================================================
|
||||
const setCorsHeaders = (res, req) => {
|
||||
// Permettre toutes les origines en développement/production
|
||||
const origin = req.headers.origin || req.headers.referer || '*';
|
||||
// Utiliser l'origin de la requête pour permettre les credentials
|
||||
const origin = req.headers.origin || '*';
|
||||
|
||||
res.set('Access-Control-Allow-Origin', origin);
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
||||
if (origin !== '*') {
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
|
||||
res.set('Access-Control-Max-Age', '3600');
|
||||
@@ -43,7 +56,7 @@ const withCors = (handler) => {
|
||||
// Définir les headers CORS pour toutes les requêtes
|
||||
setCorsHeaders(res, req);
|
||||
|
||||
// Gérer les requêtes preflight OPTIONS
|
||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).send('');
|
||||
return;
|
||||
@@ -1165,7 +1178,7 @@ exports.updateUser = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
|
||||
// Si mise à jour propre profil, limiter les champs modifiables
|
||||
if (isOwnProfile && !isAdminUser) {
|
||||
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl'];
|
||||
const allowedFields = ['firstName', 'lastName', 'phoneNumber', 'profilePhotoUrl', 'notificationPreferences'];
|
||||
const filteredData = {};
|
||||
|
||||
for (const field of allowedFields) {
|
||||
@@ -1890,79 +1903,7 @@ exports.deleteAlert = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Créer une nouvelle alerte
|
||||
*/
|
||||
exports.createAlert = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const hasAccess = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: 'Forbidden: Requires manage_equipment permission' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, title, message, severity, equipmentId } = req.body.data;
|
||||
|
||||
if (!type || !message) {
|
||||
res.status(400).json({ error: 'type and message are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si une alerte similaire existe déjà (éviter les doublons)
|
||||
const existingAlertsQuery = await db.collection('alerts')
|
||||
.where('type', '==', type)
|
||||
.where('isRead', '==', false)
|
||||
.get();
|
||||
|
||||
let alertExists = false;
|
||||
if (equipmentId) {
|
||||
// Pour les alertes liées à un équipement, vérifier aussi l'equipmentId
|
||||
alertExists = existingAlertsQuery.docs.some(doc =>
|
||||
doc.data().equipmentId === equipmentId
|
||||
);
|
||||
} else {
|
||||
// Pour les autres alertes, vérifier le message
|
||||
alertExists = existingAlertsQuery.docs.some(doc =>
|
||||
doc.data().message === message
|
||||
);
|
||||
}
|
||||
|
||||
if (alertExists) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Alert already exists',
|
||||
skipped: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer la nouvelle alerte
|
||||
const alertData = {
|
||||
type: type,
|
||||
title: title || 'Alerte',
|
||||
message: message,
|
||||
severity: severity || 'MEDIUM',
|
||||
isRead: false,
|
||||
createdAt: admin.firestore.Timestamp.now(),
|
||||
};
|
||||
|
||||
if (equipmentId) {
|
||||
alertData.equipmentId = equipmentId;
|
||||
}
|
||||
|
||||
const alertRef = await db.collection('alerts').add(alertData);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
alertId: alertRef.id
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error creating alert:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}));
|
||||
// createAlert est défini dans createAlert.js et importé à la fin du fichier
|
||||
|
||||
// ============================================================================
|
||||
// USERS - Read with permissions
|
||||
@@ -3442,3 +3383,176 @@ exports.completeMaintenance = onRequest(httpOptions, withCors(async (req, res) =
|
||||
}
|
||||
}));
|
||||
|
||||
// ==================== EMAIL FUNCTIONS ====================
|
||||
const {sendAlertEmail} = require('./sendAlertEmail');
|
||||
exports.sendAlertEmail = sendAlertEmail;
|
||||
|
||||
// ==================== ALERT FUNCTIONS ====================
|
||||
const {createAlert} = require('./createAlert');
|
||||
exports.createAlert = createAlert;
|
||||
|
||||
const {processEquipmentValidation} = require('./processEquipmentValidation');
|
||||
exports.processEquipmentValidation = processEquipmentValidation;
|
||||
|
||||
// ==================== SCHEDULED FUNCTIONS ====================
|
||||
const {sendDailyDigest} = require('./sendDailyDigest');
|
||||
|
||||
/**
|
||||
* Fonction schedulée : Envoie quotidien d'un digest des alertes non lues
|
||||
* S'exécute tous les jours à 8h00 (Europe/Paris)
|
||||
*/
|
||||
exports.sendDailyDigest = onSchedule({
|
||||
schedule: '0 8 * * *',
|
||||
timeZone: 'Europe/Paris',
|
||||
retryCount: 2,
|
||||
memory: '512MiB'
|
||||
}, async (context) => {
|
||||
logger.info('[Scheduler] Démarrage sendDailyDigest');
|
||||
try {
|
||||
await sendDailyDigest();
|
||||
logger.info('[Scheduler] sendDailyDigest terminé avec succès');
|
||||
} catch (error) {
|
||||
logger.error('[Scheduler] Erreur sendDailyDigest:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== FIRESTORE TRIGGERS ====================
|
||||
|
||||
/**
|
||||
* Trigger : Nouvel événement créé
|
||||
* Envoie une notification à tous les membres de la workforce
|
||||
*/
|
||||
exports.onEventCreated = onDocumentCreated('events/{eventId}', async (event) => {
|
||||
logger.info(`[onEventCreated] Événement créé: ${event.params.eventId}`);
|
||||
|
||||
try {
|
||||
const eventData = event.data.data();
|
||||
const eventId = event.params.eventId;
|
||||
|
||||
// Créer une alerte pour informer la workforce
|
||||
await db.collection('alerts').add({
|
||||
type: 'EVENT_CREATED',
|
||||
severity: 'INFO',
|
||||
message: `Nouvel événement créé : "${eventData.name}" le ${new Date(eventData.startDate?.toDate ? eventData.startDate.toDate() : eventData.startDate).toLocaleDateString('fr-FR')}`,
|
||||
eventId: eventId,
|
||||
eventName: eventData.name,
|
||||
eventDate: eventData.startDate,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
isRead: false,
|
||||
metadata: {
|
||||
eventId: eventId,
|
||||
eventName: eventData.name,
|
||||
eventDate: eventData.startDate,
|
||||
},
|
||||
assignedTo: [], // Sera rempli automatiquement par la fonction createAlert
|
||||
});
|
||||
|
||||
// Appeler createAlert via HTTP pour gérer l'envoi des emails
|
||||
const createAlertModule = require('./createAlert');
|
||||
// Note: On ne peut pas appeler directement la fonction HTTP, mais on peut créer l'alerte directement
|
||||
// L'envoi des emails sera géré par un trigger sur la collection alerts
|
||||
|
||||
logger.info(`[onEventCreated] Alerte créée pour événement ${eventId}`);
|
||||
} catch (error) {
|
||||
logger.error('[onEventCreated] Erreur:', error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Trigger : Événement modifié (workforce changée)
|
||||
* Envoie une notification aux nouveaux membres ajoutés à la workforce
|
||||
*/
|
||||
exports.onEventUpdated = onDocumentUpdated('events/{eventId}', async (event) => {
|
||||
const before = event.data.before.data();
|
||||
const after = event.data.after.data();
|
||||
const eventId = event.params.eventId;
|
||||
|
||||
try {
|
||||
// Vérifier si la workforce a changé
|
||||
const workforceBefore = before.workforce || [];
|
||||
const workforceAfter = after.workforce || [];
|
||||
|
||||
// Trouver les nouveaux membres ajoutés
|
||||
const newMembers = workforceAfter.filter(afterMember => {
|
||||
return !workforceBefore.some(beforeMember =>
|
||||
beforeMember.userId === afterMember.userId
|
||||
);
|
||||
});
|
||||
|
||||
if (newMembers.length > 0) {
|
||||
logger.info(`[onEventUpdated] ${newMembers.length} nouveaux membres ajoutés à ${eventId}`);
|
||||
|
||||
// Créer une alerte pour chaque nouveau membre
|
||||
for (const member of newMembers) {
|
||||
await db.collection('alerts').add({
|
||||
type: 'WORKFORCE_ADDED',
|
||||
severity: 'INFO',
|
||||
message: `Vous avez été ajouté(e) à l'événement "${after.name}" le ${new Date(after.startDate?.toDate ? after.startDate.toDate() : after.startDate).toLocaleDateString('fr-FR')}`,
|
||||
eventId: eventId,
|
||||
eventName: after.name,
|
||||
eventDate: after.startDate,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
isRead: false,
|
||||
metadata: {
|
||||
eventId: eventId,
|
||||
eventName: after.name,
|
||||
eventDate: after.startDate,
|
||||
},
|
||||
assignedTo: [member.userId], // Alerte ciblée uniquement pour ce membre
|
||||
});
|
||||
|
||||
logger.info(`[onEventUpdated] Alerte créée pour ${member.userId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[onEventUpdated] Erreur:', error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Trigger : Nouvelle alerte créée
|
||||
* Envoie un email immédiat si l'alerte est critique
|
||||
*/
|
||||
exports.onAlertCreated = onDocumentCreated('alerts/{alertId}', async (event) => {
|
||||
const alertId = event.params.alertId;
|
||||
const alertData = event.data.data();
|
||||
|
||||
logger.info(`[onAlertCreated] Nouvelle alerte: ${alertId} (${alertData.severity})`);
|
||||
|
||||
try {
|
||||
// Si l'alerte est critique et pas encore envoyée par email
|
||||
if (alertData.severity === 'CRITICAL' && !alertData.emailSent) {
|
||||
const sendEmailModule = require('./sendAlertEmail');
|
||||
|
||||
// Les destinataires sont déjà dans assignedTo
|
||||
const userIds = alertData.assignedTo || [];
|
||||
|
||||
if (userIds.length > 0) {
|
||||
logger.info(`[onAlertCreated] Envoi email immédiat à ${userIds.length} utilisateurs`);
|
||||
|
||||
// Note: Dans un trigger Firestore, on ne peut pas facilement appeler une fonction HTTP
|
||||
// Il faudrait soit:
|
||||
// 1. Dupliquer la logique d'envoi d'email ici
|
||||
// 2. Utiliser une file d'attente (Pub/Sub ou Tasks)
|
||||
// 3. Marquer l'alerte pour qu'elle soit traitée par un scheduler
|
||||
|
||||
// Pour l'instant, on marque l'alerte comme devant être envoyée
|
||||
await db.collection('alerts').doc(alertId).update({
|
||||
pendingEmailSend: true,
|
||||
});
|
||||
|
||||
logger.info(`[onAlertCreated] Alerte marquée pour envoi email`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[onAlertCreated] Erreur:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ALERT TRIGGERS ====================
|
||||
// Temporairement désactivé - erreur de permissions Eventarc
|
||||
// const {onAlertCreated} = require('./onAlertCreated');
|
||||
// exports.onAlertCreated = onAlertCreated;
|
||||
|
||||
|
||||
|
||||
113
em2rp/functions/migrate_email_prefs.js
Normal file
113
em2rp/functions/migrate_email_prefs.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Script de migration : Active les emails pour tous les utilisateurs existants
|
||||
* À exécuter une seule fois après le déploiement
|
||||
*/
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
|
||||
// AJOUTER CECI : Charger le fichier de clé
|
||||
const serviceAccount = require('./serviceAccountKey.json');
|
||||
|
||||
// Initialiser Firebase Admin avec les credentials explicites
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount), // <-- Utiliser la clé ici
|
||||
projectId: 'em2rp-951dc',
|
||||
});
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
/**
|
||||
* Active les notifications par email pour tous les utilisateurs existants
|
||||
*/
|
||||
async function migrateEmailPreferences() {
|
||||
console.log('=== DÉBUT MIGRATION EMAIL PREFERENCES ===\n');
|
||||
|
||||
try {
|
||||
// 1. Récupérer tous les utilisateurs
|
||||
const usersSnapshot = await db.collection('users').get();
|
||||
console.log(`✓ ${usersSnapshot.size} utilisateurs trouvés\n`);
|
||||
|
||||
// 2. Préparer les updates
|
||||
const updates = [];
|
||||
let alreadyEnabled = 0;
|
||||
let toUpdate = 0;
|
||||
|
||||
usersSnapshot.forEach((doc) => {
|
||||
const user = doc.data();
|
||||
const prefs = user.notificationPreferences || {};
|
||||
|
||||
// Vérifier si déjà activé
|
||||
if (prefs.emailEnabled === true) {
|
||||
alreadyEnabled++;
|
||||
console.log(` ○ ${user.email || doc.id}: emails déjà activés`);
|
||||
} else {
|
||||
toUpdate++;
|
||||
console.log(` ✓ ${user.email || doc.id}: activation des emails`);
|
||||
|
||||
updates.push({
|
||||
ref: doc.ref,
|
||||
data: {
|
||||
'notificationPreferences.emailEnabled': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n--- RÉSUMÉ ---`);
|
||||
console.log(` Total utilisateurs: ${usersSnapshot.size}`);
|
||||
console.log(` Déjà activés: ${alreadyEnabled}`);
|
||||
console.log(` À mettre à jour: ${toUpdate}`);
|
||||
|
||||
// 3. Appliquer les mises à jour par batches de 500 (limite Firestore)
|
||||
if (updates.length > 0) {
|
||||
console.log(`\nApplication des mises à jour...`);
|
||||
|
||||
const batchSize = 500;
|
||||
for (let i = 0; i < updates.length; i += batchSize) {
|
||||
const batch = db.batch();
|
||||
const currentBatch = updates.slice(i, i + batchSize);
|
||||
|
||||
currentBatch.forEach((update) => {
|
||||
batch.update(update.ref, update.data);
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
console.log(` ✓ Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(updates.length / batchSize)} appliqué`);
|
||||
}
|
||||
|
||||
console.log(`\n✓ Migration terminée avec succès !`);
|
||||
console.log(` ${toUpdate} utilisateurs mis à jour\n`);
|
||||
} else {
|
||||
console.log(`\n✓ Aucune mise à jour nécessaire\n`);
|
||||
}
|
||||
|
||||
console.log('=== FIN MIGRATION ===');
|
||||
return {
|
||||
success: true,
|
||||
total: usersSnapshot.size,
|
||||
alreadyEnabled,
|
||||
updated: toUpdate,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ ERREUR MIGRATION:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Exécuter la migration si appelé directement
|
||||
if (require.main === module) {
|
||||
migrateEmailPreferences()
|
||||
.then((result) => {
|
||||
console.log('\n✓ Migration réussie:', result);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Migration échouée:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { migrateEmailPreferences };
|
||||
|
||||
392
em2rp/functions/package-lock.json
generated
392
em2rp/functions/package-lock.json
generated
@@ -6,9 +6,14 @@
|
||||
"": {
|
||||
"name": "functions",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^6.0.1"
|
||||
"firebase-functions": "^7.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
@@ -706,7 +711,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
|
||||
"integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"arrify": "^2.0.0",
|
||||
"extend": "^3.0.2"
|
||||
@@ -720,7 +724,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz",
|
||||
"integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -730,17 +733,15 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz",
|
||||
"integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/storage": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz",
|
||||
"integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==",
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz",
|
||||
"integrity": "sha512-r3ZwDMiz4nwW6R922Z1pwpePxyRwE5GdevYX63hRmAQUkUQJcBH/79EnQPDv5cOv1mFBgevdNWQfi3tie3dHrQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@google-cloud/paginator": "^5.0.0",
|
||||
"@google-cloud/projectify": "^4.0.0",
|
||||
@@ -767,7 +768,6 @@
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -885,9 +885,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1460,7 +1460,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -1524,8 +1523,7 @@
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
||||
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
@@ -1674,7 +1672,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz",
|
||||
"integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/caseless": "*",
|
||||
"@types/node": "*",
|
||||
@@ -1714,8 +1711,7 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
@@ -1746,7 +1742,6 @@
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
@@ -1796,7 +1791,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
@@ -1905,7 +1899,6 @@
|
||||
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
|
||||
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1915,7 +1908,6 @@
|
||||
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
|
||||
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"retry": "0.13.1"
|
||||
}
|
||||
@@ -2094,37 +2086,35 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz",
|
||||
"integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
@@ -2140,16 +2130,45 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2631,6 +2650,18 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2650,7 +2681,6 @@
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
@@ -2714,11 +2744,34 @@
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/envdot": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/envdot/-/envdot-0.0.3.tgz",
|
||||
"integrity": "sha512-vaJ+ac5s9X/cz1hPA7D/JLSbkloEZVozkzx2n83xcCUxuaQf/sHwjFIUiJfBwSoEU3crecRT7OftKCizhe9dwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"envdot": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/envdot/node_modules/dotenv": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-7.0.0.tgz",
|
||||
"integrity": "sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2996,7 +3049,6 @@
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -3052,39 +3104,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -3116,8 +3168,7 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/farmhash-modern": {
|
||||
"version": "1.1.0",
|
||||
@@ -3160,7 +3211,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"strnum": "^1.1.1"
|
||||
},
|
||||
@@ -3302,9 +3352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/firebase-functions": {
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.3.2.tgz",
|
||||
"integrity": "sha512-FC3A1/nhqt1ZzxRnj5HZLScQaozAcFSD/vSR8khqSoFNOfxuXgwJS6ZABTB7+v+iMD5z6Mmxw6OfqITUBuI7OQ==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
|
||||
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -3318,10 +3368,20 @@
|
||||
"firebase-functions": "lib/bin/firebase-functions.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.10.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apollo/server": "^5.2.0",
|
||||
"@as-integrations/express4": "^1.1.2",
|
||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@apollo/server": {
|
||||
"optional": true
|
||||
},
|
||||
"@as-integrations/express4": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/firebase-functions-test": {
|
||||
@@ -3387,15 +3447,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz",
|
||||
"integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==",
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
|
||||
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
@@ -3464,7 +3524,6 @@
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
@@ -3485,7 +3544,6 @@
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -3495,7 +3553,6 @@
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
@@ -3641,7 +3698,6 @@
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
@@ -3697,7 +3753,6 @@
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -3733,7 +3788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
@@ -3742,6 +3796,27 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -3805,8 +3880,7 @@
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
@@ -3842,7 +3916,6 @@
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "2",
|
||||
"agent-base": "6",
|
||||
@@ -3857,7 +3930,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@@ -3870,7 +3942,6 @@
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
@@ -4075,7 +4146,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4789,9 +4859,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4819,7 +4889,6 @@
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
@@ -4899,12 +4968,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"jwa": "^1.4.2",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -4925,7 +4994,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
@@ -4950,13 +5018,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -5246,7 +5313,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
@@ -5298,6 +5364,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5320,12 +5395,17 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
@@ -5342,9 +5422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
@@ -5364,6 +5444,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -5434,7 +5523,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@@ -5478,7 +5566,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
@@ -5835,12 +5922,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -5880,20 +5967,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -5906,7 +6022,6 @@
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -5995,7 +6110,6 @@
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -6005,7 +6119,6 @@
|
||||
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz",
|
||||
"integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/request": "^2.48.8",
|
||||
"extend": "^3.0.2",
|
||||
@@ -6307,7 +6420,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6368,7 +6480,6 @@
|
||||
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
|
||||
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"stubs": "^3.0.0"
|
||||
}
|
||||
@@ -6377,15 +6488,13 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -6475,15 +6584,13 @@
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stubs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
|
||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
@@ -6516,7 +6623,6 @@
|
||||
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
|
||||
"integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
@@ -6533,7 +6639,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@@ -6546,7 +6651,6 @@
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
@@ -6564,7 +6668,6 @@
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -6624,8 +6727,7 @@
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-deepmerge": {
|
||||
"version": "2.0.7",
|
||||
@@ -6689,6 +6791,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -6749,8 +6864,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
@@ -6812,8 +6926,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.4",
|
||||
@@ -6843,7 +6956,6 @@
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
@@ -6875,6 +6987,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -6897,7 +7015,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
@@ -6964,7 +7081,6 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^6.0.1"
|
||||
"firebase-functions": "^7.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
|
||||
415
em2rp/functions/processEquipmentValidation.js
Normal file
415
em2rp/functions/processEquipmentValidation.js
Normal file
@@ -0,0 +1,415 @@
|
||||
const {onCall} = require('firebase-functions/v2/https');
|
||||
const admin = require('firebase-admin');
|
||||
const logger = require('firebase-functions/logger');
|
||||
const nodemailer = require('nodemailer');
|
||||
const {getSmtpConfig, EMAIL_CONFIG} = require('./utils/emailConfig');
|
||||
/**
|
||||
* Traite la validation du matériel d'un événement
|
||||
* Appelée par le client lors du chargement/déchargement
|
||||
* Crée automatiquement les alertes nécessaires
|
||||
*/
|
||||
exports.processEquipmentValidation = onCall({cors: true}, async (request) => {
|
||||
try {
|
||||
// L'authentification est automatique avec onCall
|
||||
const {auth, data} = request;
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('L\'utilisateur doit être authentifié');
|
||||
}
|
||||
|
||||
const {
|
||||
eventId,
|
||||
equipmentList, // [{equipmentId, status, quantity, etc.}]
|
||||
validationType, // 'LOADING', 'UNLOADING', 'CHECK_OUT', 'CHECK_IN'
|
||||
} = data;
|
||||
|
||||
// Validation
|
||||
if (!eventId || !equipmentList || !validationType) {
|
||||
throw new Error('eventId, equipmentList et validationType sont requis');
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
const alerts = [];
|
||||
|
||||
// 1. Récupérer les détails de l'événement
|
||||
const eventRef = db.collection('events').doc(eventId);
|
||||
const eventDoc = await eventRef.get();
|
||||
|
||||
if (!eventDoc.exists) {
|
||||
throw new Error('Événement introuvable');
|
||||
}
|
||||
|
||||
const event = eventDoc.data();
|
||||
const eventName = event.Name || event.name || 'Événement inconnu';
|
||||
const eventDate = formatEventDate(event);
|
||||
|
||||
// 2. Analyser les équipements et détecter les problèmes
|
||||
for (const equipment of equipmentList) {
|
||||
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||
|
||||
// Cas 1: Équipement PERDU
|
||||
if (status === 'LOST') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'LOST',
|
||||
severity: 'CRITICAL',
|
||||
title: 'Équipement perdu',
|
||||
message: `Équipement "${equipment.name || equipmentId}" perdu lors de l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 2: Équipement MANQUANT
|
||||
if (status === 'MISSING') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'EQUIPMENT_MISSING',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement manquant',
|
||||
message: `Équipement "${equipment.name || equipmentId}" manquant pour l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 3: Quantité incorrecte
|
||||
if (expectedQuantity && quantity !== expectedQuantity) {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'QUANTITY_MISMATCH',
|
||||
severity: 'INFO',
|
||||
title: 'Quantité incorrecte',
|
||||
message: `Quantité incorrecte pour "${equipment.name || equipmentId}": ${quantity} au lieu de ${expectedQuantity} attendus`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
expected: expectedQuantity,
|
||||
actual: quantity,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
|
||||
// Cas 4: Équipement endommagé
|
||||
if (status === 'DAMAGED') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'DAMAGED',
|
||||
severity: 'WARNING',
|
||||
title: 'Équipement endommagé',
|
||||
message: `Équipement "${equipment.name || equipmentId}" endommagé durant l'événement "${eventName}" (${eventDate})`,
|
||||
equipmentId,
|
||||
eventId,
|
||||
eventName,
|
||||
eventDate,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
validationType,
|
||||
equipment,
|
||||
},
|
||||
});
|
||||
alerts.push(alertData);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mettre à jour les équipements de l'événement
|
||||
await eventRef.update({
|
||||
equipment: equipmentList,
|
||||
lastValidation: {
|
||||
type: validationType,
|
||||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||
by: auth.uid,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Envoyer les notifications pour les alertes critiques
|
||||
const criticalAlerts = alerts.filter((a) => a.severity === 'CRITICAL');
|
||||
if (criticalAlerts.length > 0) {
|
||||
for (const alert of criticalAlerts) {
|
||||
try {
|
||||
await sendAlertNotifications(alert, eventId);
|
||||
} catch (notificationError) {
|
||||
logger.error(`[processEquipmentValidation] Erreur notification alerte ${alert.id}:`, notificationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alertsCreated: alerts.length,
|
||||
criticalAlertsCount: criticalAlerts.length,
|
||||
alertIds: alerts.map((a) => a.id),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[processEquipmentValidation] Erreur:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée une alerte dans Firestore
|
||||
*/
|
||||
async function createAlertInFirestore(alertData) {
|
||||
const db = admin.firestore();
|
||||
const alertRef = db.collection('alerts').doc();
|
||||
|
||||
const fullAlertData = {
|
||||
id: alertRef.id,
|
||||
...alertData,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
isRead: false,
|
||||
status: 'ACTIVE',
|
||||
emailSent: false,
|
||||
assignedTo: [],
|
||||
};
|
||||
|
||||
await alertRef.set(fullAlertData);
|
||||
|
||||
return {...fullAlertData, id: alertRef.id};
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine les utilisateurs à notifier et envoie les notifications
|
||||
*/
|
||||
async function sendAlertNotifications(alert, eventId) {
|
||||
const db = admin.firestore();
|
||||
const targetUserIds = new Set();
|
||||
const usersWithPermission = new Set();
|
||||
|
||||
try {
|
||||
// 1. Récupérer TOUS les utilisateurs et leurs permissions
|
||||
const allUsersSnapshot = await db.collection('users').get();
|
||||
|
||||
// Créer un map pour stocker les références de rôles à récupérer
|
||||
const roleRefs = new Map();
|
||||
|
||||
for (const doc of allUsersSnapshot.docs) {
|
||||
const user = doc.data();
|
||||
|
||||
if (!user.role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extraire le chemin du rôle
|
||||
let rolePath = '';
|
||||
let roleId = '';
|
||||
|
||||
if (typeof user.role === 'string') {
|
||||
rolePath = user.role;
|
||||
roleId = user.role.split('/').pop();
|
||||
} else if (user.role.path) {
|
||||
rolePath = user.role.path;
|
||||
roleId = user.role.path.split('/').pop();
|
||||
} else if (user.role._path && user.role._path.segments) {
|
||||
rolePath = user.role._path.segments.join('/');
|
||||
roleId = user.role._path.segments[user.role._path.segments.length - 1];
|
||||
}
|
||||
|
||||
if (roleId && !roleRefs.has(roleId)) {
|
||||
roleRefs.set(roleId, {users: [], rolePath});
|
||||
}
|
||||
|
||||
if (roleId) {
|
||||
roleRefs.get(roleId).users.push(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Récupérer les permissions de chaque rôle unique
|
||||
for (const [roleId, {users, rolePath}] of roleRefs.entries()) {
|
||||
try {
|
||||
const roleDoc = await db.collection('roles').doc(roleId).get();
|
||||
|
||||
if (roleDoc.exists) {
|
||||
const roleData = roleDoc.data();
|
||||
const permissions = roleData.permissions || [];
|
||||
|
||||
// Vérifier si le rôle a la permission view_all_events
|
||||
if (permissions.includes('view_all_events')) {
|
||||
users.forEach((userId) => {
|
||||
usersWithPermission.add(userId);
|
||||
targetUserIds.add(userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertNotifications] Erreur récupération rôle ${roleId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ajouter la workforce de l'événement
|
||||
if (eventId) {
|
||||
const eventDoc = await db.collection('events').doc(eventId).get();
|
||||
|
||||
if (eventDoc.exists) {
|
||||
const event = eventDoc.data();
|
||||
const workforce = event.workforce || [];
|
||||
|
||||
workforce.forEach((member) => {
|
||||
// Extraire l'userId selon différentes structures possibles
|
||||
let userId = null;
|
||||
|
||||
if (typeof member === 'string') {
|
||||
userId = member;
|
||||
} else if (member.userId) {
|
||||
userId = member.userId;
|
||||
} else if (member.id) {
|
||||
userId = member.id;
|
||||
} else if (member.user) {
|
||||
if (typeof member.user === 'string') {
|
||||
userId = member.user;
|
||||
} else if (member.user.id) {
|
||||
userId = member.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
targetUserIds.add(userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userIds = Array.from(targetUserIds);
|
||||
|
||||
// 4. Mettre à jour l'alerte avec la liste des utilisateurs
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
assignedTo: userIds,
|
||||
});
|
||||
|
||||
// 5. Envoyer les emails si alerte critique
|
||||
if (alert.severity === 'CRITICAL') {
|
||||
await sendAlertEmails(alert, userIds);
|
||||
}
|
||||
|
||||
return userIds;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertNotifications] Erreur:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie les emails d'alerte
|
||||
*/
|
||||
async function sendAlertEmails(alert, userIds) {
|
||||
try {
|
||||
const {renderTemplate, getEmailSubject, prepareTemplateData} = require('./utils/emailTemplates');
|
||||
const db = admin.firestore();
|
||||
|
||||
// Vérifier que EMAIL_CONFIG est disponible
|
||||
if (!EMAIL_CONFIG || !EMAIL_CONFIG.from) {
|
||||
logger.error('[sendAlertEmails] EMAIL_CONFIG non configuré');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||
let successCount = 0;
|
||||
|
||||
// Envoyer les emails par lots de 5
|
||||
const batches = [];
|
||||
for (let i = 0; i < userIds.length; i += 5) {
|
||||
batches.push(userIds.slice(i, i + 5));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (userId) => {
|
||||
try {
|
||||
// Récupérer l'utilisateur
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = userDoc.data();
|
||||
|
||||
// Vérifier les préférences email
|
||||
const prefs = user.notificationPreferences || {};
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Préparer et envoyer l'email
|
||||
let html;
|
||||
try {
|
||||
const templateData = await prepareTemplateData(alert, user);
|
||||
html = await renderTemplate('alert-individual', templateData);
|
||||
} catch (templateError) {
|
||||
logger.error(`[sendAlertEmails] Erreur template pour ${userId}:`, templateError);
|
||||
html = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${alert.title || 'Nouvelle alerte'}</h2>
|
||||
<p>${alert.message}</p>
|
||||
<a href="${EMAIL_CONFIG.appUrl}/alerts">Voir l'alerte</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${EMAIL_CONFIG.from.name}" <${EMAIL_CONFIG.from.address}>`,
|
||||
to: user.email,
|
||||
replyTo: EMAIL_CONFIG.replyTo,
|
||||
subject: getEmailSubject(alert),
|
||||
html: html,
|
||||
text: alert.message,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[sendAlertEmails] Erreur email ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
successCount += results.filter((r) => r).length;
|
||||
}
|
||||
|
||||
// Mettre à jour l'alerte
|
||||
await db.collection('alerts').doc(alert.id).update({
|
||||
emailSent: true,
|
||||
emailSentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
emailsSentCount: successCount,
|
||||
});
|
||||
|
||||
return successCount;
|
||||
} catch (error) {
|
||||
logger.error('[sendAlertEmails] Erreur globale:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la date d'un événement
|
||||
*/
|
||||
function formatEventDate(event) {
|
||||
if (event.startDate) {
|
||||
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
|
||||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
|
||||
}
|
||||
return 'Date inconnue';
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
267
em2rp/functions/sendDailyDigest.js
Normal file
267
em2rp/functions/sendDailyDigest.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
107
em2rp/functions/templates/alert-digest.html
Normal file
107
em2rp/functions/templates/alert-digest.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<div style="margin-bottom: 30px;">
|
||||
<!-- En-tête du digest -->
|
||||
<div style="margin-bottom: 25px;">
|
||||
<h2 style="color: #111827; margin: 0 0 10px 0; font-size: 24px; font-weight: 600;">
|
||||
📬 Votre résumé quotidien
|
||||
</h2>
|
||||
<p style="color: #6b7280; margin: 0; font-size: 14px;">
|
||||
{{digestDate}} • {{alertCount}} nouvelle(s) alerte(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message d'introduction -->
|
||||
<p style="color: #374151; margin: 0 0 30px 0; font-size: 16px; line-height: 1.6;">
|
||||
Bonjour <strong>{{userName}}</strong>,<br>
|
||||
Voici le récapitulatif de vos alertes des dernières 24 heures.
|
||||
</p>
|
||||
|
||||
<!-- Liste des alertes -->
|
||||
{{#each alerts}}
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid {{#if this.isCritical}}#DC2626{{else}}#3B82F6{{/if}}; padding: 20px; margin-bottom: 15px; border-radius: 4px;">
|
||||
<!-- Badge type -->
|
||||
<div style="display: inline-block; padding: 4px 12px; border-radius: 12px; margin-bottom: 10px; background-color: {{#if this.isCritical}}#FEE2E2{{else}}#DBEAFE{{/if}}; color: {{#if this.isCritical}}#991B1B{{else}}#1E40AF{{/if}}; font-size: 11px; font-weight: 600; text-transform: uppercase;">
|
||||
{{this.typeLabel}}
|
||||
</div>
|
||||
|
||||
<!-- Titre de l'alerte -->
|
||||
<h3 style="color: #111827; margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">
|
||||
{{this.title}}
|
||||
</h3>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="color: #4b5563; margin: 0 0 12px 0; font-size: 14px; line-height: 1.5;">
|
||||
{{this.message}}
|
||||
</p>
|
||||
|
||||
<!-- Contexte -->
|
||||
{{#if this.context}}
|
||||
<p style="color: #6b7280; margin: 0; font-size: 13px;">
|
||||
<strong>Contexte :</strong> {{this.context}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p style="color: #9ca3af; margin: 8px 0 0 0; font-size: 12px;">
|
||||
🕐 {{this.timestamp}}
|
||||
</p>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<!-- Aucune alerte -->
|
||||
{{#unless alerts}}
|
||||
<div style="background-color: #f0fdf4; border: 1px solid #86efac; padding: 20px; margin-bottom: 20px; border-radius: 8px; text-align: center;">
|
||||
<p style="color: #166534; margin: 0; font-size: 16px;">
|
||||
✅ <strong>Aucune alerte aujourd'hui</strong><br>
|
||||
<span style="font-size: 14px; color: #15803d;">Tout est en ordre !</span>
|
||||
</p>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
<!-- Bouton d'action principal -->
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
|
||||
<tr>
|
||||
<td style="border-radius: 6px; background: #3B82F6;">
|
||||
<a href="{{appUrl}}/alerts" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||
Voir toutes mes alertes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
{{#if stats}}
|
||||
<div style="margin-top: 30px; padding: 20px; background-color: #fef3c7; border-radius: 8px;">
|
||||
<h3 style="color: #92400e; margin: 0 0 15px 0; font-size: 16px; font-weight: 600;">
|
||||
📊 Vos statistiques
|
||||
</h3>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||
<strong>Alertes non lues :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||
{{stats.unreadCount}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f;">
|
||||
<strong>Événements en cours :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #78350f; text-align: right;">
|
||||
{{stats.activeEvents}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Note de bas de page -->
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||
💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
81
em2rp/functions/templates/alert-individual.html
Normal file
81
em2rp/functions/templates/alert-individual.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<div style="margin-bottom: 30px;">
|
||||
<!-- Badge de sévérité -->
|
||||
<div style="display: inline-block; padding: 8px 16px; border-radius: 20px; margin-bottom: 20px; {{#if isCritical}}background-color: #FEE2E2; color: #991B1B;{{else}}background-color: #FEF3C7; color: #92400E;{{/if}}">
|
||||
<strong style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
{{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<h2 style="color: #111827; margin: 0 0 20px 0; font-size: 24px; font-weight: 600;">
|
||||
{{alertTitle}}
|
||||
</h2>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="color: #374151; margin: 0 0 25px 0; font-size: 16px; line-height: 1.6;">
|
||||
{{alertMessage}}
|
||||
</p>
|
||||
|
||||
<!-- Détails de l'alerte -->
|
||||
{{#if alertDetails}}
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #3B82F6; padding: 16px; margin-bottom: 25px; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Détails :</strong><br>
|
||||
{{alertDetails}}
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Informations contextuelles -->
|
||||
{{#if eventName}}
|
||||
<table style="width: 100%; margin-bottom: 25px; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Événement :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{eventName}}
|
||||
</td>
|
||||
</tr>
|
||||
{{#if eventDate}}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Date :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{eventDate}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if equipmentName}}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #6b7280;">
|
||||
<strong style="color: #374151;">Équipement :</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; font-size: 14px; color: #374151;">
|
||||
{{equipmentName}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
<!-- Bouton d'action -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius: 6px; {{#if isCritical}}background: #DC2626;{{else}}background: #3B82F6;{{/if}}">
|
||||
<a href="{{actionUrl}}" target="_blank" style="display: inline-block; padding: 14px 30px; font-size: 16px; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 6px;">
|
||||
{{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Note de bas de page -->
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 13px; color: #6b7280; line-height: 1.5;">
|
||||
💡 <strong>Astuce :</strong> Vous pouvez gérer vos préférences de notifications dans votre <a href="{{appUrl}}/my_account" style="color: #3B82F6; text-decoration: none;">espace personnel</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
65
em2rp/functions/templates/base-template.html
Normal file
65
em2rp/functions/templates/base-template.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{subject}}</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||
body { margin: 0; padding: 0; width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.container { width: 100% !important; }
|
||||
.content { padding: 20px !important; }
|
||||
.button { width: 100% !important; display: block !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f3f4f6;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f3f4f6;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<!-- Container -->
|
||||
<table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="background: linear-gradient(135deg, #1E3A8A 0%, #3B82F6 100%); padding: 30px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
|
||||
EM2 Events
|
||||
</h1>
|
||||
<p style="color: #E0E7FF; margin: 8px 0 0 0; font-size: 14px;">
|
||||
Gestion d'événements professionnelle
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="content" style="padding: 40px 30px;">
|
||||
{{{content}}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0 0 15px 0; font-size: 13px; color: #6b7280; text-align: center;">
|
||||
Cet email a été envoyé automatiquement par EM2 Events
|
||||
</p>
|
||||
<p style="margin: 15px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
|
||||
© {{year}} EM2 Events. Tous droits réservés.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Test rapide des Cloud Functions
|
||||
* Vérifie que toutes les fonctions sont exportées correctement
|
||||
*/
|
||||
|
||||
const functions = require('./index');
|
||||
|
||||
console.log('🧪 Test des Cloud Functions\n');
|
||||
|
||||
const expectedFunctions = [
|
||||
'moveEventFileV2',
|
||||
'createEquipment',
|
||||
'updateEquipment',
|
||||
'deleteEquipment',
|
||||
'getEquipment',
|
||||
'createContainer',
|
||||
'updateContainer',
|
||||
'deleteContainer',
|
||||
'createEvent',
|
||||
'updateEvent',
|
||||
'deleteEvent',
|
||||
'createMaintenance',
|
||||
'updateMaintenance',
|
||||
'createOption',
|
||||
'updateOption',
|
||||
'deleteOption',
|
||||
'createUser',
|
||||
'updateUser',
|
||||
'updateEquipmentStatus'
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const funcName of expectedFunctions) {
|
||||
if (functions[funcName]) {
|
||||
console.log(`✓ ${funcName}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`✗ ${funcName} - MANQUANTE`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Résultats: ${passed} passées, ${failed} échouées`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ Certaines fonctions sont manquantes !');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Toutes les fonctions sont présentes !');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
39
em2rp/functions/utils/emailConfig.js
Normal file
39
em2rp/functions/utils/emailConfig.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Configuration SMTP pour l'envoi d'emails
|
||||
* Les credentials sont stockés dans les variables d'environnement
|
||||
*/
|
||||
|
||||
// Configuration SMTP depuis les variables d'environnement
|
||||
// Pour configurer : Définir SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS dans .env ou Firebase
|
||||
const getSmtpConfig = () => {
|
||||
return {
|
||||
host: process.env.SMTP_HOST || 'mail.em2events.fr',
|
||||
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||
secure: true, // true pour port 465, false pour autres ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'notify@em2events.fr',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
tls: {
|
||||
// Ne pas échouer sur certificats invalides
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Configuration email par défaut
|
||||
const EMAIL_CONFIG = {
|
||||
from: {
|
||||
name: 'EM2 Events',
|
||||
address: 'notify@em2events.fr',
|
||||
},
|
||||
replyTo: 'contact@em2events.fr',
|
||||
// URL de l'application pour les liens
|
||||
appUrl: process.env.APP_URL || 'https://em2rp-951dc.web.app',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSmtpConfig,
|
||||
EMAIL_CONFIG,
|
||||
};
|
||||
|
||||
177
em2rp/functions/utils/emailTemplates.js
Normal file
177
em2rp/functions/utils/emailTemplates.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const admin = require('firebase-admin');
|
||||
const handlebars = require('handlebars');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const {EMAIL_CONFIG} = require('./emailConfig');
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'DAMAGED': 'equipmentNotifications',
|
||||
'QUANTITY_MISMATCH': '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 || event.name || 'Événement';
|
||||
if (event.StartDateTime || event.startDate) {
|
||||
const dateField = event.StartDateTime || event.startDate;
|
||||
const date = dateField.toDate ? dateField.toDate() : new Date(dateField);
|
||||
data.eventDate = date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorer silencieusement
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Ignorer silencieusement
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
'DAMAGED': '⚠️ Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'ℹ️ Quantité incorrecte',
|
||||
'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',
|
||||
'DAMAGED': 'Équipement endommagé',
|
||||
'QUANTITY_MISMATCH': 'Quantité incorrecte',
|
||||
'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) {
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAlertPreference,
|
||||
prepareTemplateData,
|
||||
getEmailSubject,
|
||||
getAlertTitle,
|
||||
renderTemplate,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user