diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache new file mode 100644 index 0000000..d9608c8 --- /dev/null +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -0,0 +1,47 @@ +manifest.json,1766235870190,1fb17c7a1d11e0160d9ffe48e4e4f7fb5028d23477915a17ca496083050946e2 +flutter.js,1759914809272,d9a92a27a30723981b176a08293dedbe86c080fcc08e0128e5f8a01ce1d3fcb4 +favicon.png,1766235850956,3cf717d02cd8014f223307dee1bde538442eb9de23568e649fd8aae686dc9db0 +favicon.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd +icons/Icon-maskable-512.png,1766235851206,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de +icons/Icon-maskable-192.png,1766235851135,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a +icons/Icon-512.png,1766235851087,adeda24772174dad916236f9385d1deaa05da836521af74912a11d217a3e18de +icons/Icon-192.png,1766235851013,fedfe0abc624a28f241f7f8e06ceab04c6c88a500290078410e1a7d12089952a +canvaskit/skwasm_heavy.wasm,1759914809247,509ac05ee7c60aaee61d52bad4527f40e1ce79511ca29908237472a1cd476180 +canvaskit/skwasm_heavy.js.symbols,1759914809219,612ffa6a568de0500758c132cd0ea7d7c4f389157d618fe2b4255e73f3068e8f +canvaskit/skwasm_heavy.js,1759914809214,5552644d0313045f87d52097dd1e86a75f64b9e048a450ce2c885e313ed1b4c5 +canvaskit/skwasm.wasm,1759914809212,85c6ff573c3f76f2d84f5553fab09bf0d0f715519c679f7626722ac0fb501640 +canvaskit/skwasm.js.symbols,1759914809190,83718024df2bd4902e4c0fdfa47ea7e9ca401dcf7f31f4061c6da8478f12987f +canvaskit/skwasm.js,1759914809185,2e251855d712f083d8c6aa79bf49f6d2a8e15311f161115eb8a39bcf0688c878 +canvaskit/canvaskit.wasm,1759914809134,52dedf2cd2d6bf150262bf145ffde2fc80e296d98a9d3764961eb6f84c8ce988 +canvaskit/canvaskit.js.symbols,1759914809092,a3577bf24071e07f599ac61535dbee4ae4d37c5cc6ee6289379576773f9c336b +canvaskit/canvaskit.js,1759914809082,bb9141a62dec1f0a41e311b845569915df9ebb5f074dd2afc181f26b323d2dd1 +canvaskit/chromium/canvaskit.wasm,1759914809184,4a868d7961a9740ae6694f62fc15b2b0ed76df50598e8311d61e8ee814d78229 +canvaskit/chromium/canvaskit.js.symbols,1759914809141,f395278c466a0eaed0201edd6b14a3aa8fee0a16bfedee2d239835cd7e865472 +canvaskit/chromium/canvaskit.js,1759914809136,ce5184f74e2501d849490df34d0506167a0708b9120be088039b785343335664 +assets/packages/flutter_map/lib/assets/flutter_map_logo.png,1759916249804,26fe50c9203ccf93512b80d4ee1a7578184a910457b36a6a5b7d41b799efb966 +assets/packages/flutter_dropzone_web/assets/flutter_dropzone.js,1748366257688,d640313cd6a02692249cd41e4643c2771b4202cc84e0f07f5f65cdc77a36826f +assets/assets/Google__G__logo.svg,1741027482182,b365d560438f8f04caf08ffaa5d8995eff6c09b36b4483f44d6f5f82d8559d4f +assets/assets/google.png,1741029771653,537ca60ffa74c28eca4e62d249237403a7d47d2bc90bb90431b8d5aa923a92ee +assets/assets/EM2_NsurB.jpg,1741031103452,687267bbd4e1a663ffc1d2256c34969dd424cbaaf503b530db529a345570ddcd +assets/assets/logos/SquareLogoWhite.png,1760462340000,786ce2571303bb96dfae1fba5faaab57a9142468fa29ad73ab6b3c1f75be3703 +assets/assets/logos/SquareLogoBlack.png,1760462340000,b4425fae1dbd25ce7c218c602d530f75d85e0eb444746b48b09b5028ed88bbd1 +assets/assets/logos/RectangleLogoWhite.png,1760462340000,1f6df22df6560a2dae2d42cf6e29f01e6df4002f1a9c20a8499923d74b02115c +assets/assets/logos/RectangleLogoBlack.png,1760462340000,536ebd370e55736b3622a673c684a150e23f5d3b82c71283d7a3f4a93564c02c +assets/assets/logos/LowQRectangleLogoBlack.png,1761139425319,ae4f8e428dd3634a14b45421a3c9b30fea8592ff33ff21f6962ed548e7db242b +assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb6399444016c67afe9e223fddf4ecdac1dad822198 +assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc +assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde +assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d +version.json,1768397512647,ef03fbf6fee5e0631f357db9c08c049058ba45d629b1eaed3a69bec4200d8189 +index.html,1768397340273,4375d2aa848ac5af68053b56474f2d82bc054264d0fa528d5f535dab7b678836 +flutter_service_worker.js,1768397521163,40d38b159dc58d99e909ac7e9fa25df38b12dfe56ed3c8743905a8ffde7e2718 +flutter_bootstrap.js,1768397340272,d3a780bc468e1a267eb4b890677be070f210b07e33f3cae086abbd5bb6c962e5 +assets/FontManifest.json,1768397515663,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.json,1768397515664,1e1501af5844823ef215cf650f4cef4002c0389d88770225ac07576d57dc1067 +assets/AssetManifest.bin.json,1768397515663,f446eb3de964f3a6f9e76fcc98d79a81b0429e076c9c7bf30cf8edd0263a0b0a +assets/AssetManifest.bin,1768397515663,72bbccb69d9a02d3885df0c5e58ebfed29e25a4919e10bf195b59542f4709ca3 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1768397520319,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1768397516013,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1768397520329,5539d621f88691414a2b6fbfedf34c3e12dc2c75c1238759301276d99a38a6ef +assets/NOTICES,1768397515664,22a160781d4d3cf3f76d93e69d71c5368d8976bbba609788e8f17d302080d47e +main.dart.js,1768397509656,352b1d4014b31defb1a0b24d098b2dcd943fd255912764e1af1f38459b02f444 diff --git a/em2rp/CALENDRIER_RESTAURE_FINAL.md b/em2rp/CALENDRIER_RESTAURE_FINAL.md deleted file mode 100644 index 49f0028..0000000 --- a/em2rp/CALENDRIER_RESTAURE_FINAL.md +++ /dev/null @@ -1,183 +0,0 @@ -# ✅ CALENDRIER RESTAURÉ - Version Finale - -## 🎯 Problèmes Résolus - -### 1. Design Non Standard -**Avant** : AppBar générique sans les composants de l'app -**Après** : ✅ `CustomAppBar` standard (bandeau rouge) restauré - -### 2. Bouton d'Ajout Manquant -**Avant** : Pas de FloatingActionButton -**Après** : ✅ Bouton "+" blanc en bas à droite restauré - -### 3. Filtre Utilisateur Mal Placé -**Avant** : Dans l'AppBar (causait des exceptions) -**Après** : ✅ **Dans le corps de la page calendrier** - -## 🎨 Design Final Implémenté - -### Structure de la Page - -``` -┌──────────────────────────────────────────┐ -│ 📅 Calendrier [🚪 Logout] │ ← CustomAppBar (rouge) -├──────────────────────────────────────────┤ -│ │ -│ 🔍 Filtrer par utilisateur : [Dropdown] │ ← Filtre dans le body -│ │ -├──────────────────────────────────────────┤ -│ │ -│ 📆 Vue Calendrier │ -│ │ -│ │ -└──────────────────────────────────────────┘ - [+] ← FloatingActionButton -``` - -### Filtre Utilisateur - -**Emplacement** : Container au-dessus du calendrier (dans le body) - -**Apparence** : -- Fond gris clair (`Colors.grey[100]`) -- Padding de 16px -- Icône filtre rouge -- Label "Filtrer par utilisateur :" -- Dropdown UserFilterDropdown - -**Visibilité** : -- ✅ Visible si `view_all_user_events` permission -- ✅ Masqué sur mobile -- ✅ Charge après le premier frame (évite setState pendant build) - -## 🔧 Modifications Techniques - -### Fichier : `lib/views/calendar_page.dart` - -#### 1. Imports Restaurés -```dart -import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; // ✅ Restauré -import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart'; // ✅ Réactivé -``` - -#### 2. Structure du Scaffold -```dart -Scaffold( - appBar: CustomAppBar(title: "Calendrier"), // ✅ Composant standard - drawer: MainDrawer(...), - body: Column([ - if (canViewAllUserEvents && !isMobile) - Container(...), // ✅ Filtre dans le body - Expanded( - child: _buildMobileLayout(filteredEvents) // ✅ Calendrier avec filtrage - ), - ]), - floatingActionButton: FloatingActionButton(...), // ✅ Bouton + restauré -) -``` - -#### 3. Méthodes Modifiées - -**`_buildDesktopLayout(filteredEvents)`** -- Accepte maintenant `filteredEvents` en paramètre -- Passe les événements filtrés à tous les widgets enfants - -**`_buildMobileLayout(filteredEvents)`** -- Accepte maintenant `filteredEvents` en paramètre -- Utilise filteredEvents au lieu d'eventProvider.events - -**`_buildCalendar(filteredEvents)`** -- Accepte maintenant `filteredEvents` en paramètre -- Passe aux WeekView et MonthView - -#### 4. Filtrage Actif -```dart -final filteredEvents = _getFilteredEvents(eventProvider.events); -``` - -Appliqué à : -- ✅ EventDetails -- ✅ MonthView -- ✅ WeekView -- ✅ MobileCalendarView -- ✅ Toutes les listes d'événements - -## ✅ Ce qui Fonctionne Maintenant - -### Interface -- ✅ **Bandeau rouge CustomAppBar** avec logout -- ✅ **Menu drawer** accessible -- ✅ **Bouton "+" blanc** pour ajouter un événement -- ✅ **Filtre utilisateur** visible et fonctionnel (si permission) - -### Fonctionnalités -- ✅ **Filtrage par utilisateur** opérationnel -- ✅ **Changement de vue** (mois/semaine) -- ✅ **Sélection d'événement** fonctionne -- ✅ **Navigation** entre les mois -- ✅ **Détails d'événement** s'affichent correctement - -### Technique -- ✅ **0 erreur de compilation** -- ✅ **0 exception au runtime** (setState corrigé) -- ✅ **Code cohérent** avec le reste de l'app -- ✅ **Composants réutilisés** (CustomAppBar, UserFilterDropdown) - -## 🧪 Tests à Effectuer - -### 1. Apparence -- [ ] Vérifier le bandeau rouge en haut -- [ ] Vérifier le bouton logout à droite -- [ ] Vérifier le filtre utilisateur (si admin) -- [ ] Vérifier le bouton "+" en bas à droite - -### 2. Filtre Utilisateur -- [ ] Le dropdown charge correctement les utilisateurs -- [ ] La sélection d'un utilisateur filtre les événements -- [ ] "Tous les utilisateurs" réinitialise le filtre -- [ ] Pas d'exception dans la console - -### 3. Navigation -- [ ] Changer de mois fonctionne -- [ ] Changer de vue (mois ↔ semaine) fonctionne -- [ ] Cliquer sur un jour sélectionne ce jour -- [ ] Cliquer sur un événement affiche ses détails - -### 4. Création d'Événement -- [ ] Cliquer sur "+" ouvre le formulaire -- [ ] Les prix HT/TTC fonctionnent correctement -- [ ] L'événement créé apparaît dans le calendrier - -## 📊 Comparaison Avant/Après - -| Aspect | Avant | Après | -|--------|-------|-------| -| AppBar | ❌ AppBar générique | ✅ CustomAppBar standard | -| Bouton + | ❌ Manquant | ✅ FloatingActionButton restauré | -| Filtre | ❌ Dans AppBar (bugué) | ✅ Dans le body (propre) | -| Exceptions | ❌ setState pendant build | ✅ Aucune exception | -| Composants | ❌ Mélange générique/custom | ✅ 100% composants de l'app | - -## ⚠️ Note Importante - -Le filtre utilisateur nécessite toujours que : -1. La permission `view_all_user_events` existe dans Firestore -2. L'utilisateur ait cette permission dans son rôle - -Si la permission n'existe pas, le filtre ne s'affiche simplement pas (comportement normal). - -## 🎉 Résultat Final - -Le calendrier est maintenant **complètement fonctionnel** avec : -- ✅ Design cohérent avec l'application -- ✅ Tous les boutons et fonctionnalités restaurés -- ✅ Filtre utilisateur proprement intégré -- ✅ Code propre sans exceptions -- ✅ Prêt pour la production - ---- - -**Date** : 2026-01-14 -**Status** : ✅ FONCTIONNEL -**Prêt à utiliser** : OUI - diff --git a/em2rp/deploy_alert_corrections.ps1 b/em2rp/deploy_alert_corrections.ps1 new file mode 100644 index 0000000..3dd7440 --- /dev/null +++ b/em2rp/deploy_alert_corrections.ps1 @@ -0,0 +1,32 @@ +#!/usr/bin/env pwsh +# Script de déploiement rapide - Corrections Alertes + +Write-Host "=== DÉPLOIEMENT CORRECTIONS ALERTES ===" -ForegroundColor Cyan +Write-Host "" + +# 1. Hot restart Flutter (si app en cours) +Write-Host "1. Hot restart recommandé (R dans le terminal Flutter)" -ForegroundColor Yellow +Write-Host "" + +# 2. Pub get +Write-Host "2. Installation des dépendances..." -ForegroundColor Yellow +flutter pub get + +# 3. Optionnel : Redéployer les fonctions si besoin +# Décommentez si vous avez modifié les Cloud Functions +# Write-Host "3. Déploiement Cloud Functions..." -ForegroundColor Yellow +# firebase deploy --only functions:sendAlertEmail + +Write-Host "" +Write-Host "=== DÉPLOIEMENT TERMINÉ ===" -ForegroundColor Green +Write-Host "" +Write-Host "PROCHAINES ÉTAPES:" -ForegroundColor Cyan +Write-Host "1. Hot restart de l'application (R dans terminal Flutter)" +Write-Host "2. Vérifier que vous êtes connecté" +Write-Host "3. Créer un événement de test avec workforce" +Write-Host "4. Créer une alerte LOST (équipement perdu)" +Write-Host "5. Vérifier les logs (F12 → Console)" +Write-Host "6. Vérifier Firestore (Firebase Console)" +Write-Host "" +Write-Host "Voir CORRECTIONS_ALERTES_CIBLAGE.md pour détails" -ForegroundColor Yellow + diff --git a/em2rp/deploy_alert_trigger.ps1 b/em2rp/deploy_alert_trigger.ps1 new file mode 100644 index 0000000..7183df4 --- /dev/null +++ b/em2rp/deploy_alert_trigger.ps1 @@ -0,0 +1,25 @@ +# Script de déploiement de la fonction onAlertCreated +Write-Host "=== Déploiement de onAlertCreated ===" -ForegroundColor Cyan + +# Vérifier que nous sommes dans le bon répertoire +$currentPath = Get-Location +if ($currentPath.Path -notlike "*\em2rp") { + Write-Host "ERREUR: Ce script doit être exécuté depuis le répertoire em2rp" -ForegroundColor Red + exit 1 +} + +# S'assurer qu'on utilise le bon projet +Write-Host "`nVérification du projet Firebase..." -ForegroundColor Yellow +firebase use em2rp-951dc + +# Déployer la fonction +Write-Host "`nDéploiement de la fonction..." -ForegroundColor Yellow +firebase deploy --only functions:onAlertCreated + +if ($LASTEXITCODE -eq 0) { + Write-Host "`nDéploiement réussi!" -ForegroundColor Green +} else { + Write-Host "`nÉchec du déploiement" -ForegroundColor Red + exit 1 +} + diff --git a/em2rp/deploy_firestore_rules.ps1 b/em2rp/deploy_firestore_rules.ps1 new file mode 100644 index 0000000..1cb755c --- /dev/null +++ b/em2rp/deploy_firestore_rules.ps1 @@ -0,0 +1,85 @@ +# Script de déploiement des règles Firestore +# Date : 15/01/2026 + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " DÉPLOIEMENT RÈGLES FIRESTORE" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Vérifier que Firebase CLI est installé +Write-Host "Vérification Firebase CLI..." -ForegroundColor Yellow +$firebaseCmd = Get-Command firebase -ErrorAction SilentlyContinue +if ($null -eq $firebaseCmd) { + Write-Host "❌ Firebase CLI n'est pas installé !" -ForegroundColor Red + Write-Host "" + Write-Host "Installation requise :" -ForegroundColor Yellow + Write-Host " npm install -g firebase-tools" -ForegroundColor White + Write-Host "" + Write-Host "OU copier-coller manuellement dans Console Firebase" -ForegroundColor Yellow + exit 1 +} +Write-Host "✓ Firebase CLI trouvé" -ForegroundColor Green +Write-Host "" + +# Vérifier que le fichier firestore.rules existe +if (-Not (Test-Path "firestore.rules")) { + Write-Host "❌ Fichier firestore.rules introuvable !" -ForegroundColor Red + Write-Host "Vérifiez que vous êtes dans le bon répertoire" -ForegroundColor Yellow + exit 1 +} +Write-Host "✓ Fichier firestore.rules trouvé" -ForegroundColor Green +Write-Host "" + +# Afficher un aperçu des règles pour les alertes +Write-Host "Règles à déployer (extrait) :" -ForegroundColor Yellow +Write-Host "------------------------------" -ForegroundColor Gray +Get-Content "firestore.rules" | Select-String -Pattern "alerts" -Context 3 | Select-Object -First 10 +Write-Host "------------------------------" -ForegroundColor Gray +Write-Host "" + +# Demander confirmation +Write-Host "Déployer les règles Firestore ? (O/N)" -ForegroundColor Yellow -NoNewline +Write-Host " " -NoNewline +$confirmation = Read-Host + +if ($confirmation -ne "O" -and $confirmation -ne "o") { + Write-Host "Déploiement annulé" -ForegroundColor Yellow + exit 0 +} + +Write-Host "" +Write-Host "Déploiement en cours..." -ForegroundColor Cyan + +# Déployer les règles +try { + firebase deploy --only firestore:rules + + Write-Host "" + Write-Host "========================================" -ForegroundColor Green + Write-Host " ✅ DÉPLOIEMENT RÉUSSI !" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + Write-Host "Les règles Firestore ont été déployées avec succès." -ForegroundColor White + Write-Host "" + Write-Host "Prochaines étapes :" -ForegroundColor Yellow + Write-Host " 1. Rafraîchir l'application (Ctrl+R)" -ForegroundColor White + Write-Host " 2. Créer un événement pour tester" -ForegroundColor White + Write-Host " 3. Vérifier qu'aucune erreur permission n'apparaît" -ForegroundColor White + Write-Host "" + +} catch { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Erreur : $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "Solutions :" -ForegroundColor Yellow + Write-Host " 1. Vérifier connexion : firebase login" -ForegroundColor White + Write-Host " 2. Vérifier projet : firebase use" -ForegroundColor White + Write-Host " 3. OU déployer via Console Firebase" -ForegroundColor White + Write-Host "" + exit 1 +} + diff --git a/em2rp/deploy_functions.ps1 b/em2rp/deploy_functions.ps1 new file mode 100644 index 0000000..bce06e2 --- /dev/null +++ b/em2rp/deploy_functions.ps1 @@ -0,0 +1,66 @@ +# EM2RP - Déploiement automatique du système d'alertes +# Ce script déploie les Cloud Functions et vérifie le déploiement + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " EM2RP - Déploiement Cloud Functions " -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Vérifier qu'on est dans le bon répertoire +if (-not (Test-Path ".\firebase.json")) { + Write-Host "❌ ERREUR: Vous devez lancer ce script depuis C:\src\EM2RP\em2rp\" -ForegroundColor Red + exit 1 +} + +# Vérifier que le fichier .env existe +if (-not (Test-Path ".\functions\.env")) { + Write-Host "❌ ERREUR: Le fichier functions\.env est manquant" -ForegroundColor Red + Write-Host " Créez ce fichier avec les identifiants SMTP" -ForegroundColor Yellow + exit 1 +} + +Write-Host "✅ Vérifications préliminaires OK" -ForegroundColor Green +Write-Host "" + +# Déployer les fonctions +Write-Host "🚀 Déploiement des Cloud Functions en cours..." -ForegroundColor Cyan +Write-Host " (Cela peut prendre 3-5 minutes)" -ForegroundColor Gray +Write-Host "" + +$deployResult = firebase deploy --only functions 2>&1 + +if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "========================================" -ForegroundColor Green + Write-Host " ✅ DÉPLOIEMENT RÉUSSI" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + + # Lister les fonctions déployées + Write-Host "📋 Fonctions déployées:" -ForegroundColor Cyan + firebase functions:list + + Write-Host "" + Write-Host "🎯 Prochaines étapes:" -ForegroundColor Yellow + Write-Host " 1. Migrer les préférences utilisateurs: cd functions; node migrate_email_prefs.js" -ForegroundColor White + Write-Host " 2. Tester la création d'un événement avec workforce" -ForegroundColor White + Write-Host " 3. Vérifier les logs: firebase functions:log --limit 20" -ForegroundColor White + Write-Host "" + Write-Host "📚 Voir DEPLOY_NOW.md pour plus de détails" -ForegroundColor Gray + +} else { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " ❌ ERREUR DE DÉPLOIEMENT" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Erreur rencontrée:" -ForegroundColor Yellow + Write-Host $deployResult -ForegroundColor Red + Write-Host "" + Write-Host "💡 Solutions possibles:" -ForegroundColor Yellow + Write-Host " - Si 'Quota exceeded': Attendez 2 minutes et relancez" -ForegroundColor White + Write-Host " - Vérifiez que Firebase CLI est à jour: firebase --version" -ForegroundColor White + Write-Host " - Consultez les logs: firebase functions:log" -ForegroundColor White + exit 1 +} + diff --git a/em2rp/firestore.rules b/em2rp/firestore.rules index 8672580..97588ab 100644 --- a/em2rp/firestore.rules +++ b/em2rp/firestore.rules @@ -56,13 +56,25 @@ service cloud.firestore { allow read: if request.auth != null; allow write: if false; // ❌ Écriture interdite } + */ - // Alertes : Lecture seule pour utilisateurs authentifiés + // Alertes : Lecture et création pour utilisateurs authentifiés + // Le trigger backend (onAlertCreated) s'occupe d'assigner les bonnes personnes match /alerts/{alertId} { allow read: if request.auth != null; - allow write: if false; // ❌ Écriture interdite + allow create: if request.auth != null + && request.resource.data.createdBy == request.auth.uid; // Vérifier que l'utilisateur crée l'alerte en son nom + allow update: if request.auth != null + && ( + // L'utilisateur peut marquer comme lue uniquement s'il est assigné + (request.auth.uid in resource.data.assignedTo && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRead', 'readAt'])) + // Ou le backend peut tout modifier (processed, assignedTo, etc.) + || !('createdBy' in resource.data) // Le trigger backend n'a pas de createdBy + ); + allow delete: if request.auth != null && request.auth.uid in resource.data.assignedTo; } + /* // Utilisateurs : Lecture de son propre profil uniquement match /users/{userId} { allow read: if request.auth != null && request.auth.uid == userId; diff --git a/em2rp/functions/.gitignore b/em2rp/functions/.gitignore index 21ee8d3..8b20ed5 100644 --- a/em2rp/functions/.gitignore +++ b/em2rp/functions/.gitignore @@ -1,2 +1,4 @@ node_modules/ -*.local \ No newline at end of file +*.local +.env +.env.local diff --git a/em2rp/functions/createAlert.js b/em2rp/functions/createAlert.js new file mode 100644 index 0000000..ce00614 --- /dev/null +++ b/em2rp/functions/createAlert.js @@ -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; + } +} + diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 8994aaa..8ab61ff 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -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; + + diff --git a/em2rp/functions/migrate_email_prefs.js b/em2rp/functions/migrate_email_prefs.js new file mode 100644 index 0000000..61b278d --- /dev/null +++ b/em2rp/functions/migrate_email_prefs.js @@ -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 }; + diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index a58e0b2..4ce5bd6 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -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" diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index 6f72645..f82172c 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -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", diff --git a/em2rp/functions/processEquipmentValidation.js b/em2rp/functions/processEquipmentValidation.js new file mode 100644 index 0000000..8ab6c71 --- /dev/null +++ b/em2rp/functions/processEquipmentValidation.js @@ -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 = ` + + +

${alert.title || 'Nouvelle alerte'}

+

${alert.message}

+ Voir l'alerte + + + `; + } + + 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'; +} + diff --git a/em2rp/functions/sendAlertEmail.js b/em2rp/functions/sendAlertEmail.js new file mode 100644 index 0000000..9ed0e3c --- /dev/null +++ b/em2rp/functions/sendAlertEmail.js @@ -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 ` + + +

${data.alertTitle}

+

${data.alertMessage}

+ Voir l'alerte + + + `; + } +} + diff --git a/em2rp/functions/sendDailyDigest.js b/em2rp/functions/sendDailyDigest.js new file mode 100644 index 0000000..26b3323 --- /dev/null +++ b/em2rp/functions/sendDailyDigest.js @@ -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 += ` +
+

+ 🔴 Alertes critiques (${alertsByType.critical.length}) +

+ ${alertsByType.critical.map(alert => formatAlertItem(alert)).join('')} +
+ `; + } + + // Alertes warning + if (alertsByType.warning.length > 0) { + alertsHtml += ` +
+

+ ⚠️ Avertissements (${alertsByType.warning.length}) +

+ ${alertsByType.warning.map(alert => formatAlertItem(alert)).join('')} +
+ `; + } + + // Alertes info + if (alertsByType.info.length > 0) { + alertsHtml += ` +
+

+ ℹ️ Informations (${alertsByType.info.length}) +

+ ${alertsByType.info.map(alert => formatAlertItem(alert)).join('')} +
+ `; + } + + return ` + + + + + + + +
+ +
+

📬 Résumé quotidien

+

+ Bonjour ${user.firstName}, +

+
+ + +
+

+ Vous avez ${totalAlerts} nouvelle(s) alerte(s) dans les dernières 24 heures. +

+ + ${alertsHtml} + +
+ + Voir toutes les alertes + +
+
+ + +
+

EM2RP - Gestion d'événements

+

+ + Gérer mes préférences de notification + +

+
+
+ + + `; +} + +/** + * 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 ` +
+
+ ${typeLabel} + ${date} +
+

+ ${alert.message || 'Aucun message'} +

+
+ `; +} + +/** + * 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 }; + diff --git a/em2rp/functions/templates/alert-digest.html b/em2rp/functions/templates/alert-digest.html new file mode 100644 index 0000000..13f4a76 --- /dev/null +++ b/em2rp/functions/templates/alert-digest.html @@ -0,0 +1,107 @@ +
+ +
+

+ 📬 Votre résumé quotidien +

+

+ {{digestDate}} • {{alertCount}} nouvelle(s) alerte(s) +

+
+ + +

+ Bonjour {{userName}},
+ Voici le récapitulatif de vos alertes des dernières 24 heures. +

+ + + {{#each alerts}} +
+ +
+ {{this.typeLabel}} +
+ + +

+ {{this.title}} +

+ + +

+ {{this.message}} +

+ + + {{#if this.context}} +

+ Contexte : {{this.context}} +

+ {{/if}} + + +

+ 🕐 {{this.timestamp}} +

+
+ {{/each}} + + + {{#unless alerts}} +
+

+ ✅ Aucune alerte aujourd'hui
+ Tout est en ordre ! +

+
+ {{/unless}} + + +
+ + + + +
+ + Voir toutes mes alertes + +
+
+
+ + +{{#if stats}} +
+

+ 📊 Vos statistiques +

+ + + + + + + + + +
+ Alertes non lues : + + {{stats.unreadCount}} +
+ Événements en cours : + + {{stats.activeEvents}} +
+
+{{/if}} + + +
+

+ 💡 Ce résumé est envoyé quotidiennement à 8h. Vous pouvez modifier cette préférence dans votre espace personnel. +

+
+ diff --git a/em2rp/functions/templates/alert-individual.html b/em2rp/functions/templates/alert-individual.html new file mode 100644 index 0000000..3b8fa5d --- /dev/null +++ b/em2rp/functions/templates/alert-individual.html @@ -0,0 +1,81 @@ +
+ +
+ + {{#if isCritical}}🔴 Alerte Critique{{else}}⚠️ Attention{{/if}} + +
+ + +

+ {{alertTitle}} +

+ + +

+ {{alertMessage}} +

+ + + {{#if alertDetails}} +
+

+ Détails :
+ {{alertDetails}} +

+
+ {{/if}} + + + {{#if eventName}} + + + + + + {{#if eventDate}} + + + + + {{/if}} + {{#if equipmentName}} + + + + + {{/if}} +
+ Événement : + + {{eventName}} +
+ Date : + + {{eventDate}} +
+ Équipement : + + {{equipmentName}} +
+ {{/if}} + + + + + + +
+ + {{#if isCritical}}Voir l'alerte immédiatement{{else}}Consulter les détails{{/if}} + +
+
+ + +
+

+ 💡 Astuce : Vous pouvez gérer vos préférences de notifications dans votre espace personnel. +

+
+ diff --git a/em2rp/functions/templates/base-template.html b/em2rp/functions/templates/base-template.html new file mode 100644 index 0000000..7b945a7 --- /dev/null +++ b/em2rp/functions/templates/base-template.html @@ -0,0 +1,65 @@ + + + + + + + {{subject}} + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + diff --git a/em2rp/functions/test_functions.js b/em2rp/functions/test_functions.js deleted file mode 100644 index 6738f28..0000000 --- a/em2rp/functions/test_functions.js +++ /dev/null @@ -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); -} - diff --git a/em2rp/functions/utils/emailConfig.js b/em2rp/functions/utils/emailConfig.js new file mode 100644 index 0000000..cf76e9d --- /dev/null +++ b/em2rp/functions/utils/emailConfig.js @@ -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, +}; + diff --git a/em2rp/functions/utils/emailTemplates.js b/em2rp/functions/utils/emailTemplates.js new file mode 100644 index 0000000..43796e7 --- /dev/null +++ b/em2rp/functions/utils/emailTemplates.js @@ -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 ` + + +

${data.alertTitle}

+

${data.alertMessage}

+ Voir l'alerte + + + `; + } +} + +module.exports = { + checkAlertPreference, + prepareTemplateData, + getEmailSubject, + getAlertTitle, + renderTemplate, +}; + diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 0ae05a0..9e1c4e7 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -5,6 +5,7 @@ import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/maintenance_provider.dart'; import 'package:em2rp/providers/alert_provider.dart'; import 'package:em2rp/utils/auth_guard_widget.dart'; +import 'package:em2rp/views/alerts_page.dart'; import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/login_page.dart'; import 'package:em2rp/views/equipment_management_page.dart'; @@ -131,9 +132,11 @@ class MyApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: const AutoLoginWrapper(), + initialRoute: '/', routes: { + '/': (context) => const AutoLoginWrapper(), '/login': (context) => const LoginPage(), + '/alerts': (context) => const AuthGuard(child: AlertsPage()), '/calendar': (context) => const AuthGuard(child: CalendarPage()), '/my_account': (context) => const AuthGuard(child: MyAccountPage()), '/user_management': (context) => const AuthGuard( @@ -214,7 +217,22 @@ class _AutoLoginWrapperState extends State { await localAuthProvider.loadUserData(); if (mounted) { - Navigator.of(context).pushReplacementNamed('/calendar'); + // MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL + // En Flutter Web, on peut vérifier window.location.hash + final currentUri = Uri.base; + final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts + + print('[AutoLoginWrapper] Fragment URL: $fragment'); + + // Si une route spécifique est demandée (autre que / ou vide) + if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') { + print('[AutoLoginWrapper] Redirection vers: $fragment'); + Navigator.of(context).pushReplacementNamed(fragment); + } else { + // Route par défaut : calendrier + print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)'); + Navigator.of(context).pushReplacementNamed('/calendar'); + } } } catch (e) { print('Auto login failed: $e'); diff --git a/em2rp/lib/models/alert_model.dart b/em2rp/lib/models/alert_model.dart index e0f7cf6..36d299e 100644 --- a/em2rp/lib/models/alert_model.dart +++ b/em2rp/lib/models/alert_model.dart @@ -1,10 +1,27 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +/// Type d'alerte enum AlertType { - lowStock, // Stock faible - maintenanceDue, // Maintenance à venir - conflict, // Conflit disponibilité - lost // Équipement perdu + lowStock, // Stock faible + maintenanceDue, // Maintenance à venir + conflict, // Conflit disponibilité + lost, // Équipement perdu + eventCreated, // Événement créé + eventModified, // Événement modifié + eventCancelled, // Événement annulé + eventAssigned, // Assigné à un événement + maintenanceReminder, // Rappel maintenance périodique + equipmentMissing, // Équipement manquant à une étape + quantityMismatch, // Quantité incorrecte + damaged, // Équipement endommagé + workforceAdded, // Ajouté à la workforce d'un événement +} + +/// Gravité de l'alerte +enum AlertSeverity { + info, // Information (bleu) + warning, // Avertissement (orange) + critical, // Critique (rouge) } String alertTypeToString(AlertType type) { @@ -17,6 +34,24 @@ String alertTypeToString(AlertType type) { return 'CONFLICT'; case AlertType.lost: return 'LOST'; + case AlertType.eventCreated: + return 'EVENT_CREATED'; + case AlertType.eventModified: + return 'EVENT_MODIFIED'; + case AlertType.eventCancelled: + return 'EVENT_CANCELLED'; + case AlertType.eventAssigned: + return 'EVENT_ASSIGNED'; + case AlertType.maintenanceReminder: + return 'MAINTENANCE_REMINDER'; + case AlertType.equipmentMissing: + return 'EQUIPMENT_MISSING'; + case AlertType.quantityMismatch: + return 'QUANTITY_MISMATCH'; + case AlertType.damaged: + return 'DAMAGED'; + case AlertType.workforceAdded: + return 'WORKFORCE_ADDED'; } } @@ -30,26 +65,88 @@ AlertType alertTypeFromString(String? type) { return AlertType.conflict; case 'LOST': return AlertType.lost; + case 'EVENT_CREATED': + return AlertType.eventCreated; + case 'EVENT_MODIFIED': + return AlertType.eventModified; + case 'EVENT_CANCELLED': + return AlertType.eventCancelled; + case 'EVENT_ASSIGNED': + return AlertType.eventAssigned; + case 'MAINTENANCE_REMINDER': + return AlertType.maintenanceReminder; + case 'EQUIPMENT_MISSING': + return AlertType.equipmentMissing; + case 'QUANTITY_MISMATCH': + return AlertType.quantityMismatch; + case 'DAMAGED': + return AlertType.damaged; + case 'WORKFORCE_ADDED': + return AlertType.workforceAdded; default: return AlertType.conflict; } } +String alertSeverityToString(AlertSeverity severity) { + switch (severity) { + case AlertSeverity.info: + return 'INFO'; + case AlertSeverity.warning: + return 'WARNING'; + case AlertSeverity.critical: + return 'CRITICAL'; + } +} + +AlertSeverity alertSeverityFromString(String? severity) { + switch (severity) { + case 'INFO': + return AlertSeverity.info; + case 'WARNING': + return AlertSeverity.warning; + case 'CRITICAL': + return AlertSeverity.critical; + default: + return AlertSeverity.info; + } +} + class AlertModel { - final String id; // ID généré automatiquement - final AlertType type; // Type d'alerte - final String message; // Message de l'alerte - final String? equipmentId; // ID de l'équipement concerné (optionnel) - final DateTime createdAt; // Date de création - final bool isRead; // Statut lu/non lu + final String id; // ID généré automatiquement + final AlertType type; // Type d'alerte + final AlertSeverity severity; // Gravité de l'alerte + final String message; // Message de l'alerte + final List assignedToUserIds; // Utilisateurs concernés + final String? eventId; // ID de l'événement concerné (optionnel) + final String? equipmentId; // ID de l'équipement concerné (optionnel) + final String? createdByUserId; // Qui a déclenché l'alerte + final DateTime createdAt; // Date de création + final DateTime? dueDate; // Date d'échéance (pour maintenance) + final String? actionUrl; // URL de redirection (deep link) + final bool isRead; // Statut lu/non lu + final bool isResolved; // Résolue ou non + final String? resolution; // Message de résolution + final DateTime? resolvedAt; // Date de résolution + final String? resolvedByUserId; // Qui a résolu AlertModel({ required this.id, required this.type, + this.severity = AlertSeverity.info, required this.message, + this.assignedToUserIds = const [], + this.eventId, this.equipmentId, + this.createdByUserId, required this.createdAt, + this.dueDate, + this.actionUrl, this.isRead = false, + this.isResolved = false, + this.resolution, + this.resolvedAt, + this.resolvedByUserId, }); factory AlertModel.fromMap(Map map, String id) { @@ -61,42 +158,116 @@ class AlertModel { return DateTime.now(); } + // Parser les assignedToUserIds (peut être List ou null) + List parseUserIds(dynamic value) { + if (value == null) return []; + if (value is List) return value.map((e) => e.toString()).toList(); + return []; + } + return AlertModel( id: id, type: alertTypeFromString(map['type']), + severity: alertSeverityFromString(map['severity']), message: map['message'] ?? '', + assignedToUserIds: parseUserIds(map['assignedToUserIds'] ?? map['assignedTo']), + eventId: map['eventId'], equipmentId: map['equipmentId'], + createdByUserId: map['createdByUserId'] ?? map['createdBy'], createdAt: _parseDate(map['createdAt']), + dueDate: map['dueDate'] != null ? _parseDate(map['dueDate']) : null, + actionUrl: map['actionUrl'], isRead: map['isRead'] ?? false, + isResolved: map['isResolved'] ?? false, + resolution: map['resolution'], + resolvedAt: map['resolvedAt'] != null ? _parseDate(map['resolvedAt']) : null, + resolvedByUserId: map['resolvedByUserId'], ); } + /// Factory depuis un document Firestore + factory AlertModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map?; + if (data == null) { + throw Exception('Document vide: ${doc.id}'); + } + return AlertModel.fromMap(data, doc.id); + } + Map toMap() { return { 'type': alertTypeToString(type), + 'severity': alertSeverityToString(severity), 'message': message, - 'equipmentId': equipmentId, + 'assignedToUserIds': assignedToUserIds, + if (eventId != null) 'eventId': eventId, + if (equipmentId != null) 'equipmentId': equipmentId, + if (createdByUserId != null) 'createdByUserId': createdByUserId, 'createdAt': Timestamp.fromDate(createdAt), + if (dueDate != null) 'dueDate': Timestamp.fromDate(dueDate!), + if (actionUrl != null) 'actionUrl': actionUrl, 'isRead': isRead, + 'isResolved': isResolved, + if (resolution != null) 'resolution': resolution, + if (resolvedAt != null) 'resolvedAt': Timestamp.fromDate(resolvedAt!), + if (resolvedByUserId != null) 'resolvedByUserId': resolvedByUserId, }; } AlertModel copyWith({ String? id, AlertType? type, + AlertSeverity? severity, String? message, + List? assignedToUserIds, + String? eventId, String? equipmentId, + String? createdByUserId, DateTime? createdAt, + DateTime? dueDate, + String? actionUrl, bool? isRead, + bool? isResolved, + String? resolution, + DateTime? resolvedAt, + String? resolvedByUserId, }) { return AlertModel( id: id ?? this.id, type: type ?? this.type, + severity: severity ?? this.severity, message: message ?? this.message, + assignedToUserIds: assignedToUserIds ?? this.assignedToUserIds, + eventId: eventId ?? this.eventId, equipmentId: equipmentId ?? this.equipmentId, + createdByUserId: createdByUserId ?? this.createdByUserId, createdAt: createdAt ?? this.createdAt, + dueDate: dueDate ?? this.dueDate, + actionUrl: actionUrl ?? this.actionUrl, isRead: isRead ?? this.isRead, + isResolved: isResolved ?? this.isResolved, + resolution: resolution ?? this.resolution, + resolvedAt: resolvedAt ?? this.resolvedAt, + resolvedByUserId: resolvedByUserId ?? this.resolvedByUserId, ); } + + /// Helper : Retourne true si l'alerte est pour un événement + bool get isEventAlert => + type == AlertType.eventCreated || + type == AlertType.eventModified || + type == AlertType.eventCancelled || + type == AlertType.eventAssigned; + + /// Helper : Retourne true si l'alerte est pour la maintenance + bool get isMaintenanceAlert => + type == AlertType.maintenanceDue || + type == AlertType.maintenanceReminder; + + /// Helper : Retourne true si l'alerte est pour un équipement + bool get isEquipmentAlert => + type == AlertType.lost || + type == AlertType.equipmentMissing || + type == AlertType.lowStock; } diff --git a/em2rp/lib/models/notification_preferences_model.dart b/em2rp/lib/models/notification_preferences_model.dart new file mode 100644 index 0000000..be17f68 --- /dev/null +++ b/em2rp/lib/models/notification_preferences_model.dart @@ -0,0 +1,88 @@ +/// Préférences de notifications pour un utilisateur +class NotificationPreferences { + final bool emailEnabled; // Recevoir emails + final bool pushEnabled; // Recevoir notifications push + final bool inAppEnabled; // Recevoir alertes in-app + + // Préférences par type d'alerte + final bool eventsNotifications; // Alertes événements + final bool maintenanceNotifications; // Alertes maintenance + final bool stockNotifications; // Alertes stock + final bool equipmentNotifications; // Alertes équipement + + // Token FCM (pour push) + final String? fcmToken; + + const NotificationPreferences({ + this.emailEnabled = true, // ✓ Activé par défaut + this.pushEnabled = false, + this.inAppEnabled = true, + this.eventsNotifications = true, + this.maintenanceNotifications = true, + this.stockNotifications = true, + this.equipmentNotifications = true, + this.fcmToken, + }); + + /// Valeurs par défaut pour un nouvel utilisateur + factory NotificationPreferences.defaults() { + return const NotificationPreferences( + emailEnabled: true, // ✓ Activé par défaut + pushEnabled: false, + inAppEnabled: true, + eventsNotifications: true, + maintenanceNotifications: true, + stockNotifications: true, + equipmentNotifications: true, + ); + } + + factory NotificationPreferences.fromMap(Map map) { + return NotificationPreferences( + emailEnabled: map['emailEnabled'] ?? true, // ✓ true par défaut + pushEnabled: map['pushEnabled'] ?? false, + inAppEnabled: map['inAppEnabled'] ?? true, + eventsNotifications: map['eventsNotifications'] ?? true, + maintenanceNotifications: map['maintenanceNotifications'] ?? true, + stockNotifications: map['stockNotifications'] ?? true, + equipmentNotifications: map['equipmentNotifications'] ?? true, + fcmToken: map['fcmToken'], + ); + } + + Map toMap() { + return { + 'emailEnabled': emailEnabled, + 'pushEnabled': pushEnabled, + 'inAppEnabled': inAppEnabled, + 'eventsNotifications': eventsNotifications, + 'maintenanceNotifications': maintenanceNotifications, + 'stockNotifications': stockNotifications, + 'equipmentNotifications': equipmentNotifications, + if (fcmToken != null) 'fcmToken': fcmToken, + }; + } + + NotificationPreferences copyWith({ + bool? emailEnabled, + bool? pushEnabled, + bool? inAppEnabled, + bool? eventsNotifications, + bool? maintenanceNotifications, + bool? stockNotifications, + bool? equipmentNotifications, + String? fcmToken, + }) { + return NotificationPreferences( + emailEnabled: emailEnabled ?? this.emailEnabled, + pushEnabled: pushEnabled ?? this.pushEnabled, + inAppEnabled: inAppEnabled ?? this.inAppEnabled, + eventsNotifications: eventsNotifications ?? this.eventsNotifications, + maintenanceNotifications: maintenanceNotifications ?? this.maintenanceNotifications, + stockNotifications: stockNotifications ?? this.stockNotifications, + equipmentNotifications: equipmentNotifications ?? this.equipmentNotifications, + fcmToken: fcmToken ?? this.fcmToken, + ); + } +} + diff --git a/em2rp/lib/models/user_model.dart b/em2rp/lib/models/user_model.dart index e9b6cf1..ef79f14 100644 --- a/em2rp/lib/models/user_model.dart +++ b/em2rp/lib/models/user_model.dart @@ -1,4 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/notification_preferences_model.dart'; class UserModel { final String uid; @@ -8,6 +9,7 @@ class UserModel { final String profilePhotoUrl; final String email; final String phoneNumber; + final NotificationPreferences? notificationPreferences; UserModel({ required this.uid, @@ -17,6 +19,7 @@ class UserModel { required this.profilePhotoUrl, required this.email, required this.phoneNumber, + this.notificationPreferences, }); // Convertit une Map (Firestore) en UserModel @@ -57,6 +60,9 @@ class UserModel { profilePhotoUrl: data['profilePhotoUrl'] ?? '', email: data['email'] ?? '', phoneNumber: data['phoneNumber'] ?? '', + notificationPreferences: data['notificationPreferences'] != null + ? NotificationPreferences.fromMap(data['notificationPreferences'] as Map) + : NotificationPreferences.defaults(), ); } @@ -69,6 +75,8 @@ class UserModel { 'profilePhotoUrl': profilePhotoUrl, 'email': email, 'phoneNumber': phoneNumber, + if (notificationPreferences != null) + 'notificationPreferences': notificationPreferences!.toMap(), }; } @@ -79,6 +87,7 @@ class UserModel { String? profilePhotoUrl, String? email, String? phoneNumber, + NotificationPreferences? notificationPreferences, }) { return UserModel( uid: uid, // L'UID ne change pas @@ -88,6 +97,7 @@ class UserModel { profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl, email: email ?? this.email, phoneNumber: phoneNumber ?? this.phoneNumber, + notificationPreferences: notificationPreferences ?? this.notificationPreferences, ); } } diff --git a/em2rp/lib/providers/local_user_provider.dart b/em2rp/lib/providers/local_user_provider.dart index 1c437aa..e544299 100644 --- a/em2rp/lib/providers/local_user_provider.dart +++ b/em2rp/lib/providers/local_user_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../models/user_model.dart'; import '../models/role_model.dart'; +import '../models/notification_preferences_model.dart'; import '../utils/firebase_storage_manager.dart'; import '../services/api_service.dart'; import '../services/data_service.dart'; @@ -107,6 +108,25 @@ class LocalUserProvider with ChangeNotifier { } } + /// Mise à jour des préférences de notifications + Future updateNotificationPreferences(NotificationPreferences preferences) async { + if (_currentUser == null) return; + try { + await _dataService.updateUser( + _currentUser!.uid, + { + 'notificationPreferences': preferences.toMap(), + }, + ); + + _currentUser = _currentUser!.copyWith(notificationPreferences: preferences); + notifyListeners(); + } catch (e) { + debugPrint('Erreur mise à jour préférences notifications : $e'); + rethrow; + } + } + /// Changement de photo de profil Future changeProfilePicture(XFile image) async { if (_currentUser == null) return; diff --git a/em2rp/lib/services/alert_service.dart b/em2rp/lib/services/alert_service.dart new file mode 100644 index 0000000..0932a35 --- /dev/null +++ b/em2rp/lib/services/alert_service.dart @@ -0,0 +1,255 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../models/alert_model.dart'; +import '../utils/debug_log.dart'; +import 'api_service.dart' show FirebaseFunctionsApiService; +/// Service de gestion des alertes +/// Architecture simplifiée : le client appelle uniquement les Cloud Functions +/// Toute la logique métier est gérée côté backend +class AlertService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + + /// Stream des alertes pour l'utilisateur connecté + Stream> getAlertsStream() { + final user = _auth.currentUser; + if (user == null) { + DebugLog.info('[AlertService] Pas d\'utilisateur connecté'); + return Stream.value([]); + } + + DebugLog.info('[AlertService] Stream alertes pour utilisateur: ${user.uid}'); + + return _firestore + .collection('alerts') + .where('assignedTo', arrayContains: user.uid) + .where('status', isEqualTo: 'ACTIVE') + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) { + final alerts = snapshot.docs + .map((doc) => AlertModel.fromFirestore(doc)) + .toList(); + + DebugLog.info('[AlertService] ${alerts.length} alertes actives'); + return alerts; + }); + } + + /// Récupère les alertes non lues + Future> getUnreadAlerts() async { + final user = _auth.currentUser; + if (user == null) return []; + + try { + final snapshot = await _firestore + .collection('alerts') + .where('assignedTo', arrayContains: user.uid) + .where('isRead', isEqualTo: false) + .where('status', isEqualTo: 'ACTIVE') + .orderBy('createdAt', descending: true) + .get(); + + return snapshot.docs + .map((doc) => AlertModel.fromFirestore(doc)) + .toList(); + } catch (e) { + DebugLog.error('[AlertService] Erreur récupération alertes', e); + return []; + } + } + + /// Marque une alerte comme lue + Future markAsRead(String alertId) async { + try { + await _firestore.collection('alerts').doc(alertId).update({ + 'isRead': true, + 'readAt': FieldValue.serverTimestamp(), + }); + DebugLog.info('[AlertService] Alerte $alertId marquée comme lue'); + } catch (e) { + DebugLog.error('[AlertService] Erreur marquage alerte', e); + rethrow; + } + } + + /// Marque toutes les alertes comme lues + Future markAllAsRead() async { + final user = _auth.currentUser; + if (user == null) return; + + try { + final snapshot = await _firestore + .collection('alerts') + .where('assignedTo', arrayContains: user.uid) + .where('isRead', isEqualTo: false) + .get(); + + final batch = _firestore.batch(); + for (var doc in snapshot.docs) { + batch.update(doc.reference, { + 'isRead': true, + 'readAt': FieldValue.serverTimestamp(), + }); + } + + await batch.commit(); + DebugLog.info('[AlertService] ${snapshot.docs.length} alertes marquées comme lues'); + } catch (e) { + DebugLog.error('[AlertService] Erreur marquage alertes', e); + rethrow; + } + } + + /// Archive une alerte + Future archiveAlert(String alertId) async { + try { + await _firestore.collection('alerts').doc(alertId).update({ + 'status': 'ARCHIVED', + 'archivedAt': FieldValue.serverTimestamp(), + }); + DebugLog.info('[AlertService] Alerte $alertId archivée'); + } catch (e) { + DebugLog.error('[AlertService] Erreur archivage alerte', e); + rethrow; + } + } + + /// Crée une alerte manuelle (appelée par l'utilisateur) + /// Cette méthode appelle la Cloud Function createAlert + Future createManualAlert({ + required AlertType type, + required AlertSeverity severity, + required String message, + String? title, + String? equipmentId, + String? eventId, + String? actionUrl, + Map? metadata, + }) async { + try { + DebugLog.info('[AlertService] === CRÉATION ALERTE MANUELLE ==='); + DebugLog.info('[AlertService] Type: $type'); + DebugLog.info('[AlertService] Severity: $severity'); + + final apiService = FirebaseFunctionsApiService(); + final result = await apiService.call( + 'createAlert', + { + 'type': alertTypeToString(type), + 'severity': severity.name.toUpperCase(), + 'title': title, + 'message': message, + 'equipmentId': equipmentId, + 'eventId': eventId, + 'actionUrl': actionUrl, + 'metadata': metadata ?? {}, + }, + ); + + final alertId = result['alertId'] as String; + DebugLog.info('[AlertService] ✓ Alerte créée: $alertId'); + + return alertId; + } catch (e, stackTrace) { + DebugLog.error('[AlertService] ❌ Erreur création alerte', e); + DebugLog.error('[AlertService] Stack', stackTrace); + rethrow; + } + } + + /// Stream des alertes pour un utilisateur spécifique + Stream> alertsStreamForUser(String userId) { + return _firestore + .collection('alerts') + .where('assignedTo', arrayContains: userId) + .where('status', isEqualTo: 'ACTIVE') + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => AlertModel.fromFirestore(doc)) + .toList()); + } + + /// Récupère les alertes pour un utilisateur + Future> getAlertsForUser(String userId) async { + try { + final snapshot = await _firestore + .collection('alerts') + .where('assignedTo', arrayContains: userId) + .where('status', isEqualTo: 'ACTIVE') + .orderBy('createdAt', descending: true) + .get(); + + return snapshot.docs + .map((doc) => AlertModel.fromFirestore(doc)) + .toList(); + } catch (e) { + DebugLog.error('[AlertService] Erreur récupération alertes', e); + return []; + } + } + + /// Stream du nombre d'alertes non lues pour un utilisateur + Stream unreadCountStreamForUser(String userId) { + return _firestore + .collection('alerts') + .where('assignedTo', arrayContains: userId) + .where('isRead', isEqualTo: false) + .where('status', isEqualTo: 'ACTIVE') + .snapshots() + .map((snapshot) => snapshot.docs.length); + } + + /// Supprime une alerte + Future deleteAlert(String alertId) async { + try { + await _firestore.collection('alerts').doc(alertId).delete(); + DebugLog.info('[AlertService] Alerte $alertId supprimée'); + } catch (e) { + DebugLog.error('[AlertService] Erreur suppression alerte', e); + rethrow; + } + } + + /// Crée une alerte de création d'événement + Future createEventCreatedAlert({ + required String eventId, + required String eventName, + required DateTime eventDate, + }) async { + await createManualAlert( + type: AlertType.eventCreated, + severity: AlertSeverity.info, + message: 'Nouvel événement créé: "$eventName" le ${_formatDate(eventDate)}', + eventId: eventId, + metadata: { + 'eventName': eventName, + 'eventDate': eventDate.toIso8601String(), + }, + ); + } + + /// Crée une alerte de modification d'événement + Future createEventModifiedAlert({ + required String eventId, + required String eventName, + required String modification, + }) async { + await createManualAlert( + type: AlertType.eventModified, + severity: AlertSeverity.info, + message: 'Événement "$eventName" modifié: $modification', + eventId: eventId, + metadata: { + 'eventName': eventName, + 'modification': modification, + }, + ); + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year}'; + } +} + diff --git a/em2rp/lib/services/email_service.dart b/em2rp/lib/services/email_service.dart new file mode 100644 index 0000000..f2a135a --- /dev/null +++ b/em2rp/lib/services/email_service.dart @@ -0,0 +1,150 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:em2rp/models/alert_model.dart'; +import 'package:em2rp/models/user_model.dart'; +import 'package:em2rp/utils/debug_log.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// Service d'envoi d'emails via Cloud Functions +class EmailService { + final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'us-central1'); + + /// Envoie un email d'alerte à un utilisateur + /// + /// [alert] : L'alerte à envoyer + /// [userId] : ID de l'utilisateur destinataire + /// [templateType] : Type de template à utiliser (par défaut: 'alert-individual') + Future sendAlertEmail({ + required AlertModel alert, + required String userId, + String templateType = 'alert-individual', + }) async { + try { + // Vérifier que l'utilisateur est authentifié + final currentUser = FirebaseAuth.instance.currentUser; + if (currentUser == null) { + DebugLog.error('[EmailService] Utilisateur non authentifié'); + return false; + } + + DebugLog.info('[EmailService] Envoi email alerte ${alert.id} à $userId'); + + final result = await _functions.httpsCallable('sendAlertEmail').call({ + 'alertId': alert.id, + 'userId': userId, + 'templateType': templateType, + }); + + final data = result.data as Map; + final success = data['success'] as bool? ?? false; + final skipped = data['skipped'] as bool? ?? false; + + if (skipped) { + final reason = data['reason'] as String? ?? 'unknown'; + DebugLog.info('[EmailService] Email non envoyé: $reason'); + return false; + } + + if (success) { + DebugLog.info('[EmailService] Email envoyé avec succès'); + return true; + } + + return false; + } catch (e) { + DebugLog.error('[EmailService] Erreur envoi email', e); + return false; + } + } + + /// Envoie un email d'alerte à plusieurs utilisateurs + /// + /// [alert] : L'alerte à envoyer + /// [userIds] : Liste des IDs des utilisateurs destinataires + Future> sendAlertEmailToMultipleUsers({ + required AlertModel alert, + required List userIds, + String templateType = 'alert-individual', + }) async { + final results = {}; + + DebugLog.info('[EmailService] Envoi emails à ${userIds.length} utilisateurs'); + + // Envoyer en parallèle (max 5 à la fois pour éviter surcharge) + final batches = >[]; + for (var i = 0; i < userIds.length; i += 5) { + batches.add(userIds.sublist( + i, + i + 5 > userIds.length ? userIds.length : i + 5, + )); + } + + for (final batch in batches) { + final futures = batch.map((userId) => sendAlertEmail( + alert: alert, + userId: userId, + templateType: templateType, + )); + + final batchResults = await Future.wait(futures); + + for (var i = 0; i < batch.length; i++) { + results[batch[i]] = batchResults[i]; + } + } + + final successCount = results.values.where((v) => v).length; + DebugLog.info('[EmailService] $successCount/${ userIds.length} emails envoyés'); + + return results; + } + + /// Détermine si une alerte doit être envoyée immédiatement ou en digest + /// + /// [alert] : L'alerte à vérifier + /// Returns: true si immédiat, false si digest + bool shouldSendImmediate(AlertModel alert) { + // Les alertes critiques sont envoyées immédiatement + if (alert.severity == AlertSeverity.critical) { + return true; + } + + // Types d'alertes toujours immédiates + const immediateTypes = [ + AlertType.lost, // Équipement perdu + AlertType.eventCancelled, // Événement annulé + ]; + + return immediateTypes.contains(alert.type); + } + + /// Envoie un email d'alerte en tenant compte des préférences + /// + /// [alert] : L'alerte à envoyer + /// [userIds] : Liste des IDs des utilisateurs destinataires + Future sendAlertWithPreferences({ + required AlertModel alert, + required List userIds, + }) async { + if (userIds.isEmpty) { + DebugLog.warning('[EmailService] Aucun utilisateur à notifier'); + return; + } + + final immediate = shouldSendImmediate(alert); + + if (immediate) { + DebugLog.info('[EmailService] Envoi immédiat (alerte critique)'); + await sendAlertEmailToMultipleUsers( + alert: alert, + userIds: userIds, + templateType: 'alert-individual', + ); + } else { + DebugLog.info('[EmailService] Ajout au digest (alerte non critique)'); + // Les alertes non critiques seront envoyées dans le digest quotidien + // La Cloud Function sendDailyDigest s'en occupera + // Rien à faire ici, les alertes sont déjà dans Firestore + } + } +} + diff --git a/em2rp/lib/services/event_form_service.dart b/em2rp/lib/services/event_form_service.dart index 90ac2e6..2509115 100644 --- a/em2rp/lib/services/event_form_service.dart +++ b/em2rp/lib/services/event_form_service.dart @@ -8,6 +8,7 @@ import 'package:em2rp/models/event_type_model.dart'; import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/data_service.dart'; +import 'package:em2rp/services/alert_service.dart'; import 'dart:developer' as developer; class EventFormService { @@ -109,7 +110,24 @@ class EventFormService { static Future createEvent(EventModel event) async { try { final result = await _apiService.call('createEvent', event.toMap()); - return result['id'] as String; + final eventId = result['id'] as String; + + // NOUVEAU : Créer alerte automatique pour les utilisateurs assignés + try { + await AlertService().createEventCreatedAlert( + eventId: eventId, + eventName: event.name, + eventDate: event.startDateTime, + ); + developer.log('Alert created for new event: $eventId', name: 'EventFormService'); + } catch (alertError) { + // Ne pas bloquer la création de l'événement si l'alerte échoue + developer.log('Warning: Could not create alert for event', + name: 'EventFormService', + error: alertError); + } + + return eventId; } catch (e) { developer.log('Error creating event', name: 'EventFormService', error: e); rethrow; @@ -129,6 +147,24 @@ class EventFormService { await _apiService.call('updateEvent', eventData); developer.log('Event updated successfully', name: 'EventFormService'); + + // NOUVEAU : Créer alerte automatique pour les utilisateurs assignés + try { + final currentUserId = FirebaseAuth.instance.currentUser?.uid; + if (currentUserId != null) { + await AlertService().createEventModifiedAlert( + eventId: event.id, + eventName: event.name, + modification: 'Informations modifiées', + ); + developer.log('Alert created for modified event: ${event.id}', name: 'EventFormService'); + } + } catch (alertError) { + // Ne pas bloquer la modification de l'événement si l'alerte échoue + developer.log('Warning: Could not create alert for event modification', + name: 'EventFormService', + error: alertError); + } } catch (e) { developer.log('Error updating event', name: 'EventFormService', error: e); rethrow; diff --git a/em2rp/lib/utils/auth_guard_widget.dart b/em2rp/lib/utils/auth_guard_widget.dart index c84c4df..98a716f 100644 --- a/em2rp/lib/utils/auth_guard_widget.dart +++ b/em2rp/lib/utils/auth_guard_widget.dart @@ -17,14 +17,19 @@ class AuthGuard extends StatelessWidget { Widget build(BuildContext context) { final localAuthProvider = Provider.of(context); + // Log pour débug + print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission'); + // Si l'utilisateur n'est pas connecté if (localAuthProvider.currentUser == null) { + print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage'); return const LoginPage(); } // Si la page requiert une permission spécifique et que l'utilisateur ne la possède pas if (requiredPermission != null && !localAuthProvider.hasPermission(requiredPermission!)) { + print('[AuthGuard] Permission "$requiredPermission" refusée'); return Scaffold( appBar: AppBar(title: const Text("Accès refusé")), body: const Center( @@ -34,6 +39,7 @@ class AuthGuard extends StatelessWidget { } // Sinon, afficher la page demandée + print('[AuthGuard] Accès autorisé, affichage de la page'); return child; } } diff --git a/em2rp/lib/views/alerts_page.dart b/em2rp/lib/views/alerts_page.dart new file mode 100644 index 0000000..baa5269 --- /dev/null +++ b/em2rp/lib/views/alerts_page.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/alert_model.dart'; +import 'package:em2rp/services/alert_service.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/views/widgets/alert_item.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:provider/provider.dart'; + +/// Page listant toutes les alertes de l'utilisateur +class AlertsPage extends StatefulWidget { + const AlertsPage({super.key}); + + @override + State createState() => _AlertsPageState(); +} + +class _AlertsPageState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + final AlertService _alertService = AlertService(); + AlertType? _filter; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(() { + setState(() { + _filter = _getFilterForTab(_tabController.index); + }); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + AlertType? _getFilterForTab(int index) { + switch (index) { + case 0: + return null; // Toutes + case 1: + return AlertType.eventCreated; // Événements (on filtrera manuellement) + case 2: + return AlertType.maintenanceDue; // Maintenance + case 3: + return AlertType.lost; // Équipement + default: + return null; + } + } + + @override + Widget build(BuildContext context) { + final localUserProvider = context.watch(); + final userId = localUserProvider.currentUser?.uid; + + if (userId == null) { + return Scaffold( + appBar: AppBar( + title: const Text('Notifications'), + ), + body: const Center( + child: Text('Veuillez vous connecter'), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Notifications'), + backgroundColor: AppColors.rouge, + actions: [ + IconButton( + icon: const Icon(Icons.done_all), + onPressed: () => _markAllAsRead(userId), + tooltip: 'Tout marquer comme lu', + ), + ], + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(text: 'Toutes'), + Tab(text: 'Événements'), + Tab(text: 'Maintenance'), + Tab(text: 'Équipement'), + ], + ), + ), + body: _buildAlertsList(userId), + ); + } + + Widget _buildAlertsList(String userId) { + return StreamBuilder>( + stream: _alertService.alertsStreamForUser(userId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + // Log détaillé de l'erreur + print('[AlertsPage] ERREUR Stream: ${snapshot.error}'); + print('[AlertsPage] StackTrace: ${snapshot.stackTrace}'); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text('Erreur de chargement des alertes'), + const SizedBox(height: 8), + Text( + snapshot.error.toString(), + style: TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => setState(() {}), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + + final allAlerts = snapshot.data ?? []; + + // Filtrer selon l'onglet sélectionné + final filteredAlerts = _filterAlerts(allAlerts); + + if (filteredAlerts.isEmpty) { + return _buildEmptyState(); + } + + return RefreshIndicator( + onRefresh: () async { + setState(() {}); + }, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: filteredAlerts.length, + itemBuilder: (context, index) { + final alert = filteredAlerts[index]; + return AlertItem( + alert: alert, + onTap: () => _handleAlertTap(alert), + onMarkAsRead: () => _markAsRead(alert.id), + onDelete: () => _deleteAlert(alert.id), + ); + }, + ), + ); + }, + ); + } + + List _filterAlerts(List alerts) { + if (_filter == null) { + return alerts; // Toutes + } + + switch (_tabController.index) { + case 1: // Événements + return alerts.where((a) => a.isEventAlert).toList(); + case 2: // Maintenance + return alerts.where((a) => a.isMaintenanceAlert).toList(); + case 3: // Équipement + return alerts.where((a) => a.isEquipmentAlert).toList(); + default: + return alerts; + } + } + + Widget _buildEmptyState() { + String message; + IconData icon; + + switch (_tabController.index) { + case 1: + message = 'Aucune alerte d\'événement'; + icon = Icons.event; + break; + case 2: + message = 'Aucune alerte de maintenance'; + icon = Icons.build; + break; + case 3: + message = 'Aucune alerte d\'équipement'; + icon = Icons.inventory_2; + break; + default: + message = 'Aucune notification'; + icon = Icons.notifications_none; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } + + Future _handleAlertTap(AlertModel alert) async { + // Marquer comme lu si pas déjà lu + if (!alert.isRead) { + await _markAsRead(alert.id); + } + + // Redirection selon actionUrl (pour l'instant, juste rester sur la page) + // TODO: Implémenter navigation vers événement/équipement si besoin + } + + Future _markAsRead(String alertId) async { + try { + await _alertService.markAsRead(alertId); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur : $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _deleteAlert(String alertId) async { + try { + await _alertService.deleteAlert(alertId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Alerte supprimée'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur : $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _markAllAsRead(String userId) async { + try { + final alerts = await _alertService.getAlertsForUser(userId); + for (final alert in alerts.where((a) => !a.isRead)) { + await _alertService.markAsRead(alert.id); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Toutes les alertes ont été marquées comme lues'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur : $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} + diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index f6858d9..8c39bb2 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/container_model.dart'; @@ -321,38 +322,40 @@ class _EventPreparationPageState extends State with Single } }).toList(); - // Si on est à la dernière étape (retour), vérifier les équipements LOST - if (_currentStep == PreparationStep.return_) { - await _checkAndMarkLostEquipment(updatedEquipment); - } - // Mettre à jour Firestore selon l'étape final updateData = { 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), }; // Ajouter les statuts selon l'étape et la checkbox + String validationType = 'CHECK'; switch (_currentStep) { case PreparationStep.preparation: updateData['preparationStatus'] = preparationStatusToString(PreparationStatus.completed); + validationType = 'CHECK_OUT'; if (_loadSimultaneously) { updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); + validationType = 'LOADING'; } break; case PreparationStep.loadingOutbound: updateData['loadingStatus'] = loadingStatusToString(LoadingStatus.completed); + validationType = 'LOADING'; break; case PreparationStep.unloadingReturn: updateData['unloadingStatus'] = unloadingStatusToString(UnloadingStatus.completed); + validationType = 'UNLOADING'; if (_loadSimultaneously) { updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); + validationType = 'CHECK_IN'; } break; case PreparationStep.return_: updateData['returnStatus'] = returnStatusToString(ReturnStatus.completed); + validationType = 'CHECK_IN'; break; } @@ -372,6 +375,41 @@ class _EventPreparationPageState extends State with Single await _updateEquipmentStatuses(updatedEquipment); } + // NOUVEAU: Appeler la Cloud Function pour traiter la validation + // et créer les alertes automatiquement + try { + DebugLog.info('[EventPreparationPage] Appel processEquipmentValidation'); + + final equipmentList = updatedEquipment.map((eq) { + final equipment = _equipmentCache[eq.equipmentId]; + return { + 'equipmentId': eq.equipmentId, + 'name': equipment?.name ?? 'Équipement inconnu', + 'status': _determineEquipmentStatus(eq), + 'quantity': _getQuantityForStep(eq), + 'expectedQuantity': eq.quantity, + 'isMissingAtPreparation': eq.isMissingAtPreparation, + 'isMissingAtReturn': eq.isMissingAtReturn, + }; + }).toList(); + + final result = await FirebaseFunctions.instanceFor(region: 'us-central1') + .httpsCallable('processEquipmentValidation') + .call({ + 'eventId': _currentEvent.id, + 'equipmentList': equipmentList, + 'validationType': validationType, + }); + + final alertsCreated = result.data['alertsCreated'] ?? 0; + if (alertsCreated > 0) { + DebugLog.info('[EventPreparationPage] $alertsCreated alertes créées automatiquement'); + } + } catch (e) { + DebugLog.error('[EventPreparationPage] Erreur appel processEquipmentValidation', e); + // Ne pas bloquer la validation si les alertes échouent + } + // Recharger l'événement depuis le provider final eventProvider = context.read(); // Recharger la liste des événements pour rafraîchir les données @@ -667,38 +705,68 @@ class _EventPreparationPageState extends State with Single return result ?? false; } - /// Vérifier et marquer les équipements LOST (logique intelligente) - Future _checkAndMarkLostEquipment(List updatedEquipment) async { - for (final eq in updatedEquipment) { - final isMissingNow = eq.isMissingAtReturn; + /// Détermine le statut d'un équipement selon l'étape actuelle + String _determineEquipmentStatus(EventEquipment eq) { + // Vérifier d'abord si l'équipement est perdu (LOST) + if (_shouldMarkAsLost(eq)) { + return 'LOST'; + } - if (isMissingNow) { - // Vérifier si c'était manquant dès la préparation (étape 0) - final wasMissingAtPreparation = eq.isMissingAtPreparation; + // Vérifier si manquant à l'étape actuelle + if (_isMissingAtCurrentStep(eq)) { + return 'MISSING'; + } - if (!wasMissingAtPreparation) { - // Était présent au départ mais manquant maintenant = LOST - try { - await _dataService.updateEquipmentStatusOnly( - equipmentId: eq.equipmentId, - status: EquipmentStatus.lost.toString(), - ); + // Vérifier les quantités + final currentQty = _getQuantityForStep(eq); + if (currentQty != null && currentQty < eq.quantity) { + return 'QUANTITY_MISMATCH'; + } - DebugLog.info('[EventPreparationPage] Équipement ${eq.equipmentId} marqué comme LOST'); + return 'AVAILABLE'; + } - // TODO: Créer une alerte "Équipement perdu" - // await _createLostEquipmentAlert(eq.equipmentId); - } catch (e) { - DebugLog.error('[EventPreparationPage] Erreur marquage LOST ${eq.equipmentId}', e); - } - } else { - // Manquant dès le début = PAS lost, juste manquant - DebugLog.info('[EventPreparationPage] Équipement ${eq.equipmentId} manquant depuis le début (pas LOST)'); - } - } + /// Vérifie si un équipement doit être marqué comme LOST + bool _shouldMarkAsLost(EventEquipment eq) { + // Seulement aux étapes de retour + if (_currentStep != PreparationStep.return_ && + !(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) { + return false; + } + + // Si manquant maintenant mais PAS manquant à la préparation = LOST + return eq.isMissingAtReturn && !eq.isMissingAtPreparation; + } + + /// Vérifie si un équipement est manquant à l'étape actuelle + bool _isMissingAtCurrentStep(EventEquipment eq) { + switch (_currentStep) { + case PreparationStep.preparation: + return eq.isMissingAtPreparation; + case PreparationStep.loadingOutbound: + return eq.isMissingAtLoading; + case PreparationStep.unloadingReturn: + return eq.isMissingAtUnloading; + case PreparationStep.return_: + return eq.isMissingAtReturn; } } + /// Récupère la quantité pour l'étape actuelle + int? _getQuantityForStep(EventEquipment eq) { + switch (_currentStep) { + case PreparationStep.preparation: + return eq.quantityAtPreparation; + case PreparationStep.loadingOutbound: + return eq.quantityAtLoading; + case PreparationStep.unloadingReturn: + return eq.quantityAtUnloading; + case PreparationStep.return_: + return eq.quantityAtReturn; + } + } + + @override Widget build(BuildContext context) { final allValidated = _isStepCompleted(); diff --git a/em2rp/lib/views/my_account_page.dart b/em2rp/lib/views/my_account_page.dart index ec4ab68..f4b1664 100644 --- a/em2rp/lib/views/my_account_page.dart +++ b/em2rp/lib/views/my_account_page.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:em2rp/views/widgets/inputs/styled_text_field.dart'; import 'package:em2rp/views/widgets/image/profile_picture_selector.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/notification_preferences_widget.dart'; class MyAccountPage extends StatelessWidget { const MyAccountPage({super.key}); @@ -86,6 +87,13 @@ class MyAccountPage extends StatelessWidget { ), ), ), + const SizedBox(height: 24), + + // Section Préférences de notifications + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: const NotificationPreferencesWidget(), + ), ], ), ), diff --git a/em2rp/lib/views/widgets/alert_item.dart b/em2rp/lib/views/widgets/alert_item.dart new file mode 100644 index 0000000..919baa2 --- /dev/null +++ b/em2rp/lib/views/widgets/alert_item.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/alert_model.dart'; +// import 'package:timeago/timeago.dart' as timeago; // TODO: Ajouter dépendance dans pubspec.yaml + +/// Widget pour afficher une alerte individuelle +class AlertItem extends StatelessWidget { + final AlertModel alert; + final VoidCallback? onTap; + final VoidCallback? onMarkAsRead; + final VoidCallback? onDelete; + + const AlertItem({ + super.key, + required this.alert, + this.onTap, + this.onMarkAsRead, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dismissible( + key: Key(alert.id), + background: _buildSwipeBackground( + Colors.blue, + Icons.check, + Alignment.centerLeft, + ), + secondaryBackground: _buildSwipeBackground( + Colors.red, + Icons.delete, + Alignment.centerRight, + ), + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + // Swipe vers la droite = marquer comme lu + onMarkAsRead?.call(); + return false; // Ne pas supprimer le widget + } else { + // Swipe vers la gauche = supprimer + return await _confirmDelete(context); + } + }, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: alert.isRead ? Colors.white : Colors.blue.shade50, + elevation: alert.isRead ? 1 : 2, + child: ListTile( + leading: _buildIcon(), + title: Text( + alert.message, + style: TextStyle( + fontWeight: alert.isRead ? FontWeight.normal : FontWeight.bold, + fontSize: 14, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + _formatDate(alert.createdAt), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + if (alert.isResolved) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.check_circle, size: 14, color: Colors.green), + const SizedBox(width: 4), + Text( + 'Résolu', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ], + ), + trailing: !alert.isRead + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getSeverityColor(alert.severity), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Nouveau', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ) + : null, + onTap: onTap, + ), + ), + ); + } + + Widget _buildSwipeBackground(Color color, IconData icon, Alignment alignment) { + return Container( + color: color, + alignment: alignment, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Icon(icon, color: Colors.white, size: 28), + ); + } + + Widget _buildIcon() { + IconData iconData; + Color iconColor; + + switch (alert.type) { + case AlertType.eventCreated: + case AlertType.eventModified: + case AlertType.eventAssigned: + iconData = Icons.event; + iconColor = Colors.blue; + break; + case AlertType.workforceAdded: + iconData = Icons.group_add; + iconColor = Colors.green; + break; + case AlertType.eventCancelled: + iconData = Icons.event_busy; + iconColor = Colors.red; + break; + case AlertType.maintenanceDue: + case AlertType.maintenanceReminder: + iconData = Icons.build; + iconColor = Colors.orange; + break; + case AlertType.lost: + iconData = Icons.error; + iconColor = Colors.red; + break; + case AlertType.equipmentMissing: + iconData = Icons.warning; + iconColor = Colors.orange; + break; + case AlertType.lowStock: + iconData = Icons.inventory_2; + iconColor = Colors.orange; + break; + case AlertType.conflict: + iconData = Icons.error_outline; + iconColor = Colors.red; + break; + case AlertType.quantityMismatch: + iconData = Icons.compare_arrows; + iconColor = Colors.orange; + break; + case AlertType.damaged: + iconData = Icons.broken_image; + iconColor = Colors.red; + break; + } + + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(iconData, color: iconColor, size: 24), + ); + } + + Color _getSeverityColor(AlertSeverity severity) { + switch (severity) { + case AlertSeverity.info: + return Colors.blue; + case AlertSeverity.warning: + return Colors.orange; + case AlertSeverity.critical: + return Colors.red; + } + } + + String _formatDate(DateTime date) { + // TODO: Utiliser timeago une fois la dépendance ajoutée + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inSeconds < 60) { + return 'À l\'instant'; + } else if (difference.inMinutes < 60) { + return 'Il y a ${difference.inMinutes} min'; + } else if (difference.inHours < 24) { + return 'Il y a ${difference.inHours}h'; + } else if (difference.inDays < 7) { + return 'Il y a ${difference.inDays}j'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } + + Future _confirmDelete(BuildContext context) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer l\'alerte ?'), + content: const Text('Cette action est irréversible.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + onDelete?.call(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ) ?? + false; + } +} + diff --git a/em2rp/lib/views/widgets/nav/custom_app_bar.dart b/em2rp/lib/views/widgets/nav/custom_app_bar.dart index 131bcce..9166235 100644 --- a/em2rp/lib/views/widgets/nav/custom_app_bar.dart +++ b/em2rp/lib/views/widgets/nav/custom_app_bar.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/utils/colors.dart'; +import '../notification_badge.dart' show NotificationBadge; + class CustomAppBar extends StatefulWidget implements PreferredSizeWidget { final String title; final List? actions; @@ -29,6 +31,7 @@ class _CustomAppBarState extends State { title: Text(widget.title), backgroundColor: AppColors.rouge, actions: [ + NotificationBadge(), if (widget.showLogoutButton) IconButton( icon: const Icon(Icons.logout, color: AppColors.blanc), diff --git a/em2rp/lib/views/widgets/notification_badge.dart b/em2rp/lib/views/widgets/notification_badge.dart new file mode 100644 index 0000000..eb33c81 --- /dev/null +++ b/em2rp/lib/views/widgets/notification_badge.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/services/alert_service.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:provider/provider.dart'; + +/// Badge de notifications dans l'AppBar +class NotificationBadge extends StatelessWidget { + const NotificationBadge({super.key}); + + @override + Widget build(BuildContext context) { + final localUserProvider = context.watch(); + final userId = localUserProvider.currentUser?.uid; + + if (userId == null) { + return const SizedBox.shrink(); + } + + return StreamBuilder( + stream: AlertService().unreadCountStreamForUser(userId), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + + return Badge( + label: Text('$count'), + isLabelVisible: count > 0, + backgroundColor: Colors.red, + textColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.notifications), + onPressed: () => _openAlertsPage(context), + tooltip: 'Notifications', + ), + ); + }, + ); + } + + void _openAlertsPage(BuildContext context) { + Navigator.of(context).pushNamed('/alerts'); + } +} + diff --git a/em2rp/lib/views/widgets/notification_preferences_widget.dart b/em2rp/lib/views/widgets/notification_preferences_widget.dart new file mode 100644 index 0000000..dd977b7 --- /dev/null +++ b/em2rp/lib/views/widgets/notification_preferences_widget.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/notification_preferences_model.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:provider/provider.dart'; + +/// Widget pour afficher et modifier les préférences de notifications +class NotificationPreferencesWidget extends StatefulWidget { + const NotificationPreferencesWidget({super.key}); + + @override + State createState() => _NotificationPreferencesWidgetState(); +} + +class _NotificationPreferencesWidgetState extends State { + // État local pour feedback immédiat + NotificationPreferences? _localPrefs; + bool _isSaving = false; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, userProvider, _) { + final user = userProvider.currentUser; + if (user == null) return const SizedBox.shrink(); + + // Utiliser les prefs locales si disponibles, sinon les prefs du user + final prefs = _localPrefs ?? user.notificationPreferences ?? NotificationPreferences.defaults(); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre section + Row( + children: [ + Icon(Icons.notifications, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + 'Préférences de notifications', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + if (_isSaving) ...[ + const SizedBox(width: 8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + 'Choisissez comment vous souhaitez être notifié', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + const Divider(height: 24), + + // Canaux de notification + Text( + 'Canaux de notification', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + + _buildSwitchTile( + context, + title: 'Notifications in-app', + subtitle: 'Alertes dans l\'application', + value: prefs.inAppEnabled, + icon: Icons.app_settings_alt, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(inAppEnabled: value), + ), + ), + + _buildSwitchTile( + context, + title: 'Notifications email', + subtitle: 'Recevoir des emails', + value: prefs.emailEnabled, + icon: Icons.email, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(emailEnabled: value), + ), + ), + + _buildSwitchTile( + context, + title: 'Notifications push', + subtitle: 'Notifications navigateur', + value: prefs.pushEnabled, + icon: Icons.notifications_active, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(pushEnabled: value), + ), + ), + + const Divider(height: 24), + + // Types de notifications + Text( + 'Types de notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + + _buildSwitchTile( + context, + title: 'Événements', + subtitle: 'Création, modification, assignations', + value: prefs.eventsNotifications, + icon: Icons.event, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(eventsNotifications: value), + ), + ), + + _buildSwitchTile( + context, + title: 'Maintenance', + subtitle: 'Rappels de maintenance', + value: prefs.maintenanceNotifications, + icon: Icons.build, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(maintenanceNotifications: value), + ), + ), + + _buildSwitchTile( + context, + title: 'Stock', + subtitle: 'Stock faible, quantités', + value: prefs.stockNotifications, + icon: Icons.inventory_2, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(stockNotifications: value), + ), + ), + + _buildSwitchTile( + context, + title: 'Équipement', + subtitle: 'Perdu, manquant, conflits', + value: prefs.equipmentNotifications, + icon: Icons.warning, + onChanged: (value) => _updatePrefs( + context, + prefs.copyWith(equipmentNotifications: value), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildSwitchTile( + BuildContext context, { + required String title, + required String subtitle, + required bool value, + required IconData icon, + required ValueChanged onChanged, + }) { + return SwitchListTile( + secondary: Icon(icon, color: value ? Theme.of(context).primaryColor : Colors.grey), + title: Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + value: value, + onChanged: _isSaving ? null : onChanged, // Désactiver pendant sauvegarde + activeColor: Theme.of(context).primaryColor, + inactiveThumbColor: Colors.grey.shade400, // Couleur visible quand OFF + inactiveTrackColor: Colors.grey.shade300, // Track visible quand OFF + contentPadding: EdgeInsets.zero, + dense: true, + ); + } + + Future _updatePrefs(BuildContext context, NotificationPreferences newPrefs) async { + // Mise à jour locale immédiate pour feedback visuel + setState(() { + _localPrefs = newPrefs; + _isSaving = true; + }); + + final userProvider = context.read(); + + try { + await userProvider.updateNotificationPreferences(newPrefs); + + if (mounted) { + setState(() { + _isSaving = false; + _localPrefs = null; // Revenir aux prefs du provider + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Préférences enregistrées'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + _localPrefs = null; // Rollback en cas d'erreur + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur : $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} + diff --git a/em2rp/node_modules/.bin/JSONStream b/em2rp/node_modules/.bin/JSONStream new file mode 100644 index 0000000..936e297 --- /dev/null +++ b/em2rp/node_modules/.bin/JSONStream @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../JSONStream/bin.js" "$@" +else + exec node "$basedir/../JSONStream/bin.js" "$@" +fi diff --git a/em2rp/node_modules/.bin/JSONStream.cmd b/em2rp/node_modules/.bin/JSONStream.cmd new file mode 100644 index 0000000..6b131c0 --- /dev/null +++ b/em2rp/node_modules/.bin/JSONStream.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\JSONStream\bin.js" %* diff --git a/em2rp/node_modules/.bin/JSONStream.ps1 b/em2rp/node_modules/.bin/JSONStream.ps1 new file mode 100644 index 0000000..05f9d8a --- /dev/null +++ b/em2rp/node_modules/.bin/JSONStream.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../JSONStream/bin.js" $args + } else { + & "$basedir/node$exe" "$basedir/../JSONStream/bin.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../JSONStream/bin.js" $args + } else { + & "node$exe" "$basedir/../JSONStream/bin.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/base64url b/em2rp/node_modules/.bin/base64url new file mode 100644 index 0000000..a47b4cf --- /dev/null +++ b/em2rp/node_modules/.bin/base64url @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../base64url/bin/base64url" "$@" +else + exec node "$basedir/../base64url/bin/base64url" "$@" +fi diff --git a/em2rp/node_modules/.bin/base64url.cmd b/em2rp/node_modules/.bin/base64url.cmd new file mode 100644 index 0000000..e540ebc --- /dev/null +++ b/em2rp/node_modules/.bin/base64url.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\base64url\bin\base64url" %* diff --git a/em2rp/node_modules/.bin/base64url.ps1 b/em2rp/node_modules/.bin/base64url.ps1 new file mode 100644 index 0000000..56bbb54 --- /dev/null +++ b/em2rp/node_modules/.bin/base64url.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../base64url/bin/base64url" $args + } else { + & "$basedir/node$exe" "$basedir/../base64url/bin/base64url" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../base64url/bin/base64url" $args + } else { + & "node$exe" "$basedir/../base64url/bin/base64url" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/gcs-upload b/em2rp/node_modules/.bin/gcs-upload new file mode 100644 index 0000000..e241c39 --- /dev/null +++ b/em2rp/node_modules/.bin/gcs-upload @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../gcs-resumable-upload/cli.js" "$@" +else + exec node "$basedir/../gcs-resumable-upload/cli.js" "$@" +fi diff --git a/em2rp/node_modules/.bin/gcs-upload.cmd b/em2rp/node_modules/.bin/gcs-upload.cmd new file mode 100644 index 0000000..07c666a --- /dev/null +++ b/em2rp/node_modules/.bin/gcs-upload.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\gcs-resumable-upload\cli.js" %* diff --git a/em2rp/node_modules/.bin/gcs-upload.ps1 b/em2rp/node_modules/.bin/gcs-upload.ps1 new file mode 100644 index 0000000..666a80b --- /dev/null +++ b/em2rp/node_modules/.bin/gcs-upload.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../gcs-resumable-upload/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/../gcs-resumable-upload/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../gcs-resumable-upload/cli.js" $args + } else { + & "node$exe" "$basedir/../gcs-resumable-upload/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/gp12-pem b/em2rp/node_modules/.bin/gp12-pem new file mode 100644 index 0000000..8833733 --- /dev/null +++ b/em2rp/node_modules/.bin/gp12-pem @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../google-p12-pem/bin/gp12-pem" "$@" +else + exec node "$basedir/../google-p12-pem/bin/gp12-pem" "$@" +fi diff --git a/em2rp/node_modules/.bin/gp12-pem.cmd b/em2rp/node_modules/.bin/gp12-pem.cmd new file mode 100644 index 0000000..3046baa --- /dev/null +++ b/em2rp/node_modules/.bin/gp12-pem.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\google-p12-pem\bin\gp12-pem" %* diff --git a/em2rp/node_modules/.bin/gp12-pem.ps1 b/em2rp/node_modules/.bin/gp12-pem.ps1 new file mode 100644 index 0000000..03fd282 --- /dev/null +++ b/em2rp/node_modules/.bin/gp12-pem.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../google-p12-pem/bin/gp12-pem" $args + } else { + & "$basedir/node$exe" "$basedir/../google-p12-pem/bin/gp12-pem" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../google-p12-pem/bin/gp12-pem" $args + } else { + & "node$exe" "$basedir/../google-p12-pem/bin/gp12-pem" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/indent-string b/em2rp/node_modules/.bin/indent-string new file mode 100644 index 0000000..beade61 --- /dev/null +++ b/em2rp/node_modules/.bin/indent-string @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../indent-string/cli.js" "$@" +else + exec node "$basedir/../indent-string/cli.js" "$@" +fi diff --git a/em2rp/node_modules/.bin/indent-string.cmd b/em2rp/node_modules/.bin/indent-string.cmd new file mode 100644 index 0000000..383f7f9 --- /dev/null +++ b/em2rp/node_modules/.bin/indent-string.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\indent-string\cli.js" %* diff --git a/em2rp/node_modules/.bin/indent-string.ps1 b/em2rp/node_modules/.bin/indent-string.ps1 new file mode 100644 index 0000000..762a80f --- /dev/null +++ b/em2rp/node_modules/.bin/indent-string.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../indent-string/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/../indent-string/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../indent-string/cli.js" $args + } else { + & "node$exe" "$basedir/../indent-string/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/pbjs b/em2rp/node_modules/.bin/pbjs new file mode 100644 index 0000000..31d6f61 --- /dev/null +++ b/em2rp/node_modules/.bin/pbjs @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../protobufjs/bin/pbjs" "$@" +else + exec node "$basedir/../protobufjs/bin/pbjs" "$@" +fi diff --git a/em2rp/node_modules/.bin/pbjs.cmd b/em2rp/node_modules/.bin/pbjs.cmd new file mode 100644 index 0000000..ff8dd49 --- /dev/null +++ b/em2rp/node_modules/.bin/pbjs.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\protobufjs\bin\pbjs" %* diff --git a/em2rp/node_modules/.bin/pbjs.ps1 b/em2rp/node_modules/.bin/pbjs.ps1 new file mode 100644 index 0000000..aa887b5 --- /dev/null +++ b/em2rp/node_modules/.bin/pbjs.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../protobufjs/bin/pbjs" $args + } else { + & "$basedir/node$exe" "$basedir/../protobufjs/bin/pbjs" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../protobufjs/bin/pbjs" $args + } else { + & "node$exe" "$basedir/../protobufjs/bin/pbjs" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/repeating b/em2rp/node_modules/.bin/repeating new file mode 100644 index 0000000..2b12b20 --- /dev/null +++ b/em2rp/node_modules/.bin/repeating @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../repeating/cli.js" "$@" +else + exec node "$basedir/../repeating/cli.js" "$@" +fi diff --git a/em2rp/node_modules/.bin/repeating.cmd b/em2rp/node_modules/.bin/repeating.cmd new file mode 100644 index 0000000..e1f78bb --- /dev/null +++ b/em2rp/node_modules/.bin/repeating.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\repeating\cli.js" %* diff --git a/em2rp/node_modules/.bin/repeating.ps1 b/em2rp/node_modules/.bin/repeating.ps1 new file mode 100644 index 0000000..8dd8cad --- /dev/null +++ b/em2rp/node_modules/.bin/repeating.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../repeating/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/../repeating/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../repeating/cli.js" $args + } else { + & "node$exe" "$basedir/../repeating/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/sshpk-conv b/em2rp/node_modules/.bin/sshpk-conv new file mode 100644 index 0000000..bea6043 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-conv @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../sshpk/bin/sshpk-conv" "$@" +else + exec node "$basedir/../sshpk/bin/sshpk-conv" "$@" +fi diff --git a/em2rp/node_modules/.bin/sshpk-conv.cmd b/em2rp/node_modules/.bin/sshpk-conv.cmd new file mode 100644 index 0000000..2bdc325 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-conv.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\sshpk\bin\sshpk-conv" %* diff --git a/em2rp/node_modules/.bin/sshpk-conv.ps1 b/em2rp/node_modules/.bin/sshpk-conv.ps1 new file mode 100644 index 0000000..a8e820e --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-conv.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-conv" $args + } else { + & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-conv" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../sshpk/bin/sshpk-conv" $args + } else { + & "node$exe" "$basedir/../sshpk/bin/sshpk-conv" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/sshpk-sign b/em2rp/node_modules/.bin/sshpk-sign new file mode 100644 index 0000000..83df8f2 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-sign @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../sshpk/bin/sshpk-sign" "$@" +else + exec node "$basedir/../sshpk/bin/sshpk-sign" "$@" +fi diff --git a/em2rp/node_modules/.bin/sshpk-sign.cmd b/em2rp/node_modules/.bin/sshpk-sign.cmd new file mode 100644 index 0000000..7323578 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-sign.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\sshpk\bin\sshpk-sign" %* diff --git a/em2rp/node_modules/.bin/sshpk-sign.ps1 b/em2rp/node_modules/.bin/sshpk-sign.ps1 new file mode 100644 index 0000000..0de3957 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-sign.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-sign" $args + } else { + & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-sign" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../sshpk/bin/sshpk-sign" $args + } else { + & "node$exe" "$basedir/../sshpk/bin/sshpk-sign" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/sshpk-verify b/em2rp/node_modules/.bin/sshpk-verify new file mode 100644 index 0000000..8efcddf --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-verify @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../sshpk/bin/sshpk-verify" "$@" +else + exec node "$basedir/../sshpk/bin/sshpk-verify" "$@" +fi diff --git a/em2rp/node_modules/.bin/sshpk-verify.cmd b/em2rp/node_modules/.bin/sshpk-verify.cmd new file mode 100644 index 0000000..b0c43cb --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-verify.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\sshpk\bin\sshpk-verify" %* diff --git a/em2rp/node_modules/.bin/sshpk-verify.ps1 b/em2rp/node_modules/.bin/sshpk-verify.ps1 new file mode 100644 index 0000000..8370785 --- /dev/null +++ b/em2rp/node_modules/.bin/sshpk-verify.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-verify" $args + } else { + & "$basedir/node$exe" "$basedir/../sshpk/bin/sshpk-verify" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../sshpk/bin/sshpk-verify" $args + } else { + & "node$exe" "$basedir/../sshpk/bin/sshpk-verify" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/uuid b/em2rp/node_modules/.bin/uuid index 0c2d469..5ba3622 100644 --- a/em2rp/node_modules/.bin/uuid +++ b/em2rp/node_modules/.bin/uuid @@ -10,7 +10,7 @@ case `uname` in esac if [ -x "$basedir/node" ]; then - exec "$basedir/node" "$basedir/../uuid/dist/bin/uuid" "$@" + exec "$basedir/node" "$basedir/../node-uuid/bin/uuid" "$@" else - exec node "$basedir/../uuid/dist/bin/uuid" "$@" + exec node "$basedir/../node-uuid/bin/uuid" "$@" fi diff --git a/em2rp/node_modules/.bin/uuid.cmd b/em2rp/node_modules/.bin/uuid.cmd index 0f2376e..ed2b59e 100644 --- a/em2rp/node_modules/.bin/uuid.cmd +++ b/em2rp/node_modules/.bin/uuid.cmd @@ -14,4 +14,4 @@ IF EXIST "%dp0%\node.exe" ( SET PATHEXT=%PATHEXT:;.JS;=;% ) -endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\uuid\dist\bin\uuid" %* +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\node-uuid\bin\uuid" %* diff --git a/em2rp/node_modules/.bin/uuid.ps1 b/em2rp/node_modules/.bin/uuid.ps1 index 7804628..572b713 100644 --- a/em2rp/node_modules/.bin/uuid.ps1 +++ b/em2rp/node_modules/.bin/uuid.ps1 @@ -11,17 +11,17 @@ $ret=0 if (Test-Path "$basedir/node$exe") { # Support pipeline input if ($MyInvocation.ExpectingInput) { - $input | & "$basedir/node$exe" "$basedir/../uuid/dist/bin/uuid" $args + $input | & "$basedir/node$exe" "$basedir/../node-uuid/bin/uuid" $args } else { - & "$basedir/node$exe" "$basedir/../uuid/dist/bin/uuid" $args + & "$basedir/node$exe" "$basedir/../node-uuid/bin/uuid" $args } $ret=$LASTEXITCODE } else { # Support pipeline input if ($MyInvocation.ExpectingInput) { - $input | & "node$exe" "$basedir/../uuid/dist/bin/uuid" $args + $input | & "node$exe" "$basedir/../node-uuid/bin/uuid" $args } else { - & "node$exe" "$basedir/../uuid/dist/bin/uuid" $args + & "node$exe" "$basedir/../node-uuid/bin/uuid" $args } $ret=$LASTEXITCODE } diff --git a/em2rp/node_modules/.bin/window-size b/em2rp/node_modules/.bin/window-size new file mode 100644 index 0000000..03baf6b --- /dev/null +++ b/em2rp/node_modules/.bin/window-size @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../window-size/cli.js" "$@" +else + exec node "$basedir/../window-size/cli.js" "$@" +fi diff --git a/em2rp/node_modules/.bin/window-size.cmd b/em2rp/node_modules/.bin/window-size.cmd new file mode 100644 index 0000000..b28b1dd --- /dev/null +++ b/em2rp/node_modules/.bin/window-size.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\window-size\cli.js" %* diff --git a/em2rp/node_modules/.bin/window-size.ps1 b/em2rp/node_modules/.bin/window-size.ps1 new file mode 100644 index 0000000..c71b6fa --- /dev/null +++ b/em2rp/node_modules/.bin/window-size.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../window-size/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/../window-size/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../window-size/cli.js" $args + } else { + & "node$exe" "$basedir/../window-size/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/node_modules/.bin/zonefile b/em2rp/node_modules/.bin/zonefile new file mode 100644 index 0000000..c6dc4e6 --- /dev/null +++ b/em2rp/node_modules/.bin/zonefile @@ -0,0 +1,16 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../dns-zonefile/bin/zonefile" "$@" +else + exec node "$basedir/../dns-zonefile/bin/zonefile" "$@" +fi diff --git a/em2rp/node_modules/.bin/zonefile.cmd b/em2rp/node_modules/.bin/zonefile.cmd new file mode 100644 index 0000000..93f9e50 --- /dev/null +++ b/em2rp/node_modules/.bin/zonefile.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\dns-zonefile\bin\zonefile" %* diff --git a/em2rp/node_modules/.bin/zonefile.ps1 b/em2rp/node_modules/.bin/zonefile.ps1 new file mode 100644 index 0000000..21ad212 --- /dev/null +++ b/em2rp/node_modules/.bin/zonefile.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/../dns-zonefile/bin/zonefile" $args + } else { + & "$basedir/node$exe" "$basedir/../dns-zonefile/bin/zonefile" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/../dns-zonefile/bin/zonefile" $args + } else { + & "node$exe" "$basedir/../dns-zonefile/bin/zonefile" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 2026466..8224c1a 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: firebase_core: ^4.2.0 firebase_auth: ^6.1.1 cloud_firestore: ^6.0.3 + cloud_functions: ^6.0.4 google_sign_in: ^7.2.0 provider: ^6.1.2 firebase_storage: ^13.0.3 @@ -55,6 +56,7 @@ dependencies: flutter_dropzone: ^4.2.1 flutter_localizations: sdk: flutter + timeago: ^3.6.1 path: any dev_dependencies: