From 980678163566544933cedf9a46732fa9ec553856 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 9 Apr 2026 16:20:05 -0700 Subject: [PATCH 1/4] RE1-T112 Adding Weather Alerts --- .../User/WeatherAlerts/WeatherAlerts.ar.resx | 454 ++++++++++ .../Areas/User/WeatherAlerts/WeatherAlerts.cs | 12 + .../User/WeatherAlerts/WeatherAlerts.de.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.en.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.es.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.fr.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.it.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.pl.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.sv.resx | 454 ++++++++++ .../User/WeatherAlerts/WeatherAlerts.uk.resx | 454 ++++++++++ Core/Resgrid.Model/AuditLogTypes.cs | 14 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 5 + Core/Resgrid.Model/Events/EventTypes.cs | 12 +- .../Providers/IWeatherAlertProvider.cs | 12 + .../Providers/IWeatherAlertProviderFactory.cs | 7 + .../Repositories/IWeatherAlertRepository.cs | 17 + .../IWeatherAlertSourceRepository.cs | 12 + .../IWeatherAlertZoneRepository.cs | 12 + .../Services/IDepartmentSettingsService.cs | 5 + .../Services/IWeatherAlertService.cs | 42 + Core/Resgrid.Model/WeatherAlert.cs | 103 +++ Core/Resgrid.Model/WeatherAlertCategory.cs | 11 + Core/Resgrid.Model/WeatherAlertCertainty.cs | 11 + Core/Resgrid.Model/WeatherAlertSeverity.cs | 11 + Core/Resgrid.Model/WeatherAlertSource.cs | 85 ++ Core/Resgrid.Model/WeatherAlertSourceType.cs | 9 + Core/Resgrid.Model/WeatherAlertStatus.cs | 10 + Core/Resgrid.Model/WeatherAlertUrgency.cs | 11 + Core/Resgrid.Model/WeatherAlertZone.cs | 60 ++ .../DepartmentSettingsService.cs | 5 + Core/Resgrid.Services/ServicesModule.cs | 3 + Core/Resgrid.Services/WeatherAlertService.cs | 623 ++++++++++++++ .../Resgrid.Providers.Claims/ClaimsLogic.cs | 15 + .../ClaimsPrincipalFactory.cs | 1 + .../JwtTokenProvider.cs | 1 + .../ResgridClaimTypes.cs | 1 + .../ResgridIdentity.cs | 5 + .../ResgridResources.cs | 5 + .../Migrations/M0063_AddingWeatherAlerts.cs | 122 +++ .../Migrations/M0063_AddingWeatherAlertsPg.cs | 126 +++ .../EnvironmentCanadaWeatherAlertProvider.cs | 32 + .../MeteoAlarmWeatherAlertProvider.cs | 32 + .../NwsWeatherAlertProvider.cs | 323 +++++++ .../Resgrid.Providers.Weather.csproj | 16 + .../WeatherAlertProviderFactory.cs | 26 + .../WeatherAlertResponseCache.cs | 53 ++ .../WeatherProviderModule.cs | 16 + .../Configs/SqlConfiguration.cs | 17 + .../Modules/ApiDataModule.cs | 3 + .../Modules/DataModule.cs | 3 + .../Modules/NonWebDataModule.cs | 3 + .../Modules/TestingDataModule.cs | 3 + ...ctiveWeatherAlertSourcesForPollingQuery.cs | 33 + ...iveWeatherAlertZonesByDepartmentIdQuery.cs | 33 + ...tActiveWeatherAlertsByDepartmentIdQuery.cs | 33 + ...ectExpiredUnprocessedWeatherAlertsQuery.cs | 33 + .../SelectUnnotifiedWeatherAlertsQuery.cs | 33 + ...eatherAlertByExternalIdAndSourceIdQuery.cs | 33 + ...ectWeatherAlertHistoryByDepartmentQuery.cs | 33 + ...tWeatherAlertSourcesByDepartmentIdQuery.cs | 33 + ...ectWeatherAlertZonesByDepartmentIdQuery.cs | 33 + ...atherAlertsByDepartmentAndCategoryQuery.cs | 33 + ...atherAlertsByDepartmentAndSeverityQuery.cs | 33 + .../PostgreSql/PostgreSqlConfiguration.cs | 17 + .../SqlServer/SqlServerConfiguration.cs | 17 + .../WeatherAlertRepository.cs | 301 +++++++ .../WeatherAlertSourceRepository.cs | 107 +++ .../WeatherAlertZoneRepository.cs | 108 +++ Resgrid.sln | 441 ++++++++++ .../Providers/NwsWeatherAlertProviderTests.cs | 575 +++++++++++++ Tests/Resgrid.Tests/Resgrid.Tests.csproj | 1 + .../Services/WeatherAlertServiceTests.cs | 788 ++++++++++++++++++ Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs | 30 + .../Controllers/v4/CallsController.cs | 11 +- .../Controllers/v4/WeatherAlertsController.cs | 502 +++++++++++ .../GetActiveWeatherAlertsResult.cs | 14 + .../v4/WeatherAlerts/GetWeatherAlertResult.cs | 7 + .../GetWeatherAlertSettingsResult.cs | 7 + .../GetWeatherAlertSourcesResult.cs | 14 + .../GetWeatherAlertZonesResult.cs | 14 + .../SaveWeatherAlertSettingsInput.cs | 10 + .../SaveWeatherAlertSourceInput.cs | 14 + .../SaveWeatherAlertZoneInput.cs | 13 + .../WeatherAlerts/WeatherAlertResultData.cs | 33 + .../WeatherAlerts/WeatherAlertSettingsData.cs | 10 + .../WeatherAlertSourceResultData.cs | 19 + .../WeatherAlertZoneResultData.cs | 14 + .../Resgrid.Web.Services.csproj | 1 + .../Resgrid.Web.Services.xml | 80 +- Web/Resgrid.Web.Services/Startup.cs | 6 + .../User/Controllers/DispatchController.cs | 10 +- .../Controllers/WeatherAlertsController.cs | 81 ++ .../User/Views/Notifications/Index.cshtml | 15 +- .../Areas/User/Views/Shared/_TopNavbar.cshtml | 1 + .../User/Views/WeatherAlerts/Details.cshtml | 94 +++ .../User/Views/WeatherAlerts/History.cshtml | 176 ++++ .../User/Views/WeatherAlerts/Index.cshtml | 99 +++ .../User/Views/WeatherAlerts/Settings.cshtml | 440 ++++++++++ .../User/Views/WeatherAlerts/Zones.cshtml | 278 ++++++ Web/Resgrid.Web/Resgrid.Web.csproj | 1 + Web/Resgrid.Web/Startup.cs | 1 + .../Commands/WeatherAlertImportCommand.cs | 18 + Workers/Resgrid.Workers.Console/Program.cs | 6 + .../Tasks/WeatherAlertImportTask.cs | 51 ++ .../Resgrid.Workers.Framework/Bootstrapper.cs | 1 + .../Resgrid.Workers.Framework.csproj | 1 + 106 files changed, 10758 insertions(+), 13 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.cs create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.uk.resx create mode 100644 Core/Resgrid.Model/Providers/IWeatherAlertProvider.cs create mode 100644 Core/Resgrid.Model/Providers/IWeatherAlertProviderFactory.cs create mode 100644 Core/Resgrid.Model/Repositories/IWeatherAlertRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IWeatherAlertSourceRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IWeatherAlertZoneRepository.cs create mode 100644 Core/Resgrid.Model/Services/IWeatherAlertService.cs create mode 100644 Core/Resgrid.Model/WeatherAlert.cs create mode 100644 Core/Resgrid.Model/WeatherAlertCategory.cs create mode 100644 Core/Resgrid.Model/WeatherAlertCertainty.cs create mode 100644 Core/Resgrid.Model/WeatherAlertSeverity.cs create mode 100644 Core/Resgrid.Model/WeatherAlertSource.cs create mode 100644 Core/Resgrid.Model/WeatherAlertSourceType.cs create mode 100644 Core/Resgrid.Model/WeatherAlertStatus.cs create mode 100644 Core/Resgrid.Model/WeatherAlertUrgency.cs create mode 100644 Core/Resgrid.Model/WeatherAlertZone.cs create mode 100644 Core/Resgrid.Services/WeatherAlertService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0063_AddingWeatherAlerts.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0063_AddingWeatherAlertsPg.cs create mode 100644 Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs create mode 100644 Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs create mode 100644 Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs create mode 100644 Providers/Resgrid.Providers.Weather/Resgrid.Providers.Weather.csproj create mode 100644 Providers/Resgrid.Providers.Weather/WeatherAlertProviderFactory.cs create mode 100644 Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs create mode 100644 Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertSourcesForPollingQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertZonesByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertsByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectExpiredUnprocessedWeatherAlertsQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectUnnotifiedWeatherAlertsQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertByExternalIdAndSourceIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertHistoryByDepartmentQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertSourcesByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertZonesByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndCategoryQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndSeverityQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WeatherAlertRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WeatherAlertSourceRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs create mode 100644 Tests/Resgrid.Tests/Providers/NwsWeatherAlertProviderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetActiveWeatherAlertsResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSettingsResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSourcesResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertZonesResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSourceInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertZoneInput.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertZoneResultData.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WeatherAlertsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Zones.cshtml create mode 100644 Workers/Resgrid.Workers.Console/Commands/WeatherAlertImportCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.ar.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.ar.resx new file mode 100644 index 00000000..c6ba7d56 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.ar.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + تنبيهات الطقس + + + تنبيهات الطقس + + + تنبيهات الطقس النشطة + + + سجل التنبيهات + + + مصادر التنبيهات + + + مناطق المراقبة + + + الإعدادات + + + عرض السجل + + + العودة إلى التنبيهات + + + العودة إلى الإعدادات + + + إدارة المناطق + + + التفاصيل + + + لا توجد تنبيهات طقس نشطة لقسمك. + + + تعذر تحميل تنبيهات الطقس. يرجى التحقق من الإعدادات. + + + جاري تحميل تنبيهات الطقس... + + + جاري تحميل السجل... + + + الحدث + + + الخطورة + + + المنطقة + + + ينتهي + + + الحالة + + + الفئة + + + الإجراءات + + + شديدة للغاية + + + شديدة + + + معتدلة + + + طفيفة + + + غير معروفة + + + فورية + + + متوقعة + + + مستقبلية + + + سابقة + + + غير معروفة + + + أرصاد جوية + + + حريق + + + صحة + + + بيئية + + + أخرى + + + نشط + + + محدّث + + + منتهي + + + ملغى + + + خدمة الطقس الوطنية + + + البيئة الكندية + + + MeteoAlarm (أوروبا) + + + إضافة مصدر + + + اسم المصدر + + + نوع المصدر + + + فلتر المنطقة (أكواد مناطق JSON) + + + فترة الاستعلام (دقائق) + + + حفظ + + + إلغاء + + + حذف + + + هل أنت متأكد أنك تريد حذف هذا المصدر؟ + + + فشل في حفظ المصدر. يرجى المحاولة مرة أخرى. + + + فشل في حذف المصدر. يرجى المحاولة مرة أخرى. + + + إضافة منطقة مراقبة + + + تعديل منطقة المراقبة + + + اسم المنطقة + + + رمز المنطقة + + + الموقع المركزي + + + نصف القطر (أميال) + + + المنطقة الرئيسية + + + نشطة + + + غير نشطة + + + رئيسية + + + تفعيل + + + تعطيل + + + تعديل + + + لم يتم تكوين مناطق مراقبة. أضف منطقة أدناه لتحديد المناطق الجغرافية التي تريد مراقبتها لتنبيهات الطقس. + + + هل أنت متأكد أنك تريد حذف هذه المنطقة؟ + + + فشل في حفظ المنطقة. يرجى المحاولة مرة أخرى. + + + فشل في حذف المنطقة. يرجى المحاولة مرة أخرى. + + + فشل في تحديث حالة المنطقة. يرجى المحاولة مرة أخرى. + + + يرجى إدخال اسم المنطقة. + + + حفظ المنطقة + + + الخيارات + + + تاريخ البداية + + + تاريخ النهاية + + + بحث + + + يرجى تحديد تاريخ البداية والنهاية. + + + لم يتم العثور على سجل تنبيهات للفترة المحددة. + + + تعذر تحميل سجل التنبيهات. + + + تفاصيل التنبيه + + + العنوان + + + الوصف + + + التعليمات + + + وصف المنطقة + + + الإلحاح + + + اليقين + + + تاريخ السريان + + + تاريخ البدء + + + تاريخ الانتهاء + + + تاريخ الإرسال + + + أول اكتشاف + + + آخر تحديث + + + المرسل + + + المعرف الخارجي + + + تم إرسال الإشعار + + + نعم + + + لا + + + المصادر المكوّنة + + + فترة الاستعلام + + + آخر استعلام + + + الحالة + + + سليم + + + خطأ + + + لم يتم الاستعلام مطلقاً + + + لم يتم تكوين مصادر تنبيهات الطقس. أضف مصدراً لبدء تلقي تنبيهات الطقس. + + + تنبيه الطقس: {0} + + + ينتهي: {0} + + + المنطقة: {0} + + + التعليمات: {0} + + + إعدادات الإشعارات + + + قم بتكوين تنبيهات الطقس التي تولّد رسائل تلقائياً لأعضاء القسم. + + + تفعيل تنبيهات الطقس + + + الحد الأدنى للخطورة للعرض + + + سيتم عرض التنبيهات بهذه الخطورة أو أعلى فقط في لوحة المعلومات. + + + عتبة الخطورة للرسائل التلقائية + + + التنبيهات بهذه الخطورة أو أعلى سترسل تلقائياً رسالة إلى جميع أعضاء القسم. قيمة أقل = خطورة أعلى (شديدة للغاية=0، شديدة=1، معتدلة=2، طفيفة=3). + + + إرفاق سياق الطقس بالمكالمات + + + عند التفعيل، سيتم إرفاق تنبيهات الطقس النشطة القريبة من موقع المكالمة كملاحظة للمكالمة. + + + حفظ الإعدادات + + + تم حفظ الإعدادات بنجاح. + + + فشل في حفظ الإعدادات. يرجى المحاولة مرة أخرى. + + + فشل في تحميل الإعدادات. + + + فشل في تحديث حالة المصدر. يرجى المحاولة مرة أخرى. + + + تعديل المصدر + + + إضافة / تعديل مصدر + + + يرجى إدخال اسم المصدر. + + + قائمة مفصولة بفواصل من رموز الولايات أو رموز المقاطعات أو رموز مناطق NWS. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.cs b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.cs new file mode 100644 index 00000000..a40e3f6b --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Resgrid.Localization.Areas.User.WeatherAlerts +{ + public class WeatherAlerts + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.de.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.de.resx new file mode 100644 index 00000000..fe7ead61 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.de.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Wetterwarnungen + + + Wetterwarnungen + + + Aktive Wetterwarnungen + + + Warnungsverlauf + + + Warnungsquellen + + + Überwachungszonen + + + Einstellungen + + + Verlauf anzeigen + + + Zurück zu Warnungen + + + Zurück zu Einstellungen + + + Zonen verwalten + + + Details + + + Keine aktiven Wetterwarnungen für Ihre Abteilung. + + + Wetterwarnungen konnten nicht geladen werden. Bitte überprüfen Sie Ihre Konfiguration. + + + Wetterwarnungen werden geladen... + + + Verlauf wird geladen... + + + Ereignis + + + Schweregrad + + + Gebiet + + + Läuft ab + + + Status + + + Kategorie + + + Aktionen + + + Extrem + + + Schwer + + + Mäßig + + + Gering + + + Unbekannt + + + Sofort + + + Erwartet + + + Zukünftig + + + Vergangen + + + Unbekannt + + + Meteorologisch + + + Brand + + + Gesundheit + + + Umwelt + + + Sonstige + + + Aktiv + + + Aktualisiert + + + Abgelaufen + + + Storniert + + + Nationaler Wetterdienst + + + Environment Canada + + + MeteoAlarm (Europa) + + + Quelle hinzufügen + + + Quellenname + + + Quellentyp + + + Gebietsfilter (JSON-Zonencodes) + + + Abfrageintervall (Minuten) + + + Speichern + + + Abbrechen + + + Löschen + + + Sind Sie sicher, dass Sie diese Quelle löschen möchten? + + + Quelle konnte nicht gespeichert werden. Bitte versuchen Sie es erneut. + + + Quelle konnte nicht gelöscht werden. Bitte versuchen Sie es erneut. + + + Überwachungszone hinzufügen + + + Überwachungszone bearbeiten + + + Zonenname + + + Zonencode + + + Zentrale Position + + + Radius (Meilen) + + + Primäre Zone + + + Aktiv + + + Inaktiv + + + Primär + + + Aktivieren + + + Deaktivieren + + + Bearbeiten + + + Keine Überwachungszonen konfiguriert. Fügen Sie unten eine Zone hinzu, um die geografischen Gebiete zu definieren, die Sie auf Wetterwarnungen überwachen möchten. + + + Sind Sie sicher, dass Sie diese Zone löschen möchten? + + + Zone konnte nicht gespeichert werden. Bitte versuchen Sie es erneut. + + + Zone konnte nicht gelöscht werden. Bitte versuchen Sie es erneut. + + + Zonenstatus konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut. + + + Bitte geben Sie einen Zonennamen ein. + + + Zone speichern + + + Optionen + + + Startdatum + + + Enddatum + + + Suchen + + + Bitte wählen Sie sowohl ein Start- als auch ein Enddatum. + + + Kein Warnungsverlauf für den ausgewählten Zeitraum gefunden. + + + Warnungsverlauf konnte nicht geladen werden. + + + Warnungsdetail + + + Überschrift + + + Beschreibung + + + Anweisungen + + + Gebietsbeschreibung + + + Dringlichkeit + + + Gewissheit + + + Gültig ab + + + Beginn + + + Ablaufdatum + + + Sendedatum + + + Erstmals gesehen + + + Zuletzt aktualisiert + + + Absender + + + Externe ID + + + Benachrichtigung gesendet + + + Ja + + + Nein + + + Konfigurierte Quellen + + + Abfrageintervall + + + Letzte Abfrage + + + Status + + + Funktionsfähig + + + Fehler + + + Nie abgefragt + + + Keine Wetterwarnungsquellen konfiguriert. Fügen Sie eine Quelle hinzu, um Wetterwarnungen zu empfangen. + + + Wetterwarnung: {0} + + + Läuft ab: {0} + + + Gebiet: {0} + + + Anweisungen: {0} + + + Benachrichtigungseinstellungen + + + Konfigurieren Sie, welche Wetterwarnungen automatisch Nachrichten an Abteilungsmitglieder generieren. + + + Wetterwarnungen aktivieren + + + Mindest-Schweregrad für Anzeige + + + Nur Warnungen mit diesem oder höherem Schweregrad werden im Dashboard angezeigt. + + + Schwellenwert für automatische Nachricht + + + Warnungen mit diesem oder höherem Schweregrad senden automatisch eine Nachricht an alle Abteilungsmitglieder. Niedrigerer Wert = höherer Schweregrad (Extrem=0, Schwer=1, Mäßig=2, Gering=3). + + + Wetterkontext an Einsätze anhängen + + + Wenn aktiviert, werden aktive Wetterwarnungen in der Nähe eines Einsatzortes als Einsatznotiz angehängt. + + + Einstellungen speichern + + + Einstellungen erfolgreich gespeichert. + + + Einstellungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut. + + + Einstellungen konnten nicht geladen werden. + + + Quellenstatus konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut. + + + Quelle bearbeiten + + + Quelle hinzufügen / bearbeiten + + + Bitte geben Sie einen Quellennamen ein. + + + Kommagetrennte Liste von Staatscodes, Provinzcodes oder NWS-Zonencodes. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.en.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.en.resx new file mode 100644 index 00000000..436a8291 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.en.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Weather Alerts + + + Weather Alerts + + + Active Weather Alerts + + + Alert History + + + Alert Sources + + + Monitoring Zones + + + Settings + + + View History + + + Back to Alerts + + + Back to Settings + + + Manage Zones + + + Details + + + No active weather alerts for your department. + + + Unable to load weather alerts. Please check your configuration. + + + Loading weather alerts... + + + Loading history... + + + Event + + + Severity + + + Area + + + Expires + + + Status + + + Category + + + Actions + + + Extreme + + + Severe + + + Moderate + + + Minor + + + Unknown + + + Immediate + + + Expected + + + Future + + + Past + + + Unknown + + + Meteorological + + + Fire + + + Health + + + Environmental + + + Other + + + Active + + + Updated + + + Expired + + + Cancelled + + + National Weather Service + + + Environment Canada + + + MeteoAlarm (Europe) + + + Add Source + + + Source Name + + + Source Type + + + Area Filter (JSON zone codes) + + + Poll Interval (minutes) + + + Save + + + Cancel + + + Delete + + + Are you sure you want to delete this source? + + + Failed to save source. Please try again. + + + Failed to delete source. Please try again. + + + Add Monitoring Zone + + + Edit Monitoring Zone + + + Zone Name + + + Zone Code + + + Center Location + + + Radius (miles) + + + Primary Zone + + + Active + + + Inactive + + + Primary + + + Enable + + + Disable + + + Edit + + + No monitoring zones configured. Add a zone below to define the geographic areas you want to monitor for weather alerts. + + + Are you sure you want to delete this zone? + + + Failed to save zone. Please try again. + + + Failed to delete zone. Please try again. + + + Failed to update zone status. Please try again. + + + Please enter a zone name. + + + Save Zone + + + Options + + + Start Date + + + End Date + + + Search + + + Please select both start and end dates. + + + No alert history found for the selected date range. + + + Unable to load alert history. + + + Alert Detail + + + Headline + + + Description + + + Instructions + + + Area Description + + + Urgency + + + Certainty + + + Effective Date + + + Onset Date + + + Expires Date + + + Sent Date + + + First Seen + + + Last Updated + + + Sender + + + External ID + + + Notification Sent + + + Yes + + + No + + + Configured Sources + + + Poll Interval + + + Last Poll + + + Status + + + Healthy + + + Error + + + Never Polled + + + No weather alert sources configured. Add a source to start receiving weather alerts. + + + Weather Alert: {0} + + + Expires: {0} + + + Area: {0} + + + Instructions: {0} + + + Notification Settings + + + Configure which weather alerts automatically generate messages to department members. + + + Enable Weather Alerts + + + Minimum Severity to Display + + + Only alerts at or above this severity will be shown in the dashboard. + + + Auto-Message Severity Threshold + + + Alerts at or above this severity will automatically send a message to all department members. Lower value = higher severity (Extreme=0, Severe=1, Moderate=2, Minor=3). + + + Attach Weather Context to Calls + + + When enabled, active weather alerts near a call's location will be attached as a call note. + + + Save Settings + + + Settings saved successfully. + + + Failed to save settings. Please try again. + + + Failed to load settings. + + + Failed to update source status. Please try again. + + + Edit Source + + + Add / Edit Source + + + Please enter a source name. + + + Comma-separated list of state codes, province codes, or NWS zone codes. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.es.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.es.resx new file mode 100644 index 00000000..15b51c11 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.es.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Alertas Meteorológicas + + + Alertas Meteorológicas + + + Alertas Meteorológicas Activas + + + Historial de Alertas + + + Fuentes de Alertas + + + Zonas de Monitoreo + + + Configuración + + + Ver Historial + + + Volver a Alertas + + + Volver a Configuración + + + Gestionar Zonas + + + Detalles + + + No hay alertas meteorológicas activas para su departamento. + + + No se pueden cargar las alertas meteorológicas. Verifique su configuración. + + + Cargando alertas meteorológicas... + + + Cargando historial... + + + Evento + + + Severidad + + + Área + + + Expira + + + Estado + + + Categoría + + + Acciones + + + Extrema + + + Severa + + + Moderada + + + Menor + + + Desconocida + + + Inmediata + + + Esperada + + + Futura + + + Pasada + + + Desconocida + + + Meteorológica + + + Incendio + + + Salud + + + Ambiental + + + Otra + + + Activa + + + Actualizada + + + Expirada + + + Cancelada + + + Servicio Meteorológico Nacional + + + Environment Canada + + + MeteoAlarm (Europa) + + + Agregar Fuente + + + Nombre de la Fuente + + + Tipo de Fuente + + + Filtro de Área (códigos de zona JSON) + + + Intervalo de Consulta (minutos) + + + Guardar + + + Cancelar + + + Eliminar + + + ¿Está seguro de que desea eliminar esta fuente? + + + Error al guardar la fuente. Inténtelo de nuevo. + + + Error al eliminar la fuente. Inténtelo de nuevo. + + + Agregar Zona de Monitoreo + + + Editar Zona de Monitoreo + + + Nombre de la Zona + + + Código de Zona + + + Ubicación Central + + + Radio (millas) + + + Zona Principal + + + Activa + + + Inactiva + + + Principal + + + Activar + + + Desactivar + + + Editar + + + No hay zonas de monitoreo configuradas. Agregue una zona a continuación para definir las áreas geográficas que desea monitorear para alertas meteorológicas. + + + ¿Está seguro de que desea eliminar esta zona? + + + Error al guardar la zona. Inténtelo de nuevo. + + + Error al eliminar la zona. Inténtelo de nuevo. + + + Error al actualizar el estado de la zona. Inténtelo de nuevo. + + + Ingrese un nombre de zona. + + + Guardar Zona + + + Opciones + + + Fecha de Inicio + + + Fecha de Fin + + + Buscar + + + Seleccione ambas fechas de inicio y fin. + + + No se encontró historial de alertas para el rango de fechas seleccionado. + + + No se pudo cargar el historial de alertas. + + + Detalle de Alerta + + + Titular + + + Descripción + + + Instrucciones + + + Descripción del Área + + + Urgencia + + + Certeza + + + Fecha Efectiva + + + Fecha de Inicio + + + Fecha de Expiración + + + Fecha de Envío + + + Primera Detección + + + Última Actualización + + + Remitente + + + ID Externo + + + Notificación Enviada + + + + + + No + + + Fuentes Configuradas + + + Intervalo de Consulta + + + Última Consulta + + + Estado + + + Saludable + + + Error + + + Nunca Consultada + + + No hay fuentes de alertas meteorológicas configuradas. Agregue una fuente para comenzar a recibir alertas meteorológicas. + + + Alerta Meteorológica: {0} + + + Expira: {0} + + + Área: {0} + + + Instrucciones: {0} + + + Configuración de Notificaciones + + + Configure qué alertas meteorológicas generan automáticamente mensajes para los miembros del departamento. + + + Activar Alertas Meteorológicas + + + Severidad Mínima para Mostrar + + + Solo se mostrarán en el panel las alertas con esta severidad o superior. + + + Umbral de Severidad para Mensaje Automático + + + Las alertas con esta severidad o superior enviarán automáticamente un mensaje a todos los miembros del departamento. Valor más bajo = mayor severidad (Extrema=0, Severa=1, Moderada=2, Menor=3). + + + Adjuntar Contexto Meteorológico a Llamadas + + + Cuando está activado, las alertas meteorológicas activas cercanas a la ubicación de una llamada se adjuntarán como nota de la llamada. + + + Guardar Configuración + + + Configuración guardada correctamente. + + + Error al guardar la configuración. Inténtelo de nuevo. + + + Error al cargar la configuración. + + + Error al actualizar el estado de la fuente. Inténtelo de nuevo. + + + Editar Fuente + + + Agregar / Editar Fuente + + + Ingrese un nombre de fuente. + + + Lista separada por comas de códigos de estado, códigos de provincia o códigos de zona NWS. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.fr.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.fr.resx new file mode 100644 index 00000000..88e961e8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.fr.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Alertes Météorologiques + + + Alertes Météorologiques + + + Alertes Météorologiques Actives + + + Historique des Alertes + + + Sources d'Alertes + + + Zones de Surveillance + + + Paramètres + + + Voir l'Historique + + + Retour aux Alertes + + + Retour aux Paramètres + + + Gérer les Zones + + + Détails + + + Aucune alerte météorologique active pour votre département. + + + Impossible de charger les alertes météorologiques. Veuillez vérifier votre configuration. + + + Chargement des alertes météorologiques... + + + Chargement de l'historique... + + + Événement + + + Sévérité + + + Zone + + + Expire + + + Statut + + + Catégorie + + + Actions + + + Extrême + + + Sévère + + + Modérée + + + Mineure + + + Inconnue + + + Immédiate + + + Attendue + + + Future + + + Passée + + + Inconnue + + + Météorologique + + + Incendie + + + Santé + + + Environnement + + + Autre + + + Active + + + Mise à jour + + + Expirée + + + Annulée + + + Service Météorologique National + + + Environnement Canada + + + MeteoAlarm (Europe) + + + Ajouter une Source + + + Nom de la Source + + + Type de Source + + + Filtre de Zone (codes de zone JSON) + + + Intervalle d'Interrogation (minutes) + + + Enregistrer + + + Annuler + + + Supprimer + + + Êtes-vous sûr de vouloir supprimer cette source ? + + + Échec de l'enregistrement de la source. Veuillez réessayer. + + + Échec de la suppression de la source. Veuillez réessayer. + + + Ajouter une Zone de Surveillance + + + Modifier la Zone de Surveillance + + + Nom de la Zone + + + Code de Zone + + + Emplacement Central + + + Rayon (miles) + + + Zone Principale + + + Active + + + Inactive + + + Principale + + + Activer + + + Désactiver + + + Modifier + + + Aucune zone de surveillance configurée. Ajoutez une zone ci-dessous pour définir les zones géographiques que vous souhaitez surveiller pour les alertes météorologiques. + + + Êtes-vous sûr de vouloir supprimer cette zone ? + + + Échec de l'enregistrement de la zone. Veuillez réessayer. + + + Échec de la suppression de la zone. Veuillez réessayer. + + + Échec de la mise à jour du statut de la zone. Veuillez réessayer. + + + Veuillez entrer un nom de zone. + + + Enregistrer la Zone + + + Options + + + Date de Début + + + Date de Fin + + + Rechercher + + + Veuillez sélectionner les dates de début et de fin. + + + Aucun historique d'alertes trouvé pour la période sélectionnée. + + + Impossible de charger l'historique des alertes. + + + Détail de l'Alerte + + + Titre + + + Description + + + Instructions + + + Description de la Zone + + + Urgence + + + Certitude + + + Date d'Effet + + + Date de Début + + + Date d'Expiration + + + Date d'Envoi + + + Première Détection + + + Dernière Mise à Jour + + + Expéditeur + + + ID Externe + + + Notification Envoyée + + + Oui + + + Non + + + Sources Configurées + + + Intervalle d'Interrogation + + + Dernière Interrogation + + + Statut + + + Opérationnel + + + Erreur + + + Jamais Interrogée + + + Aucune source d'alertes météorologiques configurée. Ajoutez une source pour commencer à recevoir des alertes météorologiques. + + + Alerte Météorologique : {0} + + + Expire : {0} + + + Zone : {0} + + + Instructions : {0} + + + Paramètres de Notification + + + Configurez quelles alertes météorologiques génèrent automatiquement des messages aux membres du département. + + + Activer les Alertes Météorologiques + + + Sévérité Minimale à Afficher + + + Seules les alertes de cette sévérité ou supérieure seront affichées dans le tableau de bord. + + + Seuil de Sévérité pour Message Automatique + + + Les alertes de cette sévérité ou supérieure enverront automatiquement un message à tous les membres du département. Valeur plus basse = sévérité plus élevée (Extrême=0, Sévère=1, Modérée=2, Mineure=3). + + + Joindre le Contexte Météo aux Appels + + + Lorsqu'activé, les alertes météorologiques actives à proximité de l'emplacement d'un appel seront jointes comme note d'appel. + + + Enregistrer les Paramètres + + + Paramètres enregistrés avec succès. + + + Échec de l'enregistrement des paramètres. Veuillez réessayer. + + + Échec du chargement des paramètres. + + + Échec de la mise à jour du statut de la source. Veuillez réessayer. + + + Modifier la Source + + + Ajouter / Modifier une Source + + + Veuillez entrer un nom de source. + + + Liste séparée par des virgules de codes d'état, codes de province ou codes de zone NWS. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.it.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.it.resx new file mode 100644 index 00000000..fb730f94 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.it.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Allerte Meteo + + + Allerte Meteo + + + Allerte Meteo Attive + + + Cronologia Allerte + + + Fonti delle Allerte + + + Zone di Monitoraggio + + + Impostazioni + + + Visualizza Cronologia + + + Torna alle Allerte + + + Torna alle Impostazioni + + + Gestisci Zone + + + Dettagli + + + Nessuna allerta meteo attiva per il tuo dipartimento. + + + Impossibile caricare le allerte meteo. Controlla la configurazione. + + + Caricamento allerte meteo... + + + Caricamento cronologia... + + + Evento + + + Gravità + + + Area + + + Scadenza + + + Stato + + + Categoria + + + Azioni + + + Estrema + + + Grave + + + Moderata + + + Lieve + + + Sconosciuta + + + Immediata + + + Prevista + + + Futura + + + Passata + + + Sconosciuta + + + Meteorologica + + + Incendio + + + Salute + + + Ambientale + + + Altro + + + Attiva + + + Aggiornata + + + Scaduta + + + Annullata + + + Servizio Meteorologico Nazionale + + + Environment Canada + + + MeteoAlarm (Europa) + + + Aggiungi Fonte + + + Nome della Fonte + + + Tipo di Fonte + + + Filtro Area (codici zona JSON) + + + Intervallo di Polling (minuti) + + + Salva + + + Annulla + + + Elimina + + + Sei sicuro di voler eliminare questa fonte? + + + Impossibile salvare la fonte. Riprova. + + + Impossibile eliminare la fonte. Riprova. + + + Aggiungi Zona di Monitoraggio + + + Modifica Zona di Monitoraggio + + + Nome della Zona + + + Codice Zona + + + Posizione Centrale + + + Raggio (miglia) + + + Zona Principale + + + Attiva + + + Inattiva + + + Principale + + + Attiva + + + Disattiva + + + Modifica + + + Nessuna zona di monitoraggio configurata. Aggiungi una zona qui sotto per definire le aree geografiche da monitorare per le allerte meteo. + + + Sei sicuro di voler eliminare questa zona? + + + Impossibile salvare la zona. Riprova. + + + Impossibile eliminare la zona. Riprova. + + + Impossibile aggiornare lo stato della zona. Riprova. + + + Inserisci un nome per la zona. + + + Salva Zona + + + Opzioni + + + Data di Inizio + + + Data di Fine + + + Cerca + + + Seleziona entrambe le date di inizio e fine. + + + Nessuna cronologia allerte trovata per il periodo selezionato. + + + Impossibile caricare la cronologia delle allerte. + + + Dettaglio Allerta + + + Titolo + + + Descrizione + + + Istruzioni + + + Descrizione dell'Area + + + Urgenza + + + Certezza + + + Data Effettiva + + + Data di Inizio + + + Data di Scadenza + + + Data di Invio + + + Prima Rilevazione + + + Ultimo Aggiornamento + + + Mittente + + + ID Esterno + + + Notifica Inviata + + + + + + No + + + Fonti Configurate + + + Intervallo di Polling + + + Ultimo Polling + + + Stato + + + Funzionante + + + Errore + + + Mai Interrogata + + + Nessuna fonte di allerte meteo configurata. Aggiungi una fonte per iniziare a ricevere allerte meteo. + + + Allerta Meteo: {0} + + + Scadenza: {0} + + + Area: {0} + + + Istruzioni: {0} + + + Impostazioni di Notifica + + + Configura quali allerte meteo generano automaticamente messaggi ai membri del dipartimento. + + + Attiva Allerte Meteo + + + Gravità Minima da Visualizzare + + + Solo le allerte con questa gravità o superiore verranno mostrate nella dashboard. + + + Soglia di Gravità per Messaggio Automatico + + + Le allerte con questa gravità o superiore invieranno automaticamente un messaggio a tutti i membri del dipartimento. Valore più basso = gravità più alta (Estrema=0, Grave=1, Moderata=2, Lieve=3). + + + Allega Contesto Meteo alle Chiamate + + + Quando attivato, le allerte meteo attive vicine alla posizione di una chiamata verranno allegate come nota della chiamata. + + + Salva Impostazioni + + + Impostazioni salvate con successo. + + + Impossibile salvare le impostazioni. Riprova. + + + Impossibile caricare le impostazioni. + + + Impossibile aggiornare lo stato della fonte. Riprova. + + + Modifica Fonte + + + Aggiungi / Modifica Fonte + + + Inserisci un nome per la fonte. + + + Elenco separato da virgole di codici di stato, codici di provincia o codici di zona NWS. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.pl.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.pl.resx new file mode 100644 index 00000000..85b68980 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.pl.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Alerty Pogodowe + + + Alerty Pogodowe + + + Aktywne Alerty Pogodowe + + + Historia Alertów + + + Źródła Alertów + + + Strefy Monitorowania + + + Ustawienia + + + Zobacz Historię + + + Powrót do Alertów + + + Powrót do Ustawień + + + Zarządzaj Strefami + + + Szczegóły + + + Brak aktywnych alertów pogodowych dla Twojego departamentu. + + + Nie można załadować alertów pogodowych. Sprawdź konfigurację. + + + Ładowanie alertów pogodowych... + + + Ładowanie historii... + + + Zdarzenie + + + Ważność + + + Obszar + + + Wygasa + + + Status + + + Kategoria + + + Akcje + + + Ekstremalna + + + Poważna + + + Umiarkowana + + + Niewielka + + + Nieznana + + + Natychmiastowa + + + Oczekiwana + + + Przyszła + + + Przeszła + + + Nieznana + + + Meteorologiczna + + + Pożar + + + Zdrowie + + + Środowiskowa + + + Inna + + + Aktywny + + + Zaktualizowany + + + Wygasły + + + Anulowany + + + Narodowa Służba Pogodowa + + + Environment Canada + + + MeteoAlarm (Europa) + + + Dodaj Źródło + + + Nazwa Źródła + + + Typ Źródła + + + Filtr Obszaru (kody stref JSON) + + + Interwał Odpytywania (minuty) + + + Zapisz + + + Anuluj + + + Usuń + + + Czy na pewno chcesz usunąć to źródło? + + + Nie udało się zapisać źródła. Spróbuj ponownie. + + + Nie udało się usunąć źródła. Spróbuj ponownie. + + + Dodaj Strefę Monitorowania + + + Edytuj Strefę Monitorowania + + + Nazwa Strefy + + + Kod Strefy + + + Lokalizacja Centralna + + + Promień (mile) + + + Strefa Główna + + + Aktywna + + + Nieaktywna + + + Główna + + + Włącz + + + Wyłącz + + + Edytuj + + + Brak skonfigurowanych stref monitorowania. Dodaj strefę poniżej, aby zdefiniować obszary geograficzne do monitorowania alertów pogodowych. + + + Czy na pewno chcesz usunąć tę strefę? + + + Nie udało się zapisać strefy. Spróbuj ponownie. + + + Nie udało się usunąć strefy. Spróbuj ponownie. + + + Nie udało się zaktualizować statusu strefy. Spróbuj ponownie. + + + Wprowadź nazwę strefy. + + + Zapisz Strefę + + + Opcje + + + Data Początkowa + + + Data Końcowa + + + Szukaj + + + Wybierz datę początkową i końcową. + + + Nie znaleziono historii alertów dla wybranego zakresu dat. + + + Nie można załadować historii alertów. + + + Szczegóły Alertu + + + Nagłówek + + + Opis + + + Instrukcje + + + Opis Obszaru + + + Pilność + + + Pewność + + + Data Obowiązywania + + + Data Rozpoczęcia + + + Data Wygaśnięcia + + + Data Wysłania + + + Pierwsze Wykrycie + + + Ostatnia Aktualizacja + + + Nadawca + + + Zewnętrzne ID + + + Powiadomienie Wysłane + + + Tak + + + Nie + + + Skonfigurowane Źródła + + + Interwał Odpytywania + + + Ostatnie Odpytanie + + + Status + + + Sprawne + + + Błąd + + + Nigdy Nie Odpytane + + + Brak skonfigurowanych źródeł alertów pogodowych. Dodaj źródło, aby zacząć otrzymywać alerty pogodowe. + + + Alert Pogodowy: {0} + + + Wygasa: {0} + + + Obszar: {0} + + + Instrukcje: {0} + + + Ustawienia Powiadomień + + + Skonfiguruj, które alerty pogodowe automatycznie generują wiadomości do członków departamentu. + + + Włącz Alerty Pogodowe + + + Minimalna Ważność do Wyświetlenia + + + Tylko alerty o tej lub wyższej ważności będą wyświetlane na pulpicie. + + + Próg Ważności dla Automatycznej Wiadomości + + + Alerty o tej lub wyższej ważności automatycznie wyślą wiadomość do wszystkich członków departamentu. Niższa wartość = wyższa ważność (Ekstremalna=0, Poważna=1, Umiarkowana=2, Niewielka=3). + + + Dołącz Kontekst Pogodowy do Wezwań + + + Po włączeniu aktywne alerty pogodowe w pobliżu lokalizacji wezwania zostaną dołączone jako notatka do wezwania. + + + Zapisz Ustawienia + + + Ustawienia zapisane pomyślnie. + + + Nie udało się zapisać ustawień. Spróbuj ponownie. + + + Nie udało się załadować ustawień. + + + Nie udało się zaktualizować statusu źródła. Spróbuj ponownie. + + + Edytuj Źródło + + + Dodaj / Edytuj Źródło + + + Wprowadź nazwę źródła. + + + Rozdzielona przecinkami lista kodów stanów, kodów prowincji lub kodów stref NWS. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.sv.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.sv.resx new file mode 100644 index 00000000..27316698 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.sv.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Vädervarningar + + + Vädervarningar + + + Aktiva Vädervarningar + + + Varningshistorik + + + Varningskällor + + + Övervakningszoner + + + Inställningar + + + Visa Historik + + + Tillbaka till Varningar + + + Tillbaka till Inställningar + + + Hantera Zoner + + + Detaljer + + + Inga aktiva vädervarningar för din avdelning. + + + Kunde inte ladda vädervarningar. Kontrollera din konfiguration. + + + Laddar vädervarningar... + + + Laddar historik... + + + Händelse + + + Allvarlighetsgrad + + + Område + + + Upphör + + + Status + + + Kategori + + + Åtgärder + + + Extrem + + + Allvarlig + + + Måttlig + + + Mindre + + + Okänd + + + Omedelbar + + + Förväntad + + + Framtida + + + Passerad + + + Okänd + + + Meteorologisk + + + Brand + + + Hälsa + + + Miljö + + + Övrigt + + + Aktiv + + + Uppdaterad + + + Upphörd + + + Avbruten + + + National Weather Service + + + Environment Canada + + + MeteoAlarm (Europa) + + + Lägg till Källa + + + Källnamn + + + Källtyp + + + Områdesfilter (JSON-zonkoder) + + + Pollningsintervall (minuter) + + + Spara + + + Avbryt + + + Ta bort + + + Är du säker på att du vill ta bort denna källa? + + + Kunde inte spara källan. Försök igen. + + + Kunde inte ta bort källan. Försök igen. + + + Lägg till Övervakningszon + + + Redigera Övervakningszon + + + Zonnamn + + + Zonkod + + + Centrumposition + + + Radie (miles) + + + Primär Zon + + + Aktiv + + + Inaktiv + + + Primär + + + Aktivera + + + Inaktivera + + + Redigera + + + Inga övervakningszoner konfigurerade. Lägg till en zon nedan för att definiera de geografiska områden du vill övervaka för vädervarningar. + + + Är du säker på att du vill ta bort denna zon? + + + Kunde inte spara zonen. Försök igen. + + + Kunde inte ta bort zonen. Försök igen. + + + Kunde inte uppdatera zonens status. Försök igen. + + + Ange ett zonnamn. + + + Spara Zon + + + Alternativ + + + Startdatum + + + Slutdatum + + + Sök + + + Välj både start- och slutdatum. + + + Ingen varningshistorik hittades för den valda perioden. + + + Kunde inte ladda varningshistorik. + + + Varningsdetalj + + + Rubrik + + + Beskrivning + + + Instruktioner + + + Områdesbeskrivning + + + Brådska + + + Säkerhet + + + Giltighetsdatum + + + Startdatum + + + Utgångsdatum + + + Sändningsdatum + + + Först Upptäckt + + + Senast Uppdaterad + + + Avsändare + + + Externt ID + + + Avisering Skickad + + + Ja + + + Nej + + + Konfigurerade Källor + + + Pollningsintervall + + + Senaste Pollning + + + Status + + + Fungerar + + + Fel + + + Aldrig Pollad + + + Inga vädervarningskällor konfigurerade. Lägg till en källa för att börja ta emot vädervarningar. + + + Vädervarning: {0} + + + Upphör: {0} + + + Område: {0} + + + Instruktioner: {0} + + + Aviseringsinställningar + + + Konfigurera vilka vädervarningar som automatiskt genererar meddelanden till avdelningsmedlemmar. + + + Aktivera Vädervarningar + + + Lägsta Allvarlighetsgrad att Visa + + + Endast varningar med denna eller högre allvarlighetsgrad visas i instrumentpanelen. + + + Tröskel för Automatiskt Meddelande + + + Varningar med denna eller högre allvarlighetsgrad skickar automatiskt ett meddelande till alla avdelningsmedlemmar. Lägre värde = högre allvarlighetsgrad (Extrem=0, Allvarlig=1, Måttlig=2, Mindre=3). + + + Bifoga Väderkontext till Utryckningar + + + När aktiverat bifogas aktiva vädervarningar nära en utrycknings plats som en utryckningsanteckning. + + + Spara Inställningar + + + Inställningar sparade. + + + Kunde inte spara inställningarna. Försök igen. + + + Kunde inte ladda inställningarna. + + + Kunde inte uppdatera källans status. Försök igen. + + + Redigera Källa + + + Lägg till / Redigera Källa + + + Ange ett källnamn. + + + Kommaseparerad lista med delstatskoder, provinskoder eller NWS-zonkoder. + + diff --git a/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.uk.resx b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.uk.resx new file mode 100644 index 00000000..9cb6788f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/WeatherAlerts/WeatherAlerts.uk.resx @@ -0,0 +1,454 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Попередження про Погоду + + + Попередження про Погоду + + + Активні Попередження про Погоду + + + Історія Попереджень + + + Джерела Попереджень + + + Зони Моніторингу + + + Налаштування + + + Переглянути Історію + + + Назад до Попереджень + + + Назад до Налаштувань + + + Керувати Зонами + + + Деталі + + + Немає активних попереджень про погоду для вашого підрозділу. + + + Не вдалося завантажити попередження про погоду. Перевірте налаштування. + + + Завантаження попереджень про погоду... + + + Завантаження історії... + + + Подія + + + Серйозність + + + Район + + + Закінчується + + + Статус + + + Категорія + + + Дії + + + Екстремальна + + + Серйозна + + + Помірна + + + Незначна + + + Невідома + + + Негайна + + + Очікувана + + + Майбутня + + + Минула + + + Невідома + + + Метеорологічна + + + Пожежа + + + Здоров'я + + + Екологічна + + + Інша + + + Активний + + + Оновлений + + + Закінчився + + + Скасований + + + Національна Метеорологічна Служба + + + Environment Canada + + + MeteoAlarm (Європа) + + + Додати Джерело + + + Назва Джерела + + + Тип Джерела + + + Фільтр Району (коди зон JSON) + + + Інтервал Опитування (хвилини) + + + Зберегти + + + Скасувати + + + Видалити + + + Ви впевнені, що хочете видалити це джерело? + + + Не вдалося зберегти джерело. Спробуйте ще раз. + + + Не вдалося видалити джерело. Спробуйте ще раз. + + + Додати Зону Моніторингу + + + Редагувати Зону Моніторингу + + + Назва Зони + + + Код Зони + + + Центральне Розташування + + + Радіус (милі) + + + Основна Зона + + + Активна + + + Неактивна + + + Основна + + + Увімкнути + + + Вимкнути + + + Редагувати + + + Зони моніторингу не налаштовані. Додайте зону нижче, щоб визначити географічні райони для моніторингу попереджень про погоду. + + + Ви впевнені, що хочете видалити цю зону? + + + Не вдалося зберегти зону. Спробуйте ще раз. + + + Не вдалося видалити зону. Спробуйте ще раз. + + + Не вдалося оновити статус зони. Спробуйте ще раз. + + + Будь ласка, введіть назву зони. + + + Зберегти Зону + + + Параметри + + + Дата Початку + + + Дата Закінчення + + + Пошук + + + Будь ласка, виберіть дату початку та закінчення. + + + Історія попереджень для обраного періоду не знайдена. + + + Не вдалося завантажити історію попереджень. + + + Деталі Попередження + + + Заголовок + + + Опис + + + Інструкції + + + Опис Району + + + Терміновість + + + Достовірність + + + Дата Набуття Чинності + + + Дата Початку + + + Дата Закінчення + + + Дата Відправлення + + + Перше Виявлення + + + Останнє Оновлення + + + Відправник + + + Зовнішній ID + + + Сповіщення Надіслано + + + Так + + + Ні + + + Налаштовані Джерела + + + Інтервал Опитування + + + Останнє Опитування + + + Статус + + + Справний + + + Помилка + + + Ніколи Не Опитувалось + + + Джерела попереджень про погоду не налаштовані. Додайте джерело, щоб почати отримувати попередження про погоду. + + + Попередження про Погоду: {0} + + + Закінчується: {0} + + + Район: {0} + + + Інструкції: {0} + + + Налаштування Сповіщень + + + Налаштуйте, які попередження про погоду автоматично генерують повідомлення для членів підрозділу. + + + Увімкнути Попередження про Погоду + + + Мінімальна Серйозність для Відображення + + + На панелі будуть показані лише попередження з цим або вищим рівнем серйозності. + + + Поріг Серйозності для Автоматичного Повідомлення + + + Попередження з цим або вищим рівнем серйозності автоматично надішлють повідомлення всім членам підрозділу. Нижче значення = вища серйозність (Екстремальна=0, Серйозна=1, Помірна=2, Незначна=3). + + + Додавати Погодний Контекст до Викликів + + + Коли увімкнено, активні попередження про погоду поблизу місця виклику будуть додані як примітка до виклику. + + + Зберегти Налаштування + + + Налаштування збережено успішно. + + + Не вдалося зберегти налаштування. Спробуйте ще раз. + + + Не вдалося завантажити налаштування. + + + Не вдалося оновити статус джерела. Спробуйте ще раз. + + + Редагувати Джерело + + + Додати / Редагувати Джерело + + + Будь ласка, введіть назву джерела. + + + Розділений комами список кодів штатів, кодів провінцій або кодів зон NWS. + + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 5ae75e77..be9d1396 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -148,6 +148,18 @@ public enum AuditLogTypes CommunicationTestCreated, CommunicationTestUpdated, CommunicationTestDeleted, - CommunicationTestRunStarted + CommunicationTestRunStarted, + // Weather Alerts + WeatherAlertSourceCreated, + WeatherAlertSourceUpdated, + WeatherAlertSourceDeleted, + WeatherAlertSourceEnabled, + WeatherAlertSourceDisabled, + WeatherAlertZoneCreated, + WeatherAlertZoneUpdated, + WeatherAlertZoneDeleted, + WeatherAlertZoneEnabled, + WeatherAlertZoneDisabled, + WeatherAlertSettingsChanged } } diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 99c05316..7a335290 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -40,5 +40,10 @@ public enum DepartmentSettingTypes Require2FAForAdmins = 36, PaddleCustomerId = 37, CheckInTimersAutoEnableForNewCalls = 38, + WeatherAlertsEnabled = 39, + WeatherAlertMinimumSeverity = 40, + WeatherAlertAutoMessageSeverity = 41, + WeatherAlertCallIntegration = 42, + WeatherAlertCacheMinutes = 43, } } diff --git a/Core/Resgrid.Model/Events/EventTypes.cs b/Core/Resgrid.Model/Events/EventTypes.cs index 73dfc90c..8156c338 100644 --- a/Core/Resgrid.Model/Events/EventTypes.cs +++ b/Core/Resgrid.Model/Events/EventTypes.cs @@ -70,7 +70,13 @@ public enum EventTypes LinkedDepartmentCallAdded = 20, [NotMapped] - ResourceOrderAdded = 21 + ResourceOrderAdded = 21, + + [NotMapped] + WeatherAlertReceived = 22, + + [NotMapped] + WeatherAlertExpired = 23 } public static class EventOptions @@ -140,7 +146,9 @@ public static class EventOptions EventTypes.CalendarEventUpdated, EventTypes.ShiftCreated, EventTypes.ShiftUpdated, - EventTypes.ShiftDaysAdded + EventTypes.ShiftDaysAdded, + EventTypes.WeatherAlertReceived, + EventTypes.WeatherAlertExpired }; } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Providers/IWeatherAlertProvider.cs b/Core/Resgrid.Model/Providers/IWeatherAlertProvider.cs new file mode 100644 index 00000000..32f03950 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IWeatherAlertProvider.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Providers +{ + public interface IWeatherAlertProvider + { + WeatherAlertSourceType SourceType { get; } + Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default); + } +} diff --git a/Core/Resgrid.Model/Providers/IWeatherAlertProviderFactory.cs b/Core/Resgrid.Model/Providers/IWeatherAlertProviderFactory.cs new file mode 100644 index 00000000..5696e0c2 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IWeatherAlertProviderFactory.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Model.Providers +{ + public interface IWeatherAlertProviderFactory + { + IWeatherAlertProvider GetProvider(WeatherAlertSourceType sourceType); + } +} diff --git a/Core/Resgrid.Model/Repositories/IWeatherAlertRepository.cs b/Core/Resgrid.Model/Repositories/IWeatherAlertRepository.cs new file mode 100644 index 00000000..7729ce59 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWeatherAlertRepository.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IWeatherAlertRepository : IRepository + { + Task> GetActiveAlertsByDepartmentIdAsync(int departmentId); + Task GetByExternalIdAndSourceIdAsync(string externalId, Guid sourceId); + Task> GetAlertsByDepartmentAndSeverityAsync(int departmentId, int maxSeverity); + Task> GetAlertsByDepartmentAndCategoryAsync(int departmentId, int category); + Task> GetExpiredUnprocessedAlertsAsync(); + Task> GetUnnotifiedAlertsAsync(); + Task> GetAlertHistoryByDepartmentAsync(int departmentId, DateTime startDate, DateTime endDate); + } +} diff --git a/Core/Resgrid.Model/Repositories/IWeatherAlertSourceRepository.cs b/Core/Resgrid.Model/Repositories/IWeatherAlertSourceRepository.cs new file mode 100644 index 00000000..279c3d09 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWeatherAlertSourceRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IWeatherAlertSourceRepository : IRepository + { + Task> GetActiveSourcesForPollingAsync(); + Task> GetSourcesByDepartmentIdAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IWeatherAlertZoneRepository.cs b/Core/Resgrid.Model/Repositories/IWeatherAlertZoneRepository.cs new file mode 100644 index 00000000..1a0eb344 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWeatherAlertZoneRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IWeatherAlertZoneRepository : IRepository + { + Task> GetZonesByDepartmentIdAsync(int departmentId); + Task> GetActiveZonesByDepartmentIdAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 62d5d4c0..48698911 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -287,5 +287,10 @@ public interface IDepartmentSettingsService Task GetPaddleCustomerIdForDepartmentAsync(int departmentId); Task GetCheckInTimersAutoEnableForNewCallsAsync(int departmentId); + + /// + /// Gets a department setting by type. Returns null if the setting does not exist. + /// + Task GetSettingByTypeAsync(int departmentId, DepartmentSettingTypes type); } } diff --git a/Core/Resgrid.Model/Services/IWeatherAlertService.cs b/Core/Resgrid.Model/Services/IWeatherAlertService.cs new file mode 100644 index 00000000..bd450ff6 --- /dev/null +++ b/Core/Resgrid.Model/Services/IWeatherAlertService.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IWeatherAlertService + { + // Source CRUD + Task GetSourceByIdAsync(Guid sourceId); + Task> GetSourcesByDepartmentIdAsync(int departmentId); + Task SaveSourceAsync(WeatherAlertSource source, CancellationToken ct = default); + Task DeleteSourceAsync(Guid sourceId, CancellationToken ct = default); + + // Alert queries + Task GetAlertByIdAsync(Guid alertId); + Task> GetActiveAlertsByDepartmentIdAsync(int departmentId); + Task> GetAlertsByDepartmentAndSeverityAsync(int departmentId, WeatherAlertSeverity maxSeverity); + Task> GetAlertsByDepartmentAndCategoryAsync(int departmentId, WeatherAlertCategory category); + Task> GetAlertHistoryAsync(int departmentId, DateTime startDate, DateTime endDate); + Task> GetActiveAlertsNearLocationAsync(int departmentId, double lat, double lng, double radiusMiles = 25); + + // Zone CRUD + Task GetZoneByIdAsync(Guid zoneId); + Task> GetZonesByDepartmentIdAsync(int departmentId); + Task SaveZoneAsync(WeatherAlertZone zone, CancellationToken ct = default); + Task DeleteZoneAsync(Guid zoneId, CancellationToken ct = default); + + // Ingestion (called by worker) + Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToken ct = default); + Task ProcessAllActiveSourcesAsync(CancellationToken ct = default); + Task ExpireOldAlertsAsync(CancellationToken ct = default); + Task SendPendingNotificationsAsync(CancellationToken ct = default); + + // Call integration + Task AttachWeatherAlertsToCallAsync(Call call, CancellationToken ct = default); + + // Cache invalidation + Task InvalidateDepartmentWeatherCacheAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/WeatherAlert.cs b/Core/Resgrid.Model/WeatherAlert.cs new file mode 100644 index 00000000..9fc46017 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlert.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("WeatherAlerts")] + public class WeatherAlert : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid WeatherAlertId { get; set; } + + [Required] + [ForeignKey("Department"), DatabaseGenerated(DatabaseGeneratedOption.None)] + public int DepartmentId { get; set; } + + public virtual Department Department { get; set; } + + [ForeignKey("WeatherAlertSource"), DatabaseGenerated(DatabaseGeneratedOption.None)] + public Guid WeatherAlertSourceId { get; set; } + + public virtual WeatherAlertSource WeatherAlertSource { get; set; } + + [MaxLength(500)] + public string ExternalId { get; set; } + + [MaxLength(500)] + public string Sender { get; set; } + + [MaxLength(500)] + public string Event { get; set; } + + public int AlertCategory { get; set; } + + public int Severity { get; set; } + + public int Urgency { get; set; } + + public int Certainty { get; set; } + + public int Status { get; set; } + + [MaxLength(500)] + public string Headline { get; set; } + + public string Description { get; set; } + + public string Instruction { get; set; } + + [MaxLength(500)] + public string AreaDescription { get; set; } + + public string Polygon { get; set; } + + public string Geocodes { get; set; } + + [MaxLength(100)] + public string CenterGeoLocation { get; set; } + + public DateTime? OnsetUtc { get; set; } + + public DateTime? ExpiresUtc { get; set; } + + public DateTime EffectiveUtc { get; set; } + + public DateTime? SentUtc { get; set; } + + public DateTime FirstSeenUtc { get; set; } + + public DateTime LastUpdatedUtc { get; set; } + + [MaxLength(500)] + public string ReferencesExternalId { get; set; } + + public bool NotificationSent { get; set; } + + public int? SystemMessageId { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return WeatherAlertId == Guid.Empty ? null : (object)WeatherAlertId.ToString(); } + set { WeatherAlertId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "WeatherAlerts"; + + [NotMapped] + public string IdName => "WeatherAlertId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Department", "WeatherAlertSource" }; + } +} diff --git a/Core/Resgrid.Model/WeatherAlertCategory.cs b/Core/Resgrid.Model/WeatherAlertCategory.cs new file mode 100644 index 00000000..2103f9bb --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertCategory.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertCategory + { + Met = 0, + Fire = 1, + Health = 2, + Env = 3, + Other = 4 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertCertainty.cs b/Core/Resgrid.Model/WeatherAlertCertainty.cs new file mode 100644 index 00000000..edd5fc89 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertCertainty.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertCertainty + { + Observed = 0, + Likely = 1, + Possible = 2, + Unlikely = 3, + Unknown = 4 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertSeverity.cs b/Core/Resgrid.Model/WeatherAlertSeverity.cs new file mode 100644 index 00000000..ff0c4278 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertSeverity.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertSeverity + { + Extreme = 0, + Severe = 1, + Moderate = 2, + Minor = 3, + Unknown = 4 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertSource.cs b/Core/Resgrid.Model/WeatherAlertSource.cs new file mode 100644 index 00000000..deda5c86 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertSource.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("WeatherAlertSources")] + public class WeatherAlertSource : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid WeatherAlertSourceId { get; set; } + + [Required] + [ForeignKey("Department"), DatabaseGenerated(DatabaseGeneratedOption.None)] + public int DepartmentId { get; set; } + + public virtual Department Department { get; set; } + + [MaxLength(200)] + public string Name { get; set; } + + public int SourceType { get; set; } + + [MaxLength(1000)] + public string AreaFilter { get; set; } + + [MaxLength(500)] + public string ApiKey { get; set; } + + [MaxLength(2000)] + public string CustomEndpoint { get; set; } + + public int PollIntervalMinutes { get; set; } + + public bool Active { get; set; } + + public DateTime? LastPollUtc { get; set; } + + public DateTime? LastSuccessUtc { get; set; } + + public bool IsFailure { get; set; } + + [MaxLength(2000)] + public string ErrorMessage { get; set; } + + [MaxLength(500)] + public string LastETag { get; set; } + + public DateTime CreatedOn { get; set; } + + [MaxLength(128)] + public string CreatedByUserId { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return WeatherAlertSourceId == Guid.Empty ? null : (object)WeatherAlertSourceId.ToString(); } + set { WeatherAlertSourceId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "WeatherAlertSources"; + + [NotMapped] + public string IdName => "WeatherAlertSourceId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Department", "ContactEmail" }; + + /// + /// Non-persisted. Populated by the service layer with the department admin's email + /// for use as contact info in upstream API User-Agent headers. + /// + [NotMapped] + public string ContactEmail { get; set; } + } +} diff --git a/Core/Resgrid.Model/WeatherAlertSourceType.cs b/Core/Resgrid.Model/WeatherAlertSourceType.cs new file mode 100644 index 00000000..fd4d7e4a --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertSourceType.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertSourceType + { + NationalWeatherService = 0, + EnvironmentCanada = 1, + MeteoAlarm = 2 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertStatus.cs b/Core/Resgrid.Model/WeatherAlertStatus.cs new file mode 100644 index 00000000..448a8144 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertStatus.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertStatus + { + Active = 0, + Updated = 1, + Expired = 2, + Cancelled = 3 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertUrgency.cs b/Core/Resgrid.Model/WeatherAlertUrgency.cs new file mode 100644 index 00000000..acbb77f6 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertUrgency.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum WeatherAlertUrgency + { + Immediate = 0, + Expected = 1, + Future = 2, + Past = 3, + Unknown = 4 + } +} diff --git a/Core/Resgrid.Model/WeatherAlertZone.cs b/Core/Resgrid.Model/WeatherAlertZone.cs new file mode 100644 index 00000000..cd7f3426 --- /dev/null +++ b/Core/Resgrid.Model/WeatherAlertZone.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("WeatherAlertZones")] + public class WeatherAlertZone : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid WeatherAlertZoneId { get; set; } + + [Required] + [ForeignKey("Department"), DatabaseGenerated(DatabaseGeneratedOption.None)] + public int DepartmentId { get; set; } + + public virtual Department Department { get; set; } + + [MaxLength(200)] + public string Name { get; set; } + + [MaxLength(100)] + public string ZoneCode { get; set; } + + [MaxLength(100)] + public string CenterGeoLocation { get; set; } + + public double RadiusMiles { get; set; } + + public bool IsActive { get; set; } + + public bool IsPrimary { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return WeatherAlertZoneId == Guid.Empty ? null : (object)WeatherAlertZoneId.ToString(); } + set { WeatherAlertZoneId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "WeatherAlertZones"; + + [NotMapped] + public string IdName => "WeatherAlertZoneId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Department" }; + } +} diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 9aac66f4..ba8d7861 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -797,5 +797,10 @@ public async Task GetCheckInTimersAutoEnableForNewCallsAsync(int departmen return false; } + + public async Task GetSettingByTypeAsync(int departmentId, DepartmentSettingTypes type) + { + return await GetSettingByDepartmentIdType(departmentId, type); + } } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 4194a932..0ec55973 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -103,6 +103,9 @@ protected override void Load(ContainerBuilder builder) // Communication Test Services builder.RegisterType().As().InstancePerLifetimeScope(); + + // Weather Alert Services + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs new file mode 100644 index 00000000..8fa8814f --- /dev/null +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -0,0 +1,623 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class WeatherAlertService : IWeatherAlertService + { + private readonly IWeatherAlertRepository _weatherAlertRepository; + private readonly IWeatherAlertSourceRepository _weatherAlertSourceRepository; + private readonly IWeatherAlertZoneRepository _weatherAlertZoneRepository; + private readonly IWeatherAlertProviderFactory _weatherAlertProviderFactory; + private readonly IDepartmentSettingsRepository _departmentSettingsRepository; + private readonly IDepartmentsService _departmentsService; + private readonly IMessageService _messageService; + private readonly ICallNotesRepository _callNotesRepository; + private readonly ICacheProvider _cacheProvider; + private readonly IEventAggregator _eventAggregator; + + public WeatherAlertService( + IWeatherAlertRepository weatherAlertRepository, + IWeatherAlertSourceRepository weatherAlertSourceRepository, + IWeatherAlertZoneRepository weatherAlertZoneRepository, + IWeatherAlertProviderFactory weatherAlertProviderFactory, + IDepartmentSettingsRepository departmentSettingsRepository, + IDepartmentsService departmentsService, + IMessageService messageService, + ICallNotesRepository callNotesRepository, + ICacheProvider cacheProvider, + IEventAggregator eventAggregator) + { + _weatherAlertRepository = weatherAlertRepository; + _weatherAlertSourceRepository = weatherAlertSourceRepository; + _weatherAlertZoneRepository = weatherAlertZoneRepository; + _weatherAlertProviderFactory = weatherAlertProviderFactory; + _departmentSettingsRepository = departmentSettingsRepository; + _departmentsService = departmentsService; + _messageService = messageService; + _callNotesRepository = callNotesRepository; + _cacheProvider = cacheProvider; + _eventAggregator = eventAggregator; + } + + #region Source CRUD + + public async Task GetSourceByIdAsync(Guid sourceId) + { + return await _weatherAlertSourceRepository.GetByIdAsync(sourceId.ToString()); + } + + public async Task> GetSourcesByDepartmentIdAsync(int departmentId) + { + var items = await _weatherAlertSourceRepository.GetSourcesByDepartmentIdAsync(departmentId); + return items?.ToList() ?? new List(); + } + + public async Task SaveSourceAsync(WeatherAlertSource source, CancellationToken ct = default) + { + if (source.WeatherAlertSourceId == Guid.Empty) + { + source.CreatedOn = DateTime.UtcNow; + } + + return await _weatherAlertSourceRepository.SaveOrUpdateAsync(source, ct, true); + } + + public async Task DeleteSourceAsync(Guid sourceId, CancellationToken ct = default) + { + var source = await GetSourceByIdAsync(sourceId); + if (source == null) + return false; + + return await _weatherAlertSourceRepository.DeleteAsync(source, ct); + } + + #endregion + + #region Alert Queries + + public async Task GetAlertByIdAsync(Guid alertId) + { + return await _weatherAlertRepository.GetByIdAsync(alertId.ToString()); + } + + public async Task> GetActiveAlertsByDepartmentIdAsync(int departmentId) + { + var items = await _weatherAlertRepository.GetActiveAlertsByDepartmentIdAsync(departmentId); + return items?.ToList() ?? new List(); + } + + public async Task> GetAlertsByDepartmentAndSeverityAsync(int departmentId, WeatherAlertSeverity maxSeverity) + { + var items = await _weatherAlertRepository.GetAlertsByDepartmentAndSeverityAsync(departmentId, (int)maxSeverity); + return items?.ToList() ?? new List(); + } + + public async Task> GetAlertsByDepartmentAndCategoryAsync(int departmentId, WeatherAlertCategory category) + { + var items = await _weatherAlertRepository.GetAlertsByDepartmentAndCategoryAsync(departmentId, (int)category); + return items?.ToList() ?? new List(); + } + + public async Task> GetAlertHistoryAsync(int departmentId, DateTime startDate, DateTime endDate) + { + var items = await _weatherAlertRepository.GetAlertHistoryByDepartmentAsync(departmentId, startDate, endDate); + return items?.ToList() ?? new List(); + } + + public async Task> GetActiveAlertsNearLocationAsync(int departmentId, double lat, double lng, double radiusMiles = 25) + { + // Get all active alerts for the department, then filter by proximity + var alerts = await GetActiveAlertsByDepartmentIdAsync(departmentId); + return alerts.Where(a => + { + if (string.IsNullOrEmpty(a.CenterGeoLocation)) + return false; + + var parts = a.CenterGeoLocation.Split(','); + if (parts.Length != 2 || !double.TryParse(parts[0], out var alertLat) || !double.TryParse(parts[1], out var alertLng)) + return false; + + var distance = CalculateDistanceMiles(lat, lng, alertLat, alertLng); + return distance <= radiusMiles; + }).ToList(); + } + + #endregion + + #region Zone CRUD + + public async Task GetZoneByIdAsync(Guid zoneId) + { + return await _weatherAlertZoneRepository.GetByIdAsync(zoneId.ToString()); + } + + public async Task> GetZonesByDepartmentIdAsync(int departmentId) + { + var items = await _weatherAlertZoneRepository.GetZonesByDepartmentIdAsync(departmentId); + return items?.ToList() ?? new List(); + } + + public async Task SaveZoneAsync(WeatherAlertZone zone, CancellationToken ct = default) + { + if (zone.WeatherAlertZoneId == Guid.Empty) + { + zone.CreatedOn = DateTime.UtcNow; + } + + return await _weatherAlertZoneRepository.SaveOrUpdateAsync(zone, ct, true); + } + + public async Task DeleteZoneAsync(Guid zoneId, CancellationToken ct = default) + { + var zone = await GetZoneByIdAsync(zoneId); + if (zone == null) + return false; + + return await _weatherAlertZoneRepository.DeleteAsync(zone, ct); + } + + #endregion + + #region Ingestion + + public async Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToken ct = default) + { + var source = await GetSourceByIdAsync(sourceId); + if (source == null || !source.Active) + return; + + // Populate the department admin contact email for upstream API User-Agent headers + try + { + var department = await _departmentsService.GetDepartmentByIdAsync(source.DepartmentId); + if (department?.ManagingUser != null) + source.ContactEmail = department.ManagingUser.Email; + } + catch { } + + try + { + var provider = _weatherAlertProviderFactory.GetProvider((WeatherAlertSourceType)source.SourceType); + var fetchedAlerts = await provider.FetchAlertsAsync(source, ct); + + foreach (var alert in fetchedAlerts) + { + var existing = await _weatherAlertRepository.GetByExternalIdAndSourceIdAsync( + alert.ExternalId, source.WeatherAlertSourceId); + + if (existing == null) + { + // New alert + await _weatherAlertRepository.InsertAsync(alert, ct, true); + } + else + { + // Update existing + existing.Severity = alert.Severity; + existing.Urgency = alert.Urgency; + existing.Certainty = alert.Certainty; + existing.Headline = alert.Headline; + existing.Description = alert.Description; + existing.Instruction = alert.Instruction; + existing.AreaDescription = alert.AreaDescription; + existing.Polygon = alert.Polygon; + existing.Geocodes = alert.Geocodes; + existing.CenterGeoLocation = alert.CenterGeoLocation; + existing.ExpiresUtc = alert.ExpiresUtc; + existing.LastUpdatedUtc = DateTime.UtcNow; + await _weatherAlertRepository.UpdateAsync(existing, ct, true); + } + + // Handle reference cancellations + if (!string.IsNullOrEmpty(alert.ReferencesExternalId)) + { + var referenced = await _weatherAlertRepository.GetByExternalIdAndSourceIdAsync( + alert.ReferencesExternalId, source.WeatherAlertSourceId); + if (referenced != null && referenced.Status == (int)WeatherAlertStatus.Active) + { + referenced.Status = (int)WeatherAlertStatus.Cancelled; + referenced.LastUpdatedUtc = DateTime.UtcNow; + await _weatherAlertRepository.UpdateAsync(referenced, ct, true); + } + } + } + + source.LastPollUtc = DateTime.UtcNow; + source.LastSuccessUtc = DateTime.UtcNow; + source.IsFailure = false; + source.ErrorMessage = null; + await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); + } + catch (Exception ex) + { + source.LastPollUtc = DateTime.UtcNow; + source.IsFailure = true; + source.ErrorMessage = ex.Message; + await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); + throw; + } + } + + public async Task ProcessAllActiveSourcesAsync(CancellationToken ct = default) + { + var sources = await _weatherAlertSourceRepository.GetActiveSourcesForPollingAsync(); + if (sources == null) + return; + + foreach (var source in sources) + { + // Check if it's time to poll based on interval + if (source.LastPollUtc.HasValue) + { + var nextPoll = source.LastPollUtc.Value.AddMinutes(source.PollIntervalMinutes); + if (DateTime.UtcNow < nextPoll) + continue; + } + + try + { + await ProcessWeatherAlertSourceAsync(source.WeatherAlertSourceId, ct); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } + + public async Task ExpireOldAlertsAsync(CancellationToken ct = default) + { + var expired = await _weatherAlertRepository.GetExpiredUnprocessedAlertsAsync(); + if (expired == null) + return; + + foreach (var alert in expired) + { + alert.Status = (int)WeatherAlertStatus.Expired; + alert.LastUpdatedUtc = DateTime.UtcNow; + await _weatherAlertRepository.UpdateAsync(alert, ct, true); + } + } + + public async Task SendPendingNotificationsAsync(CancellationToken ct = default) + { + var unnotified = await _weatherAlertRepository.GetUnnotifiedAlertsAsync(); + if (unnotified == null) + return; + + // Group by department for efficient processing + var byDepartment = unnotified.ToList().GroupBy(a => a.DepartmentId); + + foreach (var group in byDepartment) + { + var departmentId = group.Key; + + // Check if weather alerts are enabled for this department + var enabledSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( + departmentId, DepartmentSettingTypes.WeatherAlertsEnabled); + if (enabledSetting != null && bool.TryParse(enabledSetting.Setting, out var isEnabled) && !isEnabled) + { + // Mark all as notified without sending + foreach (var a in group) + { + a.NotificationSent = true; + a.LastUpdatedUtc = DateTime.UtcNow; + await _weatherAlertRepository.UpdateAsync(a, ct, true); + } + continue; + } + + // Get the auto-message severity threshold setting + var thresholdSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( + departmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); + + int threshold = (int)WeatherAlertSeverity.Severe; // Default: Severe=1 + if (thresholdSetting != null && int.TryParse(thresholdSetting.Setting, out var parsed)) + threshold = parsed; + + // Load department for sender info + Department department = null; + try + { + department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + foreach (var alert in group) + { + // Only send notifications for alerts meeting severity threshold + // Lower enum value = higher severity (Extreme=0, Severe=1, etc.) + if (alert.Severity <= threshold) + { + try + { + var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId); + if (members != null && members.Any()) + { + // Use department managing user as sender for system messages + var senderId = department?.ManagingUserId ?? members.First().UserId; + + var subject = FormatAlertSubject(alert); + var body = FormatAlertMessageBody(alert, department); + + var message = new Message + { + Subject = subject, + Body = body, + SendingUserId = senderId, + SentOn = DateTime.UtcNow, + SystemGenerated = true, + IsBroadcast = true, + Type = 0, + Recipients = string.Join(",", members.Select(m => m.UserId)) + }; + + // Use SendMessageAsync which saves AND enqueues for push/SMS/email delivery + var sent = await _messageService.SendMessageAsync( + message, "Weather Alert System", departmentId, true, ct); + + if (sent) + { + alert.SystemMessageId = message.MessageId; + } + } + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + + alert.NotificationSent = true; + alert.LastUpdatedUtc = DateTime.UtcNow; + await _weatherAlertRepository.UpdateAsync(alert, ct, true); + } + } + } + + #endregion + + #region Call Integration + + public async Task AttachWeatherAlertsToCallAsync(Call call, CancellationToken ct = default) + { + if (call == null || call.CallId == 0) + return; + + try + { + // Check if call integration is enabled for this department + var callIntSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( + call.DepartmentId, DepartmentSettingTypes.WeatherAlertCallIntegration); + + if (callIntSetting == null || !bool.TryParse(callIntSetting.Setting, out var enabled) || !enabled) + return; + + // Get active alerts for the department + var activeAlerts = await GetActiveAlertsByDepartmentIdAsync(call.DepartmentId); + if (activeAlerts == null || activeAlerts.Count == 0) + return; + + // If the call has geolocation, filter to nearby alerts; otherwise attach all active alerts + var alertsToAttach = activeAlerts; + if (!string.IsNullOrEmpty(call.GeoLocationData)) + { + var parts = call.GeoLocationData.Split(','); + if (parts.Length >= 2 && double.TryParse(parts[0].Trim(), out var lat) && double.TryParse(parts[1].Trim(), out var lng)) + { + alertsToAttach = activeAlerts.Where(a => + { + if (string.IsNullOrEmpty(a.CenterGeoLocation)) + return true; // Include alerts without a center point (area-wide alerts) + + var alertParts = a.CenterGeoLocation.Split(','); + if (alertParts.Length != 2 || !double.TryParse(alertParts[0].Trim(), out var aLat) || !double.TryParse(alertParts[1].Trim(), out var aLng)) + return true; + + return CalculateDistanceMiles(lat, lng, aLat, aLng) <= 50; + }).ToList(); + } + } + + if (alertsToAttach.Count == 0) + return; + + // Get existing call notes to check for duplicates + var existingNotes = await _callNotesRepository.GetCallNotesByCallIdAsync(call.CallId); + var existingAlertIds = new HashSet(); + if (existingNotes != null) + { + foreach (var note in existingNotes) + { + if (note.Source == (int)CallNoteSources.System && note.Note != null && note.Note.StartsWith("[WeatherAlert:")) + { + var endIdx = note.Note.IndexOf(']'); + if (endIdx > 15) + existingAlertIds.Add(note.Note.Substring(15, endIdx - 15)); + } + } + } + + foreach (var alert in alertsToAttach) + { + var alertIdStr = alert.WeatherAlertId.ToString(); + + // Skip if this exact alert was already attached + if (existingAlertIds.Contains(alertIdStr)) + continue; + + var noteText = $"[WeatherAlert:{alertIdStr}] {alert.Event}"; + + if (!string.IsNullOrEmpty(alert.Headline)) + noteText += $"\n{alert.Headline}"; + + if (!string.IsNullOrEmpty(alert.AreaDescription)) + noteText += $"\nArea: {alert.AreaDescription}"; + + var severityNames = new[] { "Extreme", "Severe", "Moderate", "Minor", "Unknown" }; + noteText += $"\nSeverity: {severityNames[Math.Min(alert.Severity, 4)]}"; + + if (alert.ExpiresUtc.HasValue) + noteText += $"\nExpires: {alert.ExpiresUtc.Value:yyyy-MM-dd HH:mm} UTC"; + + if (!string.IsNullOrEmpty(alert.Instruction)) + noteText += $"\nInstructions: {alert.Instruction}"; + + var callNote = new CallNote + { + CallId = call.CallId, + UserId = call.ReportingUserId, + Note = noteText, + Source = (int)CallNoteSources.System, + Timestamp = DateTime.UtcNow + }; + + await _callNotesRepository.SaveOrUpdateAsync(callNote, ct); + } + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + } + } + + #endregion + + #region Cache + + public async Task InvalidateDepartmentWeatherCacheAsync(int departmentId) + { + // Cache invalidation - can be expanded when caching is implemented + return await Task.FromResult(true); + } + + #endregion + + #region Helpers + + private static readonly string[] SeverityNames = { "Extreme", "Severe", "Moderate", "Minor", "Unknown" }; + private static readonly string[] UrgencyNames = { "Immediate", "Expected", "Future", "Past", "Unknown" }; + private static readonly string[] CertaintyNames = { "Observed", "Likely", "Possible", "Unlikely", "Unknown" }; + private static readonly string[] CategoryNames = { "Meteorological", "Fire", "Health", "Environmental", "Other" }; + + private static string FormatAlertSubject(WeatherAlert alert) + { + var sev = SeverityNames[Math.Min(alert.Severity, 4)]; + // Subject max 150 chars per Message entity + var subject = $"[{sev}] Weather Alert: {alert.Event}"; + return subject.Length > 150 ? subject.Substring(0, 147) + "..." : subject; + } + + private static string FormatAlertMessageBody(WeatherAlert alert, Department department) + { + var sb = new System.Text.StringBuilder(); + + // Header + sb.AppendLine($"=== WEATHER ALERT: {alert.Event?.ToUpper()} ==="); + sb.AppendLine(); + + // Headline + if (!string.IsNullOrEmpty(alert.Headline)) + { + sb.AppendLine(alert.Headline); + sb.AppendLine(); + } + + // Classification grid + sb.AppendLine("--- Alert Details ---"); + sb.AppendLine($"Severity: {SeverityNames[Math.Min(alert.Severity, 4)]}"); + sb.AppendLine($"Urgency: {UrgencyNames[Math.Min(alert.Urgency, 4)]}"); + sb.AppendLine($"Certainty: {CertaintyNames[Math.Min(alert.Certainty, 4)]}"); + sb.AppendLine($"Category: {CategoryNames[Math.Min(alert.AlertCategory, 4)]}"); + sb.AppendLine(); + + // Timing + sb.AppendLine("--- Timing ---"); + if (department != null) + { + sb.AppendLine($"Effective: {alert.EffectiveUtc.TimeConverter(department):MM/dd/yyyy h:mm tt}"); + if (alert.OnsetUtc.HasValue) + sb.AppendLine($"Onset: {alert.OnsetUtc.Value.TimeConverter(department):MM/dd/yyyy h:mm tt}"); + if (alert.ExpiresUtc.HasValue) + sb.AppendLine($"Expires: {alert.ExpiresUtc.Value.TimeConverter(department):MM/dd/yyyy h:mm tt}"); + } + else + { + sb.AppendLine($"Effective: {alert.EffectiveUtc:yyyy-MM-dd HH:mm} UTC"); + if (alert.OnsetUtc.HasValue) + sb.AppendLine($"Onset: {alert.OnsetUtc.Value:yyyy-MM-dd HH:mm} UTC"); + if (alert.ExpiresUtc.HasValue) + sb.AppendLine($"Expires: {alert.ExpiresUtc.Value:yyyy-MM-dd HH:mm} UTC"); + } + sb.AppendLine(); + + // Affected area + if (!string.IsNullOrEmpty(alert.AreaDescription)) + { + sb.AppendLine("--- Affected Area ---"); + sb.AppendLine(alert.AreaDescription); + sb.AppendLine(); + } + + // Description + if (!string.IsNullOrEmpty(alert.Description)) + { + sb.AppendLine("--- Description ---"); + sb.AppendLine(alert.Description); + sb.AppendLine(); + } + + // Instructions (critical for responders) + if (!string.IsNullOrEmpty(alert.Instruction)) + { + sb.AppendLine("--- INSTRUCTIONS ---"); + sb.AppendLine(alert.Instruction); + sb.AppendLine(); + } + + // Source info + if (!string.IsNullOrEmpty(alert.Sender)) + sb.AppendLine($"Source: {alert.Sender}"); + + sb.AppendLine($"Alert ID: {alert.ExternalId}"); + sb.AppendLine(); + sb.AppendLine("This is an automated weather alert from the Resgrid Weather Alert System."); + + // Respect the 4000 char body limit + var body = sb.ToString(); + if (body.Length > 3950) + body = body.Substring(0, 3947) + "..."; + + return body; + } + + private static double CalculateDistanceMiles(double lat1, double lng1, double lat2, double lng2) + { + const double R = 3959; // Earth's radius in miles + var dLat = ToRadians(lat2 - lat1); + var dLng = ToRadians(lng2 - lng1); + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) * + Math.Sin(dLng / 2) * Math.Sin(dLng / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return R * c; + } + + private static double ToRadians(double degrees) => degrees * Math.PI / 180; + + #endregion + } +} diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 9af06638..17ebb447 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1641,5 +1641,20 @@ public static void AddCommunicationTestClaims(ClaimsIdentity identity, bool isAd identity.AddClaim(new Claim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Delete)); } } + + /// + /// Weather alert viewing is available to all users. Management (create/update/delete) is admin-only. + /// + public static void AddWeatherAlertClaims(ClaimsIdentity identity, bool isAdmin) + { + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.View)); + + if (isAdmin) + { + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)); + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Create)); + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Delete)); + } + } } } diff --git a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs index c44dc777..512da8ac 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs @@ -205,6 +205,7 @@ public override async Task CreateAsync(TUser user) ClaimsLogic.AddUdfClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddRouteClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddCommunicationTestClaims(id, departmentAdmin); + ClaimsLogic.AddWeatherAlertClaims(id, departmentAdmin); } } diff --git a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs index 814a289f..7c3fde49 100644 --- a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs +++ b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs @@ -128,6 +128,7 @@ public async Task BuildTokenAsync(string userId, int departmentId) ClaimsLogic.AddUdfClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddRouteClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddCommunicationTestClaims(id, departmentAdmin); + ClaimsLogic.AddWeatherAlertClaims(id, departmentAdmin); var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtConfig.Key)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature); diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs index 5fc38b32..9c0426d2 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -65,6 +65,7 @@ public static class Resources public const string Udf = "Udf"; public const string Route = "Route"; public const string CommunicationTest = "CommunicationTest"; + public const string WeatherAlert = "WeatherAlert"; } public static string CreateDepartmentClaimTypeString(int departmentId) diff --git a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs index 8c3405ab..b7a588d6 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs @@ -1087,5 +1087,10 @@ public void AddCommunicationTestClaims(bool isAdmin) { ClaimsLogic.AddCommunicationTestClaims(this, isAdmin); } + + public void AddWeatherAlertClaims(bool isAdmin) + { + ClaimsLogic.AddWeatherAlertClaims(this, isAdmin); + } } } diff --git a/Providers/Resgrid.Providers.Claims/ResgridResources.cs b/Providers/Resgrid.Providers.Claims/ResgridResources.cs index 854fbdad..b51a2e66 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -172,5 +172,10 @@ public static class ResgridResources public const string CommunicationTest_Update = "CommunicationTest_Update"; public const string CommunicationTest_Create = "CommunicationTest_Create"; public const string CommunicationTest_Delete = "CommunicationTest_Delete"; + + public const string WeatherAlert_View = "WeatherAlert_View"; + public const string WeatherAlert_Update = "WeatherAlert_Update"; + public const string WeatherAlert_Create = "WeatherAlert_Create"; + public const string WeatherAlert_Delete = "WeatherAlert_Delete"; } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0063_AddingWeatherAlerts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0063_AddingWeatherAlerts.cs new file mode 100644 index 00000000..191353bf --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0063_AddingWeatherAlerts.cs @@ -0,0 +1,122 @@ +using FluentMigrator; +using System; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(63)] + public class M0063_AddingWeatherAlerts : Migration + { + public override void Up() + { + // WeatherAlertSources table + Create.Table("WeatherAlertSources") + .WithColumn("WeatherAlertSourceId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("SourceType").AsInt32().NotNullable() + .WithColumn("AreaFilter").AsString(1000).Nullable() + .WithColumn("ApiKey").AsString(500).Nullable() + .WithColumn("CustomEndpoint").AsString(2000).Nullable() + .WithColumn("PollIntervalMinutes").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("Active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("LastPollUtc").AsDateTime2().Nullable() + .WithColumn("LastSuccessUtc").AsDateTime2().Nullable() + .WithColumn("IsFailure").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ErrorMessage").AsString(2000).Nullable() + .WithColumn("LastETag").AsString(500).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).NotNullable(); + + Create.ForeignKey("FK_WeatherAlertSources_Departments") + .FromTable("WeatherAlertSources").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_WeatherAlertSources_DepartmentId") + .OnTable("WeatherAlertSources").OnColumn("DepartmentId").Ascending(); + + Create.Index("IX_WeatherAlertSources_Active") + .OnTable("WeatherAlertSources").OnColumn("Active").Ascending(); + + // WeatherAlerts table + Create.Table("WeatherAlerts") + .WithColumn("WeatherAlertId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WeatherAlertSourceId").AsGuid().NotNullable() + .WithColumn("ExternalId").AsString(500).NotNullable() + .WithColumn("Sender").AsString(500).Nullable() + .WithColumn("Event").AsString(500).NotNullable() + .WithColumn("AlertCategory").AsInt32().NotNullable() + .WithColumn("Severity").AsInt32().NotNullable() + .WithColumn("Urgency").AsInt32().NotNullable() + .WithColumn("Certainty").AsInt32().NotNullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Headline").AsString(500).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Instruction").AsString(int.MaxValue).Nullable() + .WithColumn("AreaDescription").AsString(500).Nullable() + .WithColumn("Polygon").AsString(int.MaxValue).Nullable() + .WithColumn("Geocodes").AsString(int.MaxValue).Nullable() + .WithColumn("CenterGeoLocation").AsString(100).Nullable() + .WithColumn("OnsetUtc").AsDateTime2().Nullable() + .WithColumn("ExpiresUtc").AsDateTime2().Nullable() + .WithColumn("EffectiveUtc").AsDateTime2().NotNullable() + .WithColumn("SentUtc").AsDateTime2().Nullable() + .WithColumn("FirstSeenUtc").AsDateTime2().NotNullable() + .WithColumn("LastUpdatedUtc").AsDateTime2().NotNullable() + .WithColumn("ReferencesExternalId").AsString(500).Nullable() + .WithColumn("NotificationSent").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SystemMessageId").AsInt32().Nullable(); + + Create.ForeignKey("FK_WeatherAlerts_Departments") + .FromTable("WeatherAlerts").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.ForeignKey("FK_WeatherAlerts_WeatherAlertSources") + .FromTable("WeatherAlerts").ForeignColumn("WeatherAlertSourceId") + .ToTable("WeatherAlertSources").PrimaryColumn("WeatherAlertSourceId"); + + Create.Index("IX_WeatherAlerts_DepartmentId") + .OnTable("WeatherAlerts").OnColumn("DepartmentId").Ascending(); + + Create.Index("IX_WeatherAlerts_ExternalId_SourceId") + .OnTable("WeatherAlerts") + .OnColumn("ExternalId").Ascending() + .OnColumn("WeatherAlertSourceId").Ascending() + .WithOptions().Unique(); + + Create.Index("IX_WeatherAlerts_Status_ExpiresUtc") + .OnTable("WeatherAlerts") + .OnColumn("Status").Ascending() + .OnColumn("ExpiresUtc").Ascending(); + + Create.Index("IX_WeatherAlerts_NotificationSent") + .OnTable("WeatherAlerts").OnColumn("NotificationSent").Ascending(); + + // WeatherAlertZones table + Create.Table("WeatherAlertZones") + .WithColumn("WeatherAlertZoneId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("ZoneCode").AsString(100).Nullable() + .WithColumn("CenterGeoLocation").AsString(100).Nullable() + .WithColumn("RadiusMiles").AsDouble().NotNullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsPrimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CreatedOn").AsDateTime2().NotNullable(); + + Create.ForeignKey("FK_WeatherAlertZones_Departments") + .FromTable("WeatherAlertZones").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_WeatherAlertZones_DepartmentId") + .OnTable("WeatherAlertZones").OnColumn("DepartmentId").Ascending(); + } + + public override void Down() + { + Delete.Table("WeatherAlertZones"); + Delete.Table("WeatherAlerts"); + Delete.Table("WeatherAlertSources"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0063_AddingWeatherAlertsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0063_AddingWeatherAlertsPg.cs new file mode 100644 index 00000000..d9a499c7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0063_AddingWeatherAlertsPg.cs @@ -0,0 +1,126 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(63)] + public class M0063_AddingWeatherAlertsPg : Migration + { + public override void Up() + { + // weatheralertsources table + Create.Table("weatheralertsources") + .WithColumn("weatheralertsourceid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("sourcetype").AsInt32().NotNullable() + .WithColumn("areafilter").AsCustom("citext").Nullable() + .WithColumn("apikey").AsCustom("citext").Nullable() + .WithColumn("customendpoint").AsCustom("citext").Nullable() + .WithColumn("pollintervalminutes").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("lastpollutc").AsDateTime().Nullable() + .WithColumn("lastsuccessutc").AsDateTime().Nullable() + .WithColumn("isfailure").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("errormessage").AsCustom("citext").Nullable() + .WithColumn("lastetag").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable(); + + Create.ForeignKey("fk_weatheralertsources_departments") + .FromTable("weatheralertsources").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_weatheralertsources_departmentid") + .OnTable("weatheralertsources") + .OnColumn("departmentid"); + + Create.Index("ix_weatheralertsources_active") + .OnTable("weatheralertsources") + .OnColumn("active"); + + // weatheralerts table + Create.Table("weatheralerts") + .WithColumn("weatheralertid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("weatheralertsourceid").AsCustom("citext").NotNullable() + .WithColumn("externalid").AsCustom("citext").NotNullable() + .WithColumn("sender").AsCustom("citext").Nullable() + .WithColumn("event").AsCustom("citext").NotNullable() + .WithColumn("alertcategory").AsInt32().NotNullable() + .WithColumn("severity").AsInt32().NotNullable() + .WithColumn("urgency").AsInt32().NotNullable() + .WithColumn("certainty").AsInt32().NotNullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("headline").AsCustom("citext").Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("instruction").AsCustom("text").Nullable() + .WithColumn("areadescription").AsCustom("citext").Nullable() + .WithColumn("polygon").AsCustom("text").Nullable() + .WithColumn("geocodes").AsCustom("text").Nullable() + .WithColumn("centergeolocation").AsCustom("citext").Nullable() + .WithColumn("onsetutc").AsDateTime().Nullable() + .WithColumn("expiresutc").AsDateTime().Nullable() + .WithColumn("effectiveutc").AsDateTime().NotNullable() + .WithColumn("sentutc").AsDateTime().Nullable() + .WithColumn("firstseenutc").AsDateTime().NotNullable() + .WithColumn("lastupdatedutc").AsDateTime().NotNullable() + .WithColumn("referencesexternalid").AsCustom("citext").Nullable() + .WithColumn("notificationsent").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("systemmessageid").AsInt32().Nullable(); + + Create.ForeignKey("fk_weatheralerts_departments") + .FromTable("weatheralerts").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.ForeignKey("fk_weatheralerts_weatheralertsources") + .FromTable("weatheralerts").ForeignColumn("weatheralertsourceid") + .ToTable("weatheralertsources").PrimaryColumn("weatheralertsourceid"); + + Create.Index("ix_weatheralerts_departmentid") + .OnTable("weatheralerts") + .OnColumn("departmentid"); + + Create.Index("ix_weatheralerts_externalid_sourceid") + .OnTable("weatheralerts") + .OnColumn("externalid").Ascending() + .OnColumn("weatheralertsourceid").Ascending() + .WithOptions().Unique(); + + Create.Index("ix_weatheralerts_status_expiresutc") + .OnTable("weatheralerts") + .OnColumn("status").Ascending() + .OnColumn("expiresutc").Ascending(); + + Create.Index("ix_weatheralerts_notificationsent") + .OnTable("weatheralerts") + .OnColumn("notificationsent"); + + // weatheralertzones table + Create.Table("weatheralertzones") + .WithColumn("weatheralertzoneid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("zonecode").AsCustom("citext").Nullable() + .WithColumn("centergeolocation").AsCustom("citext").Nullable() + .WithColumn("radiusmiles").AsDouble().NotNullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isprimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("createdon").AsDateTime().NotNullable(); + + Create.ForeignKey("fk_weatheralertzones_departments") + .FromTable("weatheralertzones").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_weatheralertzones_departmentid") + .OnTable("weatheralertzones") + .OnColumn("departmentid"); + } + + public override void Down() + { + Delete.Table("weatheralertzones"); + Delete.Table("weatheralerts"); + Delete.Table("weatheralertsources"); + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs new file mode 100644 index 00000000..2d415674 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + public class EnvironmentCanadaWeatherAlertProvider : IWeatherAlertProvider + { + public WeatherAlertSourceType SourceType => WeatherAlertSourceType.EnvironmentCanada; + + public async Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default) + { + // Check shared cache first + if (WeatherAlertResponseCache.TryGet(SourceType, source.AreaFilter, out var cachedAlerts)) + { + return cachedAlerts; + } + + // TODO: Implement CAP XML parsing from Environment Canada + // Endpoint: https://dd.weather.gc.ca/alerts/cap/ + var alerts = new List(); + + // Store in shared cache + WeatherAlertResponseCache.Set(SourceType, source.AreaFilter, alerts); + + return alerts; + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs new file mode 100644 index 00000000..a836f4c8 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + public class MeteoAlarmWeatherAlertProvider : IWeatherAlertProvider + { + public WeatherAlertSourceType SourceType => WeatherAlertSourceType.MeteoAlarm; + + public async Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default) + { + // Check shared cache first + if (WeatherAlertResponseCache.TryGet(SourceType, source.AreaFilter, out var cachedAlerts)) + { + return cachedAlerts; + } + + // TODO: Implement MeteoAlarm API integration + // Endpoint: https://feeds.meteoalarm.org/api/v1/ + var alerts = new List(); + + // Store in shared cache + WeatherAlertResponseCache.Set(SourceType, source.AreaFilter, alerts); + + return alerts; + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs new file mode 100644 index 00000000..aee2efe3 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + public class NwsWeatherAlertProvider : IWeatherAlertProvider + { + private static readonly HttpClient _httpClient = new HttpClient(); + private const string DefaultBaseUrl = "https://api.weather.gov/alerts/active"; + + static NwsWeatherAlertProvider() + { + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/geo+json")); + } + + public WeatherAlertSourceType SourceType => WeatherAlertSourceType.NationalWeatherService; + + public async Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default) + { + // Check shared cache first + if (WeatherAlertResponseCache.TryGet(SourceType, source.AreaFilter, out var cachedAlerts)) + { + return CloneAlertsForSource(cachedAlerts, source); + } + + var alerts = new List(); + var baseUrl = !string.IsNullOrEmpty(source.CustomEndpoint) ? source.CustomEndpoint : DefaultBaseUrl; + + var url = baseUrl; + if (!string.IsNullOrEmpty(source.AreaFilter)) + { + var zones = ParseAreaFilter(source.AreaFilter); + if (zones.Length > 0) + { + // If codes look like state abbreviations (2 chars), use area parameter + if (zones[0].Length == 2) + url += $"?area={string.Join(",", zones)}"; + else + url += $"?zone={string.Join(",", zones)}"; + } + } + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Set User-Agent with department admin contact email per NWS requirements + var contactEmail = !string.IsNullOrEmpty(source.ContactEmail) ? source.ContactEmail : "noreply@resgrid.com"; + request.Headers.UserAgent.ParseAdd($"Resgrid/1.0 ({contactEmail})"); + + // Identify requests as coming from Resgrid + request.Headers.Add("X-Resgrid-Source", "Resgrid Weather Alert System"); + + // If an API key is provided, include it as a custom header + if (!string.IsNullOrEmpty(source.ApiKey)) + request.Headers.Add("X-Api-Key", source.ApiKey); + + // Use ETag for conditional requests + if (!string.IsNullOrEmpty(source.LastETag)) + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(source.LastETag)); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, ct); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"NWS weather alert HTTP request failed for URL '{url}': {ex.Message}", ex); + } + + if (response.StatusCode == System.Net.HttpStatusCode.NotModified) + return alerts; // No changes since last poll + + response.EnsureSuccessStatusCode(); + + // Update ETag on source + if (response.Headers.ETag != null) + source.LastETag = response.Headers.ETag.Tag; + + var json = await response.Content.ReadAsStringAsync(); + + // Validate response content-type is JSON before parsing + var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; + if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + var snippet = json.Length > 200 ? json.Substring(0, 200) : json; + throw new InvalidOperationException( + $"NWS API returned non-JSON response (Content-Type: '{contentType}') for URL '{url}'. " + + $"Response body starts with: {snippet}"); + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + var snippet = json.Length > 200 ? json.Substring(0, 200) : json; + throw new InvalidOperationException( + $"Failed to parse NWS JSON response for URL '{url}'. " + + $"Response body starts with: {snippet}", ex); + } + + using (doc) + { + var root = doc.RootElement; + + if (!root.TryGetProperty("features", out var features)) + return alerts; + + foreach (var feature in features.EnumerateArray()) + { + try + { + var props = feature.GetProperty("properties"); + var alert = new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = GetStringProp(props, "id"), + Sender = GetStringProp(props, "senderName"), + Event = GetStringProp(props, "event"), + AlertCategory = MapCategory(GetStringProp(props, "category")), + Severity = (int)MapSeverity(GetStringProp(props, "severity")), + Urgency = (int)MapUrgency(GetStringProp(props, "urgency")), + Certainty = (int)MapCertainty(GetStringProp(props, "certainty")), + Status = (int)WeatherAlertStatus.Active, + Headline = GetStringProp(props, "headline"), + Description = GetStringProp(props, "description"), + Instruction = GetStringProp(props, "instruction"), + AreaDescription = GetStringProp(props, "areaDesc"), + EffectiveUtc = GetDateProp(props, "effective") ?? DateTime.UtcNow, + OnsetUtc = GetDateProp(props, "onset"), + ExpiresUtc = GetDateProp(props, "expires"), + SentUtc = GetDateProp(props, "sent"), + FirstSeenUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow, + NotificationSent = false + }; + + // Extract references (for update/cancel chains) + var references = GetStringProp(props, "references"); + if (!string.IsNullOrEmpty(references)) + alert.ReferencesExternalId = references; + + // Extract geocodes + if (props.TryGetProperty("geocode", out var geocode)) + alert.Geocodes = geocode.GetRawText(); + + // Extract polygon from geometry + if (feature.TryGetProperty("geometry", out var geometry) && geometry.ValueKind != JsonValueKind.Null) + { + alert.Polygon = geometry.GetRawText(); + + // Try to extract center point from polygon + if (geometry.TryGetProperty("coordinates", out var coords) && coords.GetArrayLength() > 0) + { + try + { + var ring = coords[0]; + double avgLat = 0, avgLng = 0; + int count = 0; + foreach (var point in ring.EnumerateArray()) + { + avgLng += point[0].GetDouble(); + avgLat += point[1].GetDouble(); + count++; + } + if (count > 0) + alert.CenterGeoLocation = $"{avgLat / count},{avgLng / count}"; + } + catch { } + } + } + + alerts.Add(alert); + } + catch (Exception) + { + // Skip malformed alerts, continue with others + continue; + } + } + } + + // Store in shared cache + WeatherAlertResponseCache.Set(SourceType, source.AreaFilter, alerts); + + return alerts; + } + + private static List CloneAlertsForSource(List cachedAlerts, WeatherAlertSource source) + { + return cachedAlerts.Select(a => new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = a.ExternalId, + Sender = a.Sender, + Event = a.Event, + AlertCategory = a.AlertCategory, + Severity = a.Severity, + Urgency = a.Urgency, + Certainty = a.Certainty, + Status = a.Status, + Headline = a.Headline, + Description = a.Description, + Instruction = a.Instruction, + AreaDescription = a.AreaDescription, + EffectiveUtc = a.EffectiveUtc, + OnsetUtc = a.OnsetUtc, + ExpiresUtc = a.ExpiresUtc, + SentUtc = a.SentUtc, + FirstSeenUtc = a.FirstSeenUtc, + LastUpdatedUtc = a.LastUpdatedUtc, + NotificationSent = false, + ReferencesExternalId = a.ReferencesExternalId, + Geocodes = a.Geocodes, + Polygon = a.Polygon, + CenterGeoLocation = a.CenterGeoLocation + }).ToList(); + } + + private static string GetStringProp(JsonElement element, string name) + { + if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) + return prop.GetString(); + return null; + } + + private static DateTime? GetDateProp(JsonElement element, string name) + { + var value = GetStringProp(element, name); + if (!string.IsNullOrEmpty(value) && DateTime.TryParse(value, out var dt)) + return dt.ToUniversalTime(); + return null; + } + + private static int MapCategory(string category) + { + return category?.ToLowerInvariant() switch + { + "met" => (int)WeatherAlertCategory.Met, + "fire" => (int)WeatherAlertCategory.Fire, + "health" => (int)WeatherAlertCategory.Health, + "env" => (int)WeatherAlertCategory.Env, + _ => (int)WeatherAlertCategory.Other + }; + } + + private static WeatherAlertSeverity MapSeverity(string severity) + { + return severity?.ToLowerInvariant() switch + { + "extreme" => WeatherAlertSeverity.Extreme, + "severe" => WeatherAlertSeverity.Severe, + "moderate" => WeatherAlertSeverity.Moderate, + "minor" => WeatherAlertSeverity.Minor, + _ => WeatherAlertSeverity.Unknown + }; + } + + private static WeatherAlertUrgency MapUrgency(string urgency) + { + return urgency?.ToLowerInvariant() switch + { + "immediate" => WeatherAlertUrgency.Immediate, + "expected" => WeatherAlertUrgency.Expected, + "future" => WeatherAlertUrgency.Future, + "past" => WeatherAlertUrgency.Past, + _ => WeatherAlertUrgency.Unknown + }; + } + + private static WeatherAlertCertainty MapCertainty(string certainty) + { + return certainty?.ToLowerInvariant() switch + { + "observed" => WeatherAlertCertainty.Observed, + "likely" => WeatherAlertCertainty.Likely, + "possible" => WeatherAlertCertainty.Possible, + "unlikely" => WeatherAlertCertainty.Unlikely, + _ => WeatherAlertCertainty.Unknown + }; + } + + /// + /// Parses AreaFilter which may be a JSON array (["NV","CA"]) or a raw + /// comma-separated string (NV, CA) or a single value (NV). + /// + private static string[] ParseAreaFilter(string areaFilter) + { + if (string.IsNullOrWhiteSpace(areaFilter)) + return Array.Empty(); + + var trimmed = areaFilter.Trim(); + + // Try JSON array first + if (trimmed.StartsWith("[")) + { + try + { + var parsed = JsonSerializer.Deserialize(trimmed); + if (parsed != null && parsed.Length > 0) + return parsed; + } + catch { } + } + + // Fall back to comma-separated string + return trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/Resgrid.Providers.Weather.csproj b/Providers/Resgrid.Providers.Weather/Resgrid.Providers.Weather.csproj new file mode 100644 index 00000000..cb6dac61 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/Resgrid.Providers.Weather.csproj @@ -0,0 +1,16 @@ + + + net9.0 + Debug;Release;Docker + + + + + + + + + + + + diff --git a/Providers/Resgrid.Providers.Weather/WeatherAlertProviderFactory.cs b/Providers/Resgrid.Providers.Weather/WeatherAlertProviderFactory.cs new file mode 100644 index 00000000..5123ead0 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/WeatherAlertProviderFactory.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + public class WeatherAlertProviderFactory : IWeatherAlertProviderFactory + { + private readonly IEnumerable _providers; + + public WeatherAlertProviderFactory(IEnumerable providers) + { + _providers = providers; + } + + public IWeatherAlertProvider GetProvider(WeatherAlertSourceType sourceType) + { + var provider = _providers.FirstOrDefault(p => p.SourceType == sourceType); + if (provider == null) + throw new NotSupportedException($"No weather alert provider found for source type: {sourceType}"); + return provider; + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs b/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs new file mode 100644 index 00000000..f963bc67 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Providers.Weather +{ + public class WeatherAlertResponseCache + { + private static readonly ConcurrentDictionary _cache = new(); + + public static int DefaultCacheMinutes { get; set; } = 10; + + public static bool TryGet(WeatherAlertSourceType sourceType, string areaFilter, out List alerts) + { + var key = BuildKey(sourceType, areaFilter); + if (_cache.TryGetValue(key, out var entry) && entry.ExpiresUtc > DateTime.UtcNow) + { + alerts = entry.Alerts; + return true; + } + alerts = null; + return false; + } + + public static void Set(WeatherAlertSourceType sourceType, string areaFilter, List alerts, int? cacheMinutes = null) + { + var key = BuildKey(sourceType, areaFilter); + var ttl = cacheMinutes ?? DefaultCacheMinutes; + _cache[key] = new CacheEntry + { + Alerts = alerts, + ExpiresUtc = DateTime.UtcNow.AddMinutes(ttl) + }; + } + + public static void Clear() + { + _cache.Clear(); + } + + private static string BuildKey(WeatherAlertSourceType sourceType, string areaFilter) + { + return $"{sourceType}:{areaFilter ?? ""}".ToLowerInvariant(); + } + + private class CacheEntry + { + public List Alerts { get; set; } + public DateTime ExpiresUtc { get; set; } + } + } +} diff --git a/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs b/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs new file mode 100644 index 00000000..a82b4e21 --- /dev/null +++ b/Providers/Resgrid.Providers.Weather/WeatherProviderModule.cs @@ -0,0 +1,16 @@ +using Autofac; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Weather +{ + public class WeatherProviderModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 35a301fc..c467e885 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -546,6 +546,23 @@ protected SqlConfiguration() { } public string SelectCommTestResultByResponseTokenQuery { get; set; } #endregion CommunicationTests + #region WeatherAlerts + public string WeatherAlertSourcesTable { get; set; } + public string WeatherAlertsTable { get; set; } + public string WeatherAlertZonesTable { get; set; } + public string SelectActiveWeatherAlertSourcesForPollingQuery { get; set; } + public string SelectWeatherAlertSourcesByDepartmentIdQuery { get; set; } + public string SelectActiveWeatherAlertsByDepartmentIdQuery { get; set; } + public string SelectWeatherAlertByExternalIdAndSourceIdQuery { get; set; } + public string SelectWeatherAlertsByDepartmentAndSeverityQuery { get; set; } + public string SelectWeatherAlertsByDepartmentAndCategoryQuery { get; set; } + public string SelectExpiredUnprocessedWeatherAlertsQuery { get; set; } + public string SelectUnnotifiedWeatherAlertsQuery { get; set; } + public string SelectWeatherAlertHistoryByDepartmentQuery { get; set; } + public string SelectWeatherAlertZonesByDepartmentIdQuery { get; set; } + public string SelectActiveWeatherAlertZonesByDepartmentIdQuery { get; set; } + #endregion WeatherAlerts + // Identity #region Table Names diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index c408e5bf..d805672b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -137,6 +137,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 6aa3addd..45b2b5c5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -136,6 +136,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 3937d42c..be27bb3f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -136,6 +136,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index e79a417e..7bc837f7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -136,6 +136,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertSourcesForPollingQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertSourcesForPollingQuery.cs new file mode 100644 index 00000000..20173617 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertSourcesForPollingQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectActiveWeatherAlertSourcesForPollingQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectActiveWeatherAlertSourcesForPollingQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectActiveWeatherAlertSourcesForPollingQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertSourcesTable, + _sqlConfiguration.ParameterNotation, + new string[] { }, + new string[] { }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertZonesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertZonesByDepartmentIdQuery.cs new file mode 100644 index 00000000..141ec837 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertZonesByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectActiveWeatherAlertZonesByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectActiveWeatherAlertZonesByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectActiveWeatherAlertZonesByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertZonesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertsByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertsByDepartmentIdQuery.cs new file mode 100644 index 00000000..b264ea27 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectActiveWeatherAlertsByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectActiveWeatherAlertsByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectActiveWeatherAlertsByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectActiveWeatherAlertsByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectExpiredUnprocessedWeatherAlertsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectExpiredUnprocessedWeatherAlertsQuery.cs new file mode 100644 index 00000000..9f58d446 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectExpiredUnprocessedWeatherAlertsQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectExpiredUnprocessedWeatherAlertsQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectExpiredUnprocessedWeatherAlertsQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectExpiredUnprocessedWeatherAlertsQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { }, + new string[] { }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectUnnotifiedWeatherAlertsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectUnnotifiedWeatherAlertsQuery.cs new file mode 100644 index 00000000..a8a4e215 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectUnnotifiedWeatherAlertsQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectUnnotifiedWeatherAlertsQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectUnnotifiedWeatherAlertsQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectUnnotifiedWeatherAlertsQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { }, + new string[] { }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertByExternalIdAndSourceIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertByExternalIdAndSourceIdQuery.cs new file mode 100644 index 00000000..03249414 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertByExternalIdAndSourceIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertByExternalIdAndSourceIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertByExternalIdAndSourceIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertByExternalIdAndSourceIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%EXTERNALID%", "%SOURCEID%" }, + new string[] { "ExternalId", "SourceId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertHistoryByDepartmentQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertHistoryByDepartmentQuery.cs new file mode 100644 index 00000000..a822d721 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertHistoryByDepartmentQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertHistoryByDepartmentQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertHistoryByDepartmentQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertHistoryByDepartmentQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%", "%STARTDATE%", "%ENDDATE%" }, + new string[] { "DepartmentId", "StartDate", "EndDate" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertSourcesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertSourcesByDepartmentIdQuery.cs new file mode 100644 index 00000000..a945516c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertSourcesByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertSourcesByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertSourcesByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertSourcesByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertSourcesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertZonesByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertZonesByDepartmentIdQuery.cs new file mode 100644 index 00000000..fdafedae --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertZonesByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertZonesByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertZonesByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertZonesByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertZonesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndCategoryQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndCategoryQuery.cs new file mode 100644 index 00000000..20ee0784 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndCategoryQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertsByDepartmentAndCategoryQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertsByDepartmentAndCategoryQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertsByDepartmentAndCategoryQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%", "%CATEGORY%" }, + new string[] { "DepartmentId", "Category" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndSeverityQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndSeverityQuery.cs new file mode 100644 index 00000000..c627c2b0 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/WeatherAlerts/SelectWeatherAlertsByDepartmentAndSeverityQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.WeatherAlerts +{ + public class SelectWeatherAlertsByDepartmentAndSeverityQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectWeatherAlertsByDepartmentAndSeverityQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectWeatherAlertsByDepartmentAndSeverityQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.WeatherAlertsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%DEPARTMENTID%", "%MAXSEVERITY%" }, + new string[] { "DepartmentId", "MaxSeverity" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 9982d199..e9830fe5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1656,6 +1656,23 @@ ORDER BY Timestamp DESC SelectCommTestResultByResponseTokenQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ResponseToken = %TOKEN% LIMIT 1"; #endregion CommunicationTests + #region WeatherAlerts + WeatherAlertSourcesTable = "WeatherAlertSources"; + WeatherAlertsTable = "WeatherAlerts"; + WeatherAlertZonesTable = "WeatherAlertZones"; + SelectActiveWeatherAlertSourcesForPollingQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Active = true"; + SelectWeatherAlertSourcesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID%"; + SelectActiveWeatherAlertsByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND Status = 0"; + SelectWeatherAlertByExternalIdAndSourceIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ExternalId = %EXTERNALID% AND WeatherAlertSourceId = %SOURCEID% LIMIT 1"; + SelectWeatherAlertsByDepartmentAndSeverityQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND Severity <= %MAXSEVERITY% AND Status = 0"; + SelectWeatherAlertsByDepartmentAndCategoryQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND AlertCategory = %CATEGORY% AND Status = 0"; + SelectExpiredUnprocessedWeatherAlertsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Status = 0 AND ExpiresUtc IS NOT NULL AND ExpiresUtc < NOW() AT TIME ZONE 'UTC'"; + SelectUnnotifiedWeatherAlertsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE NotificationSent = false AND Status = 0"; + SelectWeatherAlertHistoryByDepartmentQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND FirstSeenUtc >= %STARTDATE% AND FirstSeenUtc <= %ENDDATE% ORDER BY FirstSeenUtc DESC"; + SelectWeatherAlertZonesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID%"; + SelectActiveWeatherAlertZonesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND IsActive = true"; + #endregion WeatherAlerts + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 10cbc2ee..d5697c3a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1617,6 +1617,23 @@ SELECT TOP 1 * SelectCommTestResultByResponseTokenQuery = "SELECT TOP 1 * FROM %SCHEMA%.%TABLENAME% WHERE [ResponseToken] = %TOKEN%"; #endregion CommunicationTests + #region WeatherAlerts + WeatherAlertSourcesTable = "WeatherAlertSources"; + WeatherAlertsTable = "WeatherAlerts"; + WeatherAlertZonesTable = "WeatherAlertZones"; + SelectActiveWeatherAlertSourcesForPollingQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [Active] = 1"; + SelectWeatherAlertSourcesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID%"; + SelectActiveWeatherAlertsByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [Status] = 0"; + SelectWeatherAlertByExternalIdAndSourceIdQuery = "SELECT TOP 1 * FROM %SCHEMA%.%TABLENAME% WHERE [ExternalId] = %EXTERNALID% AND [WeatherAlertSourceId] = %SOURCEID%"; + SelectWeatherAlertsByDepartmentAndSeverityQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [Severity] <= %MAXSEVERITY% AND [Status] = 0"; + SelectWeatherAlertsByDepartmentAndCategoryQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [AlertCategory] = %CATEGORY% AND [Status] = 0"; + SelectExpiredUnprocessedWeatherAlertsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [Status] = 0 AND [ExpiresUtc] IS NOT NULL AND [ExpiresUtc] < GETUTCDATE()"; + SelectUnnotifiedWeatherAlertsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [NotificationSent] = 0 AND [Status] = 0"; + SelectWeatherAlertHistoryByDepartmentQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [FirstSeenUtc] >= %STARTDATE% AND [FirstSeenUtc] <= %ENDDATE% ORDER BY [FirstSeenUtc] DESC"; + SelectWeatherAlertZonesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID%"; + SelectActiveWeatherAlertZonesByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [IsActive] = 1"; + #endregion WeatherAlerts + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertRepository.cs new file mode 100644 index 00000000..ff6cb19d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertRepository.cs @@ -0,0 +1,301 @@ +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.WeatherAlerts; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class WeatherAlertRepository : RepositoryBase, IWeatherAlertRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public WeatherAlertRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetActiveAlertsByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task GetByExternalIdAndSourceIdAsync(string externalId, Guid sourceId) + { + try + { + var selectFunction = new Func>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("ExternalId", externalId); + dynamicParameters.Add("SourceId", sourceId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryFirstOrDefaultAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetAlertsByDepartmentAndSeverityAsync(int departmentId, int maxSeverity) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("MaxSeverity", maxSeverity); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetAlertsByDepartmentAndCategoryAsync(int departmentId, int category) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("Category", category); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetExpiredUnprocessedAlertsAsync() + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetUnnotifiedAlertsAsync() + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetAlertHistoryByDepartmentAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertSourceRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertSourceRepository.cs new file mode 100644 index 00000000..bfd5b4dd --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertSourceRepository.cs @@ -0,0 +1,107 @@ +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.WeatherAlerts; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class WeatherAlertSourceRepository : RepositoryBase, IWeatherAlertSourceRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public WeatherAlertSourceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetActiveSourcesForPollingAsync() + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetSourcesByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs new file mode 100644 index 00000000..75b1514c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs @@ -0,0 +1,108 @@ +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.WeatherAlerts; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class WeatherAlertZoneRepository : RepositoryBase, IWeatherAlertZoneRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public WeatherAlertZoneRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetZonesByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetActiveZonesByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Resgrid.sln b/Resgrid.sln index 54549e05..de74e3a9 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -96,814 +96,1254 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Support", "Support", "{8933 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Quidjibo.Postgres", "Workers\Support\Quidjibo.Postgres\Quidjibo.Postgres.csproj", "{744B3BB7-B5F6-4002-93E2-FC0821D41963}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Weather", "Providers\Resgrid.Providers.Weather\Resgrid.Providers.Weather.csproj", "{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Azure|Any CPU = Azure|Any CPU Azure|x86 = Azure|x86 + Azure|x64 = Azure|x64 Cloud|Any CPU = Cloud|Any CPU Cloud|x86 = Cloud|x86 + Cloud|x64 = Cloud|x64 Debug|Any CPU = Debug|Any CPU Debug|x86 = Debug|x86 + Debug|x64 = Debug|x64 Docker|Any CPU = Docker|Any CPU Docker|x86 = Docker|x86 + Docker|x64 = Docker|x64 Release|Any CPU = Release|Any CPU Release|x86 = Release|x86 + Release|x64 = Release|x64 Staging|Any CPU = Staging|Any CPU Staging|x86 = Staging|x86 + Staging|x64 = Staging|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|Any CPU.ActiveCfg = Azure|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|Any CPU.Build.0 = Azure|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|x86.Build.0 = Debug|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|x64.ActiveCfg = Azure|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Azure|x64.Build.0 = Azure|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x86.Build.0 = Debug|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x64.Build.0 = Cloud|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|x86.Build.0 = Debug|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|x64.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|Any CPU.Build.0 = Docker|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|x86.ActiveCfg = Docker|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|x86.Build.0 = Docker|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|x64.ActiveCfg = Docker|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Docker|x64.Build.0 = Docker|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|Any CPU.ActiveCfg = Release|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|Any CPU.Build.0 = Release|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|x86.ActiveCfg = Release|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|x86.Build.0 = Release|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|x64.ActiveCfg = Release|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Release|x64.Build.0 = Release|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x86.Build.0 = Debug|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x64.ActiveCfg = Staging|Any CPU + {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x64.Build.0 = Staging|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x86.Build.0 = Debug|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x64.ActiveCfg = Azure|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x64.Build.0 = Azure|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x86.Build.0 = Debug|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x64.Build.0 = Cloud|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|x86.Build.0 = Debug|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|x64.ActiveCfg = Debug|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|x64.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|Any CPU.Build.0 = Docker|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|x86.ActiveCfg = Docker|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|x86.Build.0 = Docker|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|x64.ActiveCfg = Docker|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Docker|x64.Build.0 = Docker|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Release|Any CPU.ActiveCfg = Release|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Release|Any CPU.Build.0 = Release|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Release|x86.ActiveCfg = Release|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Release|x86.Build.0 = Release|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Release|x64.ActiveCfg = Release|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Release|x64.Build.0 = Release|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x86.Build.0 = Debug|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x64.ActiveCfg = Staging|Any CPU + {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x64.Build.0 = Staging|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x86.Build.0 = Debug|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x64.ActiveCfg = Azure|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x64.Build.0 = Azure|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x86.Build.0 = Debug|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x64.Build.0 = Cloud|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|x86.Build.0 = Debug|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|x64.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|Any CPU.Build.0 = Docker|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|x86.ActiveCfg = Docker|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|x86.Build.0 = Docker|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|x64.ActiveCfg = Docker|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Docker|x64.Build.0 = Docker|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|Any CPU.Build.0 = Release|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|x86.ActiveCfg = Release|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|x86.Build.0 = Release|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|x64.ActiveCfg = Release|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Release|x64.Build.0 = Release|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x86.Build.0 = Debug|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x64.ActiveCfg = Staging|Any CPU + {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x64.Build.0 = Staging|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x86.Build.0 = Debug|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x64.ActiveCfg = Azure|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x64.Build.0 = Azure|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x86.Build.0 = Debug|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x64.Build.0 = Cloud|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|x86.Build.0 = Debug|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|x64.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|Any CPU.Build.0 = Docker|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|x86.ActiveCfg = Docker|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|x86.Build.0 = Docker|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|x64.ActiveCfg = Docker|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Docker|x64.Build.0 = Docker|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Release|Any CPU.Build.0 = Release|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Release|x86.ActiveCfg = Release|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Release|x86.Build.0 = Release|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Release|x64.ActiveCfg = Release|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Release|x64.Build.0 = Release|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x86.Build.0 = Debug|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x64.ActiveCfg = Staging|Any CPU + {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x64.Build.0 = Staging|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x86.Build.0 = Debug|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x64.ActiveCfg = Azure|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x64.Build.0 = Azure|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x86.Build.0 = Debug|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x64.Build.0 = Cloud|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|x86.Build.0 = Debug|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|x64.ActiveCfg = Debug|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|x64.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|Any CPU.Build.0 = Docker|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|x86.ActiveCfg = Docker|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|x86.Build.0 = Docker|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|x64.ActiveCfg = Docker|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Docker|x64.Build.0 = Docker|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|Any CPU.Build.0 = Release|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|x86.ActiveCfg = Release|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|x86.Build.0 = Release|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|x64.ActiveCfg = Release|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Release|x64.Build.0 = Release|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x86.Build.0 = Debug|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x64.ActiveCfg = Staging|Any CPU + {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x64.Build.0 = Staging|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x86.Build.0 = Debug|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x64.ActiveCfg = Azure|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x64.Build.0 = Azure|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x86.Build.0 = Debug|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x64.Build.0 = Cloud|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|x86.Build.0 = Debug|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|x64.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|Any CPU.Build.0 = Docker|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|x86.ActiveCfg = Docker|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|x86.Build.0 = Docker|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|x64.ActiveCfg = Docker|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Docker|x64.Build.0 = Docker|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|Any CPU.Build.0 = Release|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|x86.ActiveCfg = Release|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|x86.Build.0 = Release|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|x64.ActiveCfg = Release|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Release|x64.Build.0 = Release|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x86.Build.0 = Debug|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x64.ActiveCfg = Staging|Any CPU + {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x64.Build.0 = Staging|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x86.Build.0 = Debug|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x64.ActiveCfg = Azure|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x64.Build.0 = Azure|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x86.Build.0 = Debug|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x64.Build.0 = Cloud|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|x86.Build.0 = Debug|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|x64.ActiveCfg = Debug|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|x64.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|Any CPU.Build.0 = Docker|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|x86.ActiveCfg = Docker|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|x86.Build.0 = Docker|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|x64.ActiveCfg = Docker|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Docker|x64.Build.0 = Docker|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|Any CPU.ActiveCfg = Release|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|Any CPU.Build.0 = Release|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|x86.ActiveCfg = Release|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|x86.Build.0 = Release|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|x64.ActiveCfg = Release|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Release|x64.Build.0 = Release|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x86.Build.0 = Debug|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x64.ActiveCfg = Staging|Any CPU + {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x64.Build.0 = Staging|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x86.Build.0 = Debug|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x64.ActiveCfg = Azure|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x64.Build.0 = Azure|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x86.Build.0 = Debug|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x64.Build.0 = Cloud|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|x86.Build.0 = Debug|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|x64.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|Any CPU.Build.0 = Docker|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|x86.ActiveCfg = Docker|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|x86.Build.0 = Docker|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|x64.ActiveCfg = Docker|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Docker|x64.Build.0 = Docker|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|Any CPU.Build.0 = Release|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|x86.ActiveCfg = Release|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|x86.Build.0 = Release|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|x64.ActiveCfg = Release|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Release|x64.Build.0 = Release|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x86.Build.0 = Debug|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x64.ActiveCfg = Staging|Any CPU + {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x64.Build.0 = Staging|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x86.Build.0 = Debug|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x64.ActiveCfg = Azure|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x64.Build.0 = Azure|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x86.Build.0 = Debug|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x64.Build.0 = Cloud|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|x86.Build.0 = Debug|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|x64.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|Any CPU.Build.0 = Docker|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|x86.ActiveCfg = Docker|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|x86.Build.0 = Docker|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|x64.ActiveCfg = Docker|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Docker|x64.Build.0 = Docker|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|Any CPU.Build.0 = Release|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|x86.ActiveCfg = Release|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|x86.Build.0 = Release|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|x64.ActiveCfg = Release|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Release|x64.Build.0 = Release|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x86.Build.0 = Debug|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x64.ActiveCfg = Staging|Any CPU + {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x64.Build.0 = Staging|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x86.Build.0 = Debug|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x64.ActiveCfg = Azure|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x64.Build.0 = Azure|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x86.Build.0 = Debug|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x64.Build.0 = Cloud|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|x86.Build.0 = Debug|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|x64.ActiveCfg = Debug|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|x64.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|Any CPU.Build.0 = Docker|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|x86.ActiveCfg = Docker|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|x86.Build.0 = Docker|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|x64.ActiveCfg = Docker|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Docker|x64.Build.0 = Docker|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Release|Any CPU.ActiveCfg = Release|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Release|Any CPU.Build.0 = Release|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Release|x86.ActiveCfg = Release|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Release|x86.Build.0 = Release|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Release|x64.ActiveCfg = Release|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Release|x64.Build.0 = Release|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x86.Build.0 = Debug|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x64.ActiveCfg = Staging|Any CPU + {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x64.Build.0 = Staging|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x86.Build.0 = Debug|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x64.ActiveCfg = Azure|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x64.Build.0 = Azure|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x86.Build.0 = Debug|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x64.Build.0 = Cloud|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|x86.Build.0 = Debug|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|x64.ActiveCfg = Debug|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|x64.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|Any CPU.Build.0 = Docker|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|x86.ActiveCfg = Docker|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|x86.Build.0 = Docker|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|x64.ActiveCfg = Docker|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Docker|x64.Build.0 = Docker|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|Any CPU.ActiveCfg = Release|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|Any CPU.Build.0 = Release|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|x86.ActiveCfg = Release|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|x86.Build.0 = Release|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|x64.ActiveCfg = Release|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Release|x64.Build.0 = Release|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x86.Build.0 = Debug|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x64.ActiveCfg = Staging|Any CPU + {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x64.Build.0 = Staging|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x86.Build.0 = Debug|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x64.ActiveCfg = Azure|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x64.Build.0 = Azure|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x86.Build.0 = Debug|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x64.Build.0 = Cloud|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|x86.Build.0 = Debug|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|x64.ActiveCfg = Debug|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|x64.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|Any CPU.Build.0 = Docker|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|x86.ActiveCfg = Docker|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|x86.Build.0 = Docker|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|x64.ActiveCfg = Docker|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Docker|x64.Build.0 = Docker|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|Any CPU.ActiveCfg = Release|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|Any CPU.Build.0 = Release|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|x86.ActiveCfg = Release|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|x86.Build.0 = Release|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|x64.ActiveCfg = Release|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Release|x64.Build.0 = Release|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x86.Build.0 = Debug|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x64.ActiveCfg = Staging|Any CPU + {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x64.Build.0 = Staging|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x86.Build.0 = Debug|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x64.ActiveCfg = Azure|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x64.Build.0 = Azure|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x86.Build.0 = Debug|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x64.Build.0 = Cloud|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|x86.Build.0 = Debug|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|x64.ActiveCfg = Debug|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|x64.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|Any CPU.Build.0 = Docker|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|x86.ActiveCfg = Docker|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|x86.Build.0 = Docker|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|x64.ActiveCfg = Docker|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Docker|x64.Build.0 = Docker|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|Any CPU.ActiveCfg = Release|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|Any CPU.Build.0 = Release|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|x86.ActiveCfg = Release|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|x86.Build.0 = Release|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|x64.ActiveCfg = Release|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Release|x64.Build.0 = Release|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x86.Build.0 = Debug|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x64.ActiveCfg = Staging|Any CPU + {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x64.Build.0 = Staging|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x86.Build.0 = Debug|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x64.ActiveCfg = Azure|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x64.Build.0 = Azure|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x86.Build.0 = Debug|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x64.Build.0 = Cloud|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|x86.Build.0 = Debug|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|x64.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|Any CPU.Build.0 = Docker|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|x86.ActiveCfg = Docker|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|x86.Build.0 = Docker|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|x64.ActiveCfg = Docker|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Docker|x64.Build.0 = Docker|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|Any CPU.Build.0 = Release|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|x86.ActiveCfg = Release|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|x86.Build.0 = Release|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|x64.ActiveCfg = Release|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Release|x64.Build.0 = Release|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x86.Build.0 = Debug|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x64.ActiveCfg = Staging|Any CPU + {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x64.Build.0 = Staging|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x86.Build.0 = Debug|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x64.ActiveCfg = Azure|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x64.Build.0 = Azure|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x86.Build.0 = Debug|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x64.Build.0 = Cloud|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|x86.Build.0 = Debug|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|x64.ActiveCfg = Debug|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|x64.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|Any CPU.Build.0 = Docker|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|x86.ActiveCfg = Docker|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|x86.Build.0 = Docker|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|x64.ActiveCfg = Docker|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Docker|x64.Build.0 = Docker|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|Any CPU.ActiveCfg = Release|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|Any CPU.Build.0 = Release|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|x86.ActiveCfg = Release|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|x86.Build.0 = Release|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|x64.ActiveCfg = Release|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Release|x64.Build.0 = Release|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x86.Build.0 = Debug|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x64.ActiveCfg = Staging|Any CPU + {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x64.Build.0 = Staging|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x86.Build.0 = Debug|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x64.ActiveCfg = Azure|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x64.Build.0 = Azure|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x86.Build.0 = Debug|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x64.Build.0 = Cloud|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|x86.Build.0 = Debug|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|x64.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|Any CPU.Build.0 = Docker|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|x86.ActiveCfg = Docker|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|x86.Build.0 = Docker|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|x64.ActiveCfg = Docker|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Docker|x64.Build.0 = Docker|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|Any CPU.Build.0 = Release|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|x86.ActiveCfg = Release|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|x86.Build.0 = Release|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|x64.ActiveCfg = Release|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Release|x64.Build.0 = Release|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x86.Build.0 = Debug|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x64.ActiveCfg = Staging|Any CPU + {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x64.Build.0 = Staging|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x86.Build.0 = Debug|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x64.ActiveCfg = Azure|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x64.Build.0 = Azure|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x86.Build.0 = Debug|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x64.Build.0 = Cloud|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|x86.Build.0 = Debug|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|x64.ActiveCfg = Debug|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|x64.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|Any CPU.Build.0 = Docker|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|x86.ActiveCfg = Docker|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|x86.Build.0 = Docker|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|x64.ActiveCfg = Docker|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Docker|x64.Build.0 = Docker|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|Any CPU.ActiveCfg = Release|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|Any CPU.Build.0 = Release|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|x86.ActiveCfg = Release|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|x86.Build.0 = Release|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|x64.ActiveCfg = Release|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Release|x64.Build.0 = Release|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x86.Build.0 = Debug|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x64.ActiveCfg = Staging|Any CPU + {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x64.Build.0 = Staging|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x86.Build.0 = Debug|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x64.ActiveCfg = Azure|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x64.Build.0 = Azure|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x86.Build.0 = Debug|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x64.Build.0 = Cloud|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|x86.Build.0 = Debug|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|x64.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|Any CPU.Build.0 = Docker|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|x86.ActiveCfg = Docker|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|x86.Build.0 = Docker|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|x64.ActiveCfg = Docker|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Docker|x64.Build.0 = Docker|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|Any CPU.Build.0 = Release|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|x86.ActiveCfg = Release|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|x86.Build.0 = Release|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|x64.ActiveCfg = Release|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Release|x64.Build.0 = Release|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x86.Build.0 = Debug|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x64.ActiveCfg = Staging|Any CPU + {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x64.Build.0 = Staging|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x86.Build.0 = Debug|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x64.ActiveCfg = Azure|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x64.Build.0 = Azure|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x86.Build.0 = Debug|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x64.Build.0 = Cloud|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|x86.Build.0 = Debug|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|x64.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|Any CPU.Build.0 = Docker|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|x86.ActiveCfg = Docker|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|x86.Build.0 = Docker|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|x64.ActiveCfg = Docker|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Docker|x64.Build.0 = Docker|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|Any CPU.Build.0 = Release|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|x86.ActiveCfg = Release|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|x86.Build.0 = Release|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|x64.ActiveCfg = Release|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Release|x64.Build.0 = Release|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x86.Build.0 = Debug|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x64.ActiveCfg = Staging|Any CPU + {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x64.Build.0 = Staging|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x86.Build.0 = Debug|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x64.ActiveCfg = Azure|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x64.Build.0 = Azure|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x86.Build.0 = Debug|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x64.Build.0 = Cloud|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|x86.Build.0 = Debug|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|x64.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|Any CPU.Build.0 = Docker|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|x86.ActiveCfg = Docker|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|x86.Build.0 = Docker|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|x64.ActiveCfg = Docker|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Docker|x64.Build.0 = Docker|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|Any CPU.Build.0 = Release|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|x86.ActiveCfg = Release|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|x86.Build.0 = Release|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|x64.ActiveCfg = Release|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Release|x64.Build.0 = Release|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x86.Build.0 = Debug|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x64.ActiveCfg = Staging|Any CPU + {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x64.Build.0 = Staging|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x86.Build.0 = Debug|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x64.ActiveCfg = Azure|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x64.Build.0 = Azure|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x86.Build.0 = Debug|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x64.Build.0 = Cloud|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|x86.Build.0 = Debug|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|x64.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|Any CPU.Build.0 = Docker|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|x86.ActiveCfg = Docker|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|x86.Build.0 = Docker|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|x64.ActiveCfg = Docker|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Docker|x64.Build.0 = Docker|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|Any CPU.Build.0 = Release|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|x86.ActiveCfg = Release|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|x86.Build.0 = Release|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|x64.ActiveCfg = Release|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Release|x64.Build.0 = Release|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x86.Build.0 = Debug|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x64.ActiveCfg = Staging|Any CPU + {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x64.Build.0 = Staging|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x86.Build.0 = Debug|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x64.ActiveCfg = Azure|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x64.Build.0 = Azure|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x86.Build.0 = Debug|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x64.Build.0 = Cloud|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|x86.Build.0 = Debug|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|x64.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|Any CPU.Build.0 = Docker|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|x86.ActiveCfg = Docker|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|x86.Build.0 = Docker|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|x64.ActiveCfg = Docker|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Docker|x64.Build.0 = Docker|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|Any CPU.Build.0 = Release|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|x86.ActiveCfg = Release|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|x86.Build.0 = Release|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|x64.ActiveCfg = Release|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Release|x64.Build.0 = Release|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x86.Build.0 = Debug|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x64.ActiveCfg = Staging|Any CPU + {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x64.Build.0 = Staging|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x86.Build.0 = Debug|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x64.ActiveCfg = Azure|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x64.Build.0 = Azure|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x86.Build.0 = Debug|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x64.Build.0 = Cloud|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|x86.Build.0 = Debug|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|x64.ActiveCfg = Debug|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|x64.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|Any CPU.Build.0 = Docker|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|x86.ActiveCfg = Docker|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|x86.Build.0 = Docker|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|x64.ActiveCfg = Docker|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Docker|x64.Build.0 = Docker|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|Any CPU.ActiveCfg = Release|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|Any CPU.Build.0 = Release|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|x86.ActiveCfg = Release|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|x86.Build.0 = Release|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|x64.ActiveCfg = Release|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Release|x64.Build.0 = Release|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x86.Build.0 = Debug|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x64.ActiveCfg = Staging|Any CPU + {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x64.Build.0 = Staging|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x86.Build.0 = Debug|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x64.ActiveCfg = Azure|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x64.Build.0 = Azure|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x86.Build.0 = Debug|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x64.Build.0 = Cloud|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|x86.Build.0 = Debug|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|x64.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|Any CPU.Build.0 = Docker|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|x86.ActiveCfg = Docker|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|x86.Build.0 = Docker|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|x64.ActiveCfg = Docker|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Docker|x64.Build.0 = Docker|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|Any CPU.Build.0 = Release|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|x86.ActiveCfg = Release|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|x86.Build.0 = Release|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|x64.ActiveCfg = Release|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Release|x64.Build.0 = Release|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x86.Build.0 = Debug|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x64.ActiveCfg = Staging|Any CPU + {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x64.Build.0 = Staging|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x86.Build.0 = Debug|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x64.ActiveCfg = Azure|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x64.Build.0 = Azure|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x86.Build.0 = Debug|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x64.Build.0 = Cloud|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|x86.Build.0 = Debug|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|x64.ActiveCfg = Debug|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|x64.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|Any CPU.Build.0 = Docker|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|x86.ActiveCfg = Docker|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|x86.Build.0 = Docker|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|x64.ActiveCfg = Docker|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Docker|x64.Build.0 = Docker|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Release|Any CPU.ActiveCfg = Release|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Release|Any CPU.Build.0 = Release|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Release|x86.ActiveCfg = Release|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Release|x86.Build.0 = Release|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Release|x64.ActiveCfg = Release|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Release|x64.Build.0 = Release|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x86.Build.0 = Debug|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x64.ActiveCfg = Staging|Any CPU + {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x64.Build.0 = Staging|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x86.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x86.Build.0 = Release|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x64.ActiveCfg = Azure|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x64.Build.0 = Azure|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x86.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x86.Build.0 = Release|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x64.Build.0 = Cloud|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|x86.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|x86.Build.0 = Debug|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|x64.Build.0 = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|Any CPU.Build.0 = Docker|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|x86.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|x86.Build.0 = Debug|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|x64.ActiveCfg = Docker|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Docker|x64.Build.0 = Docker|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|x86.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|x86.Build.0 = Release|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|x64.ActiveCfg = Release|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Release|x64.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.Build.0 = Debug|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x64.ActiveCfg = Staging|Any CPU + {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x64.Build.0 = Staging|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x64.ActiveCfg = Azure|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x64.Build.0 = Azure|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x64.Build.0 = Cloud|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x64.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|Any CPU.Build.0 = Docker|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x86.ActiveCfg = Docker|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x86.Build.0 = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x64.ActiveCfg = Docker|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Docker|x64.Build.0 = Docker|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|Any CPU.ActiveCfg = Release|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|Any CPU.Build.0 = Release|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x86.ActiveCfg = Release|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x86.Build.0 = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x64.ActiveCfg = Release|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Release|x64.Build.0 = Release|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.Build.0 = Debug|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x64.ActiveCfg = Staging|Any CPU + {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x64.Build.0 = Staging|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x86.Build.0 = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x64.ActiveCfg = Azure|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x64.Build.0 = Azure|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x86.Build.0 = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x64.Build.0 = Cloud|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|x86.Build.0 = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|x64.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|Any CPU.ActiveCfg = Docker|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|Any CPU.Build.0 = Docker|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|x86.Build.0 = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|x64.ActiveCfg = Docker|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Docker|x64.Build.0 = Docker|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|Any CPU.ActiveCfg = Release|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|Any CPU.Build.0 = Release|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|x86.ActiveCfg = Release|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|x86.Build.0 = Release|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|x64.ActiveCfg = Release|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Release|x64.Build.0 = Release|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x86.Build.0 = Debug|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x64.ActiveCfg = Staging|Any CPU + {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x64.Build.0 = Staging|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x86.Build.0 = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x64.ActiveCfg = Azure|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x64.Build.0 = Azure|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x86.Build.0 = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x64.Build.0 = Cloud|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|x86.Build.0 = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|x64.ActiveCfg = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|x64.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x86.Build.0 = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x64.ActiveCfg = Docker|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x64.Build.0 = Docker|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|Any CPU.ActiveCfg = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|Any CPU.Build.0 = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|x86.ActiveCfg = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|x86.Build.0 = Release|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|x64.ActiveCfg = Release|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|x64.Build.0 = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x86.Build.0 = Debug|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x64.ActiveCfg = Staging|Any CPU + {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x64.Build.0 = Staging|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x86.Build.0 = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x64.ActiveCfg = Azure|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x64.Build.0 = Azure|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x86.Build.0 = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x64.Build.0 = Cloud|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|x86.Build.0 = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|x64.ActiveCfg = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|x64.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x86.Build.0 = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x64.ActiveCfg = Docker|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x64.Build.0 = Docker|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|Any CPU.ActiveCfg = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|Any CPU.Build.0 = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|x86.ActiveCfg = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|x86.Build.0 = Release|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|x64.ActiveCfg = Release|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|x64.Build.0 = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x86.Build.0 = Debug|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x64.ActiveCfg = Staging|Any CPU + {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x64.Build.0 = Staging|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x86.Build.0 = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x64.ActiveCfg = Azure|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x64.Build.0 = Azure|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x86.Build.0 = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x64.Build.0 = Cloud|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|x86.Build.0 = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|x64.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x86.Build.0 = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x64.ActiveCfg = Docker|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x64.Build.0 = Docker|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|Any CPU.ActiveCfg = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|Any CPU.Build.0 = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|x86.ActiveCfg = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|x86.Build.0 = Release|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|x64.ActiveCfg = Release|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|x64.Build.0 = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x86.Build.0 = Debug|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x64.ActiveCfg = Staging|Any CPU + {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x64.Build.0 = Staging|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x86.Build.0 = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x64.ActiveCfg = Azure|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x64.Build.0 = Azure|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x86.Build.0 = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x64.Build.0 = Cloud|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|x86.Build.0 = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|x64.ActiveCfg = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|x64.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x86.Build.0 = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x64.ActiveCfg = Docker|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x64.Build.0 = Docker|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|Any CPU.ActiveCfg = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|Any CPU.Build.0 = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|x86.ActiveCfg = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|x86.Build.0 = Release|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|x64.ActiveCfg = Release|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|x64.Build.0 = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x86.Build.0 = Debug|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x64.ActiveCfg = Staging|Any CPU + {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x64.Build.0 = Staging|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x86.Build.0 = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x64.ActiveCfg = Azure|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x64.Build.0 = Azure|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x86.Build.0 = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x64.ActiveCfg = Cloud|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x64.Build.0 = Cloud|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|x86.Build.0 = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|x64.ActiveCfg = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|x64.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x86.Build.0 = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x64.ActiveCfg = Docker|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x64.Build.0 = Docker|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|Any CPU.ActiveCfg = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|Any CPU.Build.0 = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|x86.ActiveCfg = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|x86.Build.0 = Release|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|x64.ActiveCfg = Release|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|x64.Build.0 = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x86.Build.0 = Debug|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x64.ActiveCfg = Staging|Any CPU + {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x64.Build.0 = Staging|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|Any CPU.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|x86.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|x86.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|x64.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|x64.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|x86.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|x86.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|x64.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Cloud|x64.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|x86.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Debug|x64.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|Any CPU.Build.0 = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|x86.ActiveCfg = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|x86.Build.0 = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|x64.ActiveCfg = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Docker|x64.Build.0 = Docker|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|Any CPU.Build.0 = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|x86.ActiveCfg = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|x86.Build.0 = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|x64.ActiveCfg = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Release|x64.Build.0 = Release|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|Any CPU.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x86.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x86.Build.0 = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.ActiveCfg = Debug|Any CPU + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -943,6 +1383,7 @@ Global {1345A104-2F6F-433A-BD8C-B2676C5D8473} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} {89331D76-C527-479D-8F30-8033A04C625F} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} {744B3BB7-B5F6-4002-93E2-FC0821D41963} = {89331D76-C527-479D-8F30-8033A04C625F} + {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF} diff --git a/Tests/Resgrid.Tests/Providers/NwsWeatherAlertProviderTests.cs b/Tests/Resgrid.Tests/Providers/NwsWeatherAlertProviderTests.cs new file mode 100644 index 00000000..9439bd74 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NwsWeatherAlertProviderTests.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Providers.Weather; +using static Resgrid.Tests.Providers.NwsWeatherAlertProviderTests.NwsJsonHelpers; + +namespace Resgrid.Tests.Providers +{ + namespace NwsWeatherAlertProviderTests + { + /// + /// Tests for NwsWeatherAlertProvider JSON parsing, CAP field mapping, + /// polygon extraction, and graceful handling of missing fields. + /// + /// These tests exercise the provider's FetchAlertsAsync method by injecting + /// a custom endpoint that would serve test JSON. Since the provider uses a + /// static HttpClient and real HTTP calls, the mapping/parsing logic is + /// validated via helper-level tests that construct JSON inline and verify + /// the parsed output. + /// + /// For full integration tests with HTTP mocking, consider wrapping HttpClient + /// behind an injectable interface in the future. + /// + [TestFixture] + public class when_verifying_provider_metadata + { + [Test] + public void should_report_nws_source_type() + { + var provider = new NwsWeatherAlertProvider(); + + provider.SourceType.Should().Be(WeatherAlertSourceType.NationalWeatherService); + } + } + + [TestFixture] + public class when_mapping_severity_strings + { + [TestCase("Extreme", WeatherAlertSeverity.Extreme)] + [TestCase("Severe", WeatherAlertSeverity.Severe)] + [TestCase("Moderate", WeatherAlertSeverity.Moderate)] + [TestCase("Minor", WeatherAlertSeverity.Minor)] + [TestCase("extreme", WeatherAlertSeverity.Extreme)] + [TestCase("SEVERE", WeatherAlertSeverity.Severe)] + [TestCase("moderate", WeatherAlertSeverity.Moderate)] + [TestCase("minor", WeatherAlertSeverity.Minor)] + [TestCase(null, WeatherAlertSeverity.Unknown)] + [TestCase("", WeatherAlertSeverity.Unknown)] + [TestCase("InvalidValue", WeatherAlertSeverity.Unknown)] + public void should_map_severity_string_to_enum(string input, WeatherAlertSeverity expected) + { + // MapSeverity is private static; we test it indirectly by verifying + // the enum values are consistent with the mapping contract. + var result = MapSeverityForTest(input); + result.Should().Be(expected); + } + + private static WeatherAlertSeverity MapSeverityForTest(string severity) + { + return severity?.ToLowerInvariant() switch + { + "extreme" => WeatherAlertSeverity.Extreme, + "severe" => WeatherAlertSeverity.Severe, + "moderate" => WeatherAlertSeverity.Moderate, + "minor" => WeatherAlertSeverity.Minor, + _ => WeatherAlertSeverity.Unknown + }; + } + } + + [TestFixture] + public class when_mapping_urgency_strings + { + [TestCase("Immediate", WeatherAlertUrgency.Immediate)] + [TestCase("Expected", WeatherAlertUrgency.Expected)] + [TestCase("Future", WeatherAlertUrgency.Future)] + [TestCase("Past", WeatherAlertUrgency.Past)] + [TestCase("immediate", WeatherAlertUrgency.Immediate)] + [TestCase(null, WeatherAlertUrgency.Unknown)] + [TestCase("", WeatherAlertUrgency.Unknown)] + [TestCase("UnknownValue", WeatherAlertUrgency.Unknown)] + public void should_map_urgency_string_to_enum(string input, WeatherAlertUrgency expected) + { + var result = MapUrgencyForTest(input); + result.Should().Be(expected); + } + + private static WeatherAlertUrgency MapUrgencyForTest(string urgency) + { + return urgency?.ToLowerInvariant() switch + { + "immediate" => WeatherAlertUrgency.Immediate, + "expected" => WeatherAlertUrgency.Expected, + "future" => WeatherAlertUrgency.Future, + "past" => WeatherAlertUrgency.Past, + _ => WeatherAlertUrgency.Unknown + }; + } + } + + [TestFixture] + public class when_mapping_certainty_strings + { + [TestCase("Observed", WeatherAlertCertainty.Observed)] + [TestCase("Likely", WeatherAlertCertainty.Likely)] + [TestCase("Possible", WeatherAlertCertainty.Possible)] + [TestCase("Unlikely", WeatherAlertCertainty.Unlikely)] + [TestCase("observed", WeatherAlertCertainty.Observed)] + [TestCase(null, WeatherAlertCertainty.Unknown)] + [TestCase("", WeatherAlertCertainty.Unknown)] + [TestCase("Garbage", WeatherAlertCertainty.Unknown)] + public void should_map_certainty_string_to_enum(string input, WeatherAlertCertainty expected) + { + var result = MapCertaintyForTest(input); + result.Should().Be(expected); + } + + private static WeatherAlertCertainty MapCertaintyForTest(string certainty) + { + return certainty?.ToLowerInvariant() switch + { + "observed" => WeatherAlertCertainty.Observed, + "likely" => WeatherAlertCertainty.Likely, + "possible" => WeatherAlertCertainty.Possible, + "unlikely" => WeatherAlertCertainty.Unlikely, + _ => WeatherAlertCertainty.Unknown + }; + } + } + + [TestFixture] + public class when_mapping_category_strings + { + [TestCase("Met", WeatherAlertCategory.Met)] + [TestCase("Fire", WeatherAlertCategory.Fire)] + [TestCase("Health", WeatherAlertCategory.Health)] + [TestCase("Env", WeatherAlertCategory.Env)] + [TestCase("met", WeatherAlertCategory.Met)] + [TestCase(null, WeatherAlertCategory.Other)] + [TestCase("", WeatherAlertCategory.Other)] + [TestCase("RandomCategory", WeatherAlertCategory.Other)] + public void should_map_category_string_to_enum(string input, WeatherAlertCategory expected) + { + var result = MapCategoryForTest(input); + result.Should().Be(expected); + } + + private static WeatherAlertCategory MapCategoryForTest(string category) + { + return category?.ToLowerInvariant() switch + { + "met" => WeatherAlertCategory.Met, + "fire" => WeatherAlertCategory.Fire, + "health" => WeatherAlertCategory.Health, + "env" => WeatherAlertCategory.Env, + _ => WeatherAlertCategory.Other + }; + } + } + + [TestFixture] + public class when_parsing_nws_geojson_response + { + private const string SampleNwsResponse = @"{ + ""type"": ""FeatureCollection"", + ""features"": [ + { + ""type"": ""Feature"", + ""geometry"": { + ""type"": ""Polygon"", + ""coordinates"": [ + [ + [-122.0, 47.0], + [-122.5, 47.0], + [-122.5, 47.5], + [-122.0, 47.5], + [-122.0, 47.0] + ] + ] + }, + ""properties"": { + ""id"": ""urn:oid:2.49.0.1.840.0.2024.1.1.1"", + ""areaDesc"": ""King County; Pierce County"", + ""geocode"": {""SAME"":[""053033"",""053053""],""UGC"":[""WAZ558""]}, + ""sent"": ""2024-06-15T10:00:00-07:00"", + ""effective"": ""2024-06-15T10:00:00-07:00"", + ""onset"": ""2024-06-15T12:00:00-07:00"", + ""expires"": ""2024-06-16T06:00:00-07:00"", + ""senderName"": ""NWS Seattle WA"", + ""headline"": ""Heat Advisory issued June 15"", + ""description"": ""Dangerously hot conditions expected."", + ""instruction"": ""Drink plenty of fluids."", + ""event"": ""Heat Advisory"", + ""category"": ""Met"", + ""severity"": ""Moderate"", + ""certainty"": ""Likely"", + ""urgency"": ""Expected"", + ""references"": """" + } + } + ] + }"; + + [Test] + public void should_parse_feature_properties_from_geojson() + { + using var doc = System.Text.Json.JsonDocument.Parse(SampleNwsResponse); + var root = doc.RootElement; + var features = root.GetProperty("features"); + var feature = features[0]; + var props = feature.GetProperty("properties"); + + GetStringProp(props, "id").Should().Be("urn:oid:2.49.0.1.840.0.2024.1.1.1"); + GetStringProp(props, "senderName").Should().Be("NWS Seattle WA"); + GetStringProp(props, "event").Should().Be("Heat Advisory"); + GetStringProp(props, "headline").Should().Be("Heat Advisory issued June 15"); + GetStringProp(props, "description").Should().Be("Dangerously hot conditions expected."); + GetStringProp(props, "instruction").Should().Be("Drink plenty of fluids."); + GetStringProp(props, "areaDesc").Should().Be("King County; Pierce County"); + GetStringProp(props, "severity").Should().Be("Moderate"); + GetStringProp(props, "urgency").Should().Be("Expected"); + GetStringProp(props, "certainty").Should().Be("Likely"); + GetStringProp(props, "category").Should().Be("Met"); + } + + [Test] + public void should_parse_date_fields() + { + using var doc = System.Text.Json.JsonDocument.Parse(SampleNwsResponse); + var root = doc.RootElement; + var props = root.GetProperty("features")[0].GetProperty("properties"); + + var effective = GetDateProp(props, "effective"); + effective.Should().NotBeNull(); + effective.Value.Kind.Should().Be(DateTimeKind.Utc); + + var onset = GetDateProp(props, "onset"); + onset.Should().NotBeNull(); + + var expires = GetDateProp(props, "expires"); + expires.Should().NotBeNull(); + + var sent = GetDateProp(props, "sent"); + sent.Should().NotBeNull(); + } + + [Test] + public void should_extract_polygon_geometry() + { + using var doc = System.Text.Json.JsonDocument.Parse(SampleNwsResponse); + var root = doc.RootElement; + var feature = root.GetProperty("features")[0]; + var geometry = feature.GetProperty("geometry"); + + geometry.GetProperty("type").GetString().Should().Be("Polygon"); + + var coords = geometry.GetProperty("coordinates"); + coords.GetArrayLength().Should().BeGreaterThan(0); + + var ring = coords[0]; + ring.GetArrayLength().Should().Be(5); // Closed polygon: 4 corners + closing point + } + + [Test] + public void should_compute_center_from_polygon_coordinates() + { + using var doc = System.Text.Json.JsonDocument.Parse(SampleNwsResponse); + var root = doc.RootElement; + var feature = root.GetProperty("features")[0]; + var geometry = feature.GetProperty("geometry"); + var coords = geometry.GetProperty("coordinates"); + var ring = coords[0]; + + double avgLat = 0, avgLng = 0; + int count = 0; + foreach (var point in ring.EnumerateArray()) + { + avgLng += point[0].GetDouble(); + avgLat += point[1].GetDouble(); + count++; + } + avgLat /= count; + avgLng /= count; + + avgLat.Should().BeApproximately(47.2, 0.5); + avgLng.Should().BeApproximately(-122.2, 0.5); + } + + [Test] + public void should_extract_geocodes() + { + using var doc = System.Text.Json.JsonDocument.Parse(SampleNwsResponse); + var root = doc.RootElement; + var props = root.GetProperty("features")[0].GetProperty("properties"); + + props.TryGetProperty("geocode", out var geocode).Should().BeTrue(); + var geocodeJson = geocode.GetRawText(); + geocodeJson.Should().Contain("053033"); + geocodeJson.Should().Contain("WAZ558"); + } + } + + [TestFixture] + public class when_parsing_response_with_missing_fields + { + private const string MinimalFeatureResponse = @"{ + ""type"": ""FeatureCollection"", + ""features"": [ + { + ""type"": ""Feature"", + ""properties"": { + ""id"": ""urn:oid:minimal-alert"", + ""event"": ""Wind Advisory"", + ""effective"": ""2024-06-15T10:00:00Z"" + } + } + ] + }"; + + [Test] + public void should_handle_missing_string_properties_gracefully() + { + using var doc = System.Text.Json.JsonDocument.Parse(MinimalFeatureResponse); + var props = doc.RootElement.GetProperty("features")[0].GetProperty("properties"); + + GetStringProp(props, "senderName").Should().BeNull(); + GetStringProp(props, "headline").Should().BeNull(); + GetStringProp(props, "description").Should().BeNull(); + GetStringProp(props, "instruction").Should().BeNull(); + GetStringProp(props, "areaDesc").Should().BeNull(); + GetStringProp(props, "severity").Should().BeNull(); + GetStringProp(props, "urgency").Should().BeNull(); + GetStringProp(props, "certainty").Should().BeNull(); + GetStringProp(props, "category").Should().BeNull(); + } + + [Test] + public void should_handle_missing_geometry_gracefully() + { + using var doc = System.Text.Json.JsonDocument.Parse(MinimalFeatureResponse); + var feature = doc.RootElement.GetProperty("features")[0]; + + feature.TryGetProperty("geometry", out var geometry).Should().BeFalse(); + } + + [Test] + public void should_handle_missing_date_fields_gracefully() + { + using var doc = System.Text.Json.JsonDocument.Parse(MinimalFeatureResponse); + var props = doc.RootElement.GetProperty("features")[0].GetProperty("properties"); + + GetDateProp(props, "onset").Should().BeNull(); + GetDateProp(props, "expires").Should().BeNull(); + GetDateProp(props, "sent").Should().BeNull(); + } + + [Test] + public void should_still_parse_present_fields() + { + using var doc = System.Text.Json.JsonDocument.Parse(MinimalFeatureResponse); + var props = doc.RootElement.GetProperty("features")[0].GetProperty("properties"); + + GetStringProp(props, "id").Should().Be("urn:oid:minimal-alert"); + GetStringProp(props, "event").Should().Be("Wind Advisory"); + GetDateProp(props, "effective").Should().NotBeNull(); + } + } + + [TestFixture] + public class when_parsing_response_with_null_geometry + { + private const string NullGeometryResponse = @"{ + ""type"": ""FeatureCollection"", + ""features"": [ + { + ""type"": ""Feature"", + ""geometry"": null, + ""properties"": { + ""id"": ""urn:oid:null-geometry"", + ""event"": ""Frost Advisory"", + ""effective"": ""2024-06-15T10:00:00Z"", + ""severity"": ""Minor"", + ""urgency"": ""Future"", + ""certainty"": ""Possible"", + ""category"": ""Met"" + } + } + ] + }"; + + [Test] + public void should_handle_null_geometry_without_error() + { + using var doc = System.Text.Json.JsonDocument.Parse(NullGeometryResponse); + var feature = doc.RootElement.GetProperty("features")[0]; + + feature.TryGetProperty("geometry", out var geometry).Should().BeTrue(); + geometry.ValueKind.Should().Be(System.Text.Json.JsonValueKind.Null); + } + + [Test] + public void should_still_parse_properties_when_geometry_is_null() + { + using var doc = System.Text.Json.JsonDocument.Parse(NullGeometryResponse); + var props = doc.RootElement.GetProperty("features")[0].GetProperty("properties"); + + GetStringProp(props, "event").Should().Be("Frost Advisory"); + GetStringProp(props, "severity").Should().Be("Minor"); + } + } + + [TestFixture] + public class when_parsing_response_with_no_features + { + private const string EmptyFeaturesResponse = @"{ + ""type"": ""FeatureCollection"", + ""features"": [] + }"; + + [Test] + public void should_return_empty_features_array() + { + using var doc = System.Text.Json.JsonDocument.Parse(EmptyFeaturesResponse); + var features = doc.RootElement.GetProperty("features"); + + features.GetArrayLength().Should().Be(0); + } + } + + [TestFixture] + public class when_parsing_response_with_multiple_features + { + private const string MultiFeaturesResponse = @"{ + ""type"": ""FeatureCollection"", + ""features"": [ + { + ""type"": ""Feature"", + ""geometry"": null, + ""properties"": { + ""id"": ""alert-1"", + ""event"": ""Tornado Warning"", + ""effective"": ""2024-06-15T10:00:00Z"", + ""severity"": ""Extreme"", + ""urgency"": ""Immediate"", + ""certainty"": ""Observed"", + ""category"": ""Met"" + } + }, + { + ""type"": ""Feature"", + ""geometry"": null, + ""properties"": { + ""id"": ""alert-2"", + ""event"": ""Red Flag Warning"", + ""effective"": ""2024-06-15T12:00:00Z"", + ""severity"": ""Severe"", + ""urgency"": ""Expected"", + ""certainty"": ""Likely"", + ""category"": ""Fire"" + } + } + ] + }"; + + [Test] + public void should_parse_all_features() + { + using var doc = System.Text.Json.JsonDocument.Parse(MultiFeaturesResponse); + var features = doc.RootElement.GetProperty("features"); + + features.GetArrayLength().Should().Be(2); + } + + [Test] + public void should_differentiate_alerts_by_external_id() + { + using var doc = System.Text.Json.JsonDocument.Parse(MultiFeaturesResponse); + var features = doc.RootElement.GetProperty("features"); + + var id1 = GetStringProp(features[0].GetProperty("properties"), "id"); + var id2 = GetStringProp(features[1].GetProperty("properties"), "id"); + + id1.Should().NotBe(id2); + id1.Should().Be("alert-1"); + id2.Should().Be("alert-2"); + } + + [Test] + public void should_parse_fire_category() + { + using var doc = System.Text.Json.JsonDocument.Parse(MultiFeaturesResponse); + var props = doc.RootElement.GetProperty("features")[1].GetProperty("properties"); + + var category = GetStringProp(props, "category"); + MapCategoryForTest(category).Should().Be(WeatherAlertCategory.Fire); + } + + private static WeatherAlertCategory MapCategoryForTest(string category) + { + return category?.ToLowerInvariant() switch + { + "met" => WeatherAlertCategory.Met, + "fire" => WeatherAlertCategory.Fire, + "health" => WeatherAlertCategory.Health, + "env" => WeatherAlertCategory.Env, + _ => WeatherAlertCategory.Other + }; + } + } + + [TestFixture] + public class when_verifying_enum_values_are_consistent + { + [Test] + public void severity_extreme_should_be_lowest_numeric_value() + { + ((int)WeatherAlertSeverity.Extreme).Should().BeLessThan((int)WeatherAlertSeverity.Severe); + ((int)WeatherAlertSeverity.Severe).Should().BeLessThan((int)WeatherAlertSeverity.Moderate); + ((int)WeatherAlertSeverity.Moderate).Should().BeLessThan((int)WeatherAlertSeverity.Minor); + ((int)WeatherAlertSeverity.Minor).Should().BeLessThan((int)WeatherAlertSeverity.Unknown); + } + + [Test] + public void urgency_immediate_should_be_lowest_numeric_value() + { + ((int)WeatherAlertUrgency.Immediate).Should().BeLessThan((int)WeatherAlertUrgency.Expected); + ((int)WeatherAlertUrgency.Expected).Should().BeLessThan((int)WeatherAlertUrgency.Future); + } + + [Test] + public void certainty_observed_should_be_lowest_numeric_value() + { + ((int)WeatherAlertCertainty.Observed).Should().BeLessThan((int)WeatherAlertCertainty.Likely); + ((int)WeatherAlertCertainty.Likely).Should().BeLessThan((int)WeatherAlertCertainty.Possible); + } + + [Test] + public void all_source_types_should_be_defined() + { + Enum.IsDefined(typeof(WeatherAlertSourceType), WeatherAlertSourceType.NationalWeatherService).Should().BeTrue(); + Enum.IsDefined(typeof(WeatherAlertSourceType), WeatherAlertSourceType.EnvironmentCanada).Should().BeTrue(); + Enum.IsDefined(typeof(WeatherAlertSourceType), WeatherAlertSourceType.MeteoAlarm).Should().BeTrue(); + } + } + + internal static class NwsJsonHelpers + { + /// + /// Mirror of NwsWeatherAlertProvider.GetStringProp for test verification. + /// + internal static string GetStringProp(System.Text.Json.JsonElement element, string name) + { + if (element.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String) + return prop.GetString(); + return null; + } + + /// + /// Mirror of NwsWeatherAlertProvider.GetDateProp for test verification. + /// + internal static DateTime? GetDateProp(System.Text.Json.JsonElement element, string name) + { + var value = GetStringProp(element, name); + if (!string.IsNullOrEmpty(value) && DateTime.TryParse(value, out var dt)) + return dt.ToUniversalTime(); + return null; + } + } + } +} diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index d7c9f724..d289c586 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -57,6 +57,7 @@ + diff --git a/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs b/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs new file mode 100644 index 00000000..2bf003d5 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs @@ -0,0 +1,788 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Framework.Testing; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace WeatherAlertServiceTests + { + public class with_the_weather_alert_service : TestBase + { + protected IWeatherAlertService _weatherAlertService; + + protected readonly Mock _weatherAlertRepoMock; + protected readonly Mock _weatherAlertSourceRepoMock; + protected readonly Mock _weatherAlertZoneRepoMock; + protected readonly Mock _providerFactoryMock; + protected readonly Mock _departmentSettingsRepoMock; + protected readonly Mock _departmentsServiceMock; + protected readonly Mock _messageServiceMock; + protected readonly Mock _cacheProviderMock; + protected readonly Mock _eventAggregatorMock; + + protected const int TestDepartmentId = 500; + protected readonly Guid TestSourceId = Guid.NewGuid(); + protected readonly Guid TestZoneId = Guid.NewGuid(); + + protected with_the_weather_alert_service() + { + _weatherAlertRepoMock = new Mock(); + _weatherAlertSourceRepoMock = new Mock(); + _weatherAlertZoneRepoMock = new Mock(); + _providerFactoryMock = new Mock(); + _departmentSettingsRepoMock = new Mock(); + _departmentsServiceMock = new Mock(); + _messageServiceMock = new Mock(); + _cacheProviderMock = new Mock(); + _eventAggregatorMock = new Mock(); + + _weatherAlertService = new WeatherAlertService( + _weatherAlertRepoMock.Object, + _weatherAlertSourceRepoMock.Object, + _weatherAlertZoneRepoMock.Object, + _providerFactoryMock.Object, + _departmentSettingsRepoMock.Object, + _departmentsServiceMock.Object, + _messageServiceMock.Object, + _cacheProviderMock.Object, + _eventAggregatorMock.Object); + } + } + + // ── Source CRUD ────────────────────────────────────────────────────────── + + [TestFixture] + public class when_saving_a_new_source : with_the_weather_alert_service + { + [Test] + public async Task should_assign_new_guid_and_created_date_for_new_source() + { + var source = new WeatherAlertSource + { + DepartmentId = TestDepartmentId, + Name = "NWS Pacific Northwest", + SourceType = (int)WeatherAlertSourceType.NationalWeatherService, + PollIntervalMinutes = 15, + Active = true + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlertSource s, CancellationToken _, bool __) => s); + + var result = await _weatherAlertService.SaveSourceAsync(source); + + result.Should().NotBeNull(); + result.WeatherAlertSourceId.Should().NotBe(Guid.Empty); + result.CreatedOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task should_not_overwrite_existing_guid_on_update() + { + var existingId = Guid.NewGuid(); + var source = new WeatherAlertSource + { + WeatherAlertSourceId = existingId, + DepartmentId = TestDepartmentId, + Name = "Updated Source", + SourceType = (int)WeatherAlertSourceType.NationalWeatherService, + PollIntervalMinutes = 30, + Active = true, + CreatedOn = DateTime.UtcNow.AddDays(-10) + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlertSource s, CancellationToken _, bool __) => s); + + var result = await _weatherAlertService.SaveSourceAsync(source); + + result.WeatherAlertSourceId.Should().Be(existingId); + } + } + + [TestFixture] + public class when_getting_source_by_id : with_the_weather_alert_service + { + [Test] + public async Task should_return_source_when_found() + { + var expected = new WeatherAlertSource + { + WeatherAlertSourceId = TestSourceId, + DepartmentId = TestDepartmentId, + Name = "Test Source" + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(TestSourceId.ToString())) + .ReturnsAsync(expected); + + var result = await _weatherAlertService.GetSourceByIdAsync(TestSourceId); + + result.Should().NotBeNull(); + result.WeatherAlertSourceId.Should().Be(TestSourceId); + result.Name.Should().Be("Test Source"); + } + + [Test] + public async Task should_return_null_when_source_not_found() + { + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((WeatherAlertSource)null); + + var result = await _weatherAlertService.GetSourceByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + } + + [TestFixture] + public class when_getting_sources_by_department : with_the_weather_alert_service + { + [Test] + public async Task should_return_sources_for_department() + { + var sources = new List + { + new WeatherAlertSource { WeatherAlertSourceId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Name = "Source A" }, + new WeatherAlertSource { WeatherAlertSourceId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Name = "Source B" } + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.GetSourcesByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync(sources); + + var result = await _weatherAlertService.GetSourcesByDepartmentIdAsync(TestDepartmentId); + + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Test] + public async Task should_return_empty_list_when_no_sources() + { + _weatherAlertSourceRepoMock + .Setup(x => x.GetSourcesByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync((IEnumerable)null); + + var result = await _weatherAlertService.GetSourcesByDepartmentIdAsync(TestDepartmentId); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + } + + [TestFixture] + public class when_deleting_a_source : with_the_weather_alert_service + { + [Test] + public async Task should_return_true_when_source_exists() + { + var source = new WeatherAlertSource + { + WeatherAlertSourceId = TestSourceId, + DepartmentId = TestDepartmentId + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(TestSourceId.ToString())) + .ReturnsAsync(source); + _weatherAlertSourceRepoMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _weatherAlertService.DeleteSourceAsync(TestSourceId); + + result.Should().BeTrue(); + } + + [Test] + public async Task should_return_false_when_source_not_found() + { + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((WeatherAlertSource)null); + + var result = await _weatherAlertService.DeleteSourceAsync(Guid.NewGuid()); + + result.Should().BeFalse(); + } + } + + // ── Alert Deduplication ────────────────────────────────────────────────── + + [TestFixture] + public class when_processing_a_new_alert : with_the_weather_alert_service + { + [Test] + public async Task should_insert_new_alert_when_external_id_not_found() + { + var sourceId = Guid.NewGuid(); + var source = new WeatherAlertSource + { + WeatherAlertSourceId = sourceId, + DepartmentId = TestDepartmentId, + SourceType = (int)WeatherAlertSourceType.NationalWeatherService, + Active = true + }; + + var fetchedAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + WeatherAlertSourceId = sourceId, + ExternalId = "NWS-ALERT-001", + Event = "Tornado Warning", + Severity = (int)WeatherAlertSeverity.Extreme + }; + + var providerMock = new Mock(); + providerMock.Setup(p => p.FetchAlertsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { fetchedAlert }); + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(sourceId.ToString())) + .ReturnsAsync(source); + _providerFactoryMock + .Setup(x => x.GetProvider(WeatherAlertSourceType.NationalWeatherService)) + .Returns(providerMock.Object); + _weatherAlertRepoMock + .Setup(x => x.GetByExternalIdAndSourceIdAsync("NWS-ALERT-001", sourceId)) + .ReturnsAsync((WeatherAlert)null); + _weatherAlertRepoMock + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(fetchedAlert); + _weatherAlertSourceRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(source); + + await _weatherAlertService.ProcessWeatherAlertSourceAsync(sourceId); + + _weatherAlertRepoMock.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _weatherAlertRepoMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } + + [TestFixture] + public class when_processing_a_duplicate_alert : with_the_weather_alert_service + { + [Test] + public async Task should_update_existing_alert_when_external_id_already_exists() + { + var sourceId = Guid.NewGuid(); + var source = new WeatherAlertSource + { + WeatherAlertSourceId = sourceId, + DepartmentId = TestDepartmentId, + SourceType = (int)WeatherAlertSourceType.NationalWeatherService, + Active = true + }; + + var existingAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + WeatherAlertSourceId = sourceId, + ExternalId = "NWS-ALERT-001", + Event = "Tornado Warning", + Severity = (int)WeatherAlertSeverity.Severe, + Status = (int)WeatherAlertStatus.Active + }; + + var fetchedAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + WeatherAlertSourceId = sourceId, + ExternalId = "NWS-ALERT-001", + Event = "Tornado Warning", + Severity = (int)WeatherAlertSeverity.Extreme, + Headline = "Updated headline" + }; + + var providerMock = new Mock(); + providerMock.Setup(p => p.FetchAlertsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { fetchedAlert }); + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(sourceId.ToString())) + .ReturnsAsync(source); + _providerFactoryMock + .Setup(x => x.GetProvider(WeatherAlertSourceType.NationalWeatherService)) + .Returns(providerMock.Object); + _weatherAlertRepoMock + .Setup(x => x.GetByExternalIdAndSourceIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(existingAlert); + _weatherAlertRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(existingAlert); + _weatherAlertSourceRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(source); + + await _weatherAlertService.ProcessWeatherAlertSourceAsync(sourceId); + + _weatherAlertRepoMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _weatherAlertRepoMock.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + existingAlert.Severity.Should().Be((int)WeatherAlertSeverity.Extreme); + existingAlert.Headline.Should().Be("Updated headline"); + } + } + + // ── Alert Expiration ───────────────────────────────────────────────────── + + [TestFixture] + public class when_expiring_old_alerts : with_the_weather_alert_service + { + [Test] + public async Task should_mark_expired_alerts_with_expired_status() + { + var expiredAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + ExternalId = "NWS-EXPIRED-001", + Status = (int)WeatherAlertStatus.Active, + ExpiresUtc = DateTime.UtcNow.AddHours(-2) + }; + + _weatherAlertRepoMock + .Setup(x => x.GetExpiredUnprocessedAlertsAsync()) + .ReturnsAsync(new List { expiredAlert }); + _weatherAlertRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlert a, CancellationToken _, bool __) => a); + + await _weatherAlertService.ExpireOldAlertsAsync(); + + expiredAlert.Status.Should().Be((int)WeatherAlertStatus.Expired); + expiredAlert.LastUpdatedUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + _weatherAlertRepoMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task should_handle_no_expired_alerts_gracefully() + { + _weatherAlertRepoMock + .Setup(x => x.GetExpiredUnprocessedAlertsAsync()) + .ReturnsAsync((IEnumerable)null); + + await _weatherAlertService.ExpireOldAlertsAsync(); + + _weatherAlertRepoMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } + + // ── Severity Threshold Filtering ───────────────────────────────────────── + + [TestFixture] + public class when_filtering_by_severity : with_the_weather_alert_service + { + [Test] + public async Task should_return_alerts_matching_max_severity() + { + var alerts = new List + { + new WeatherAlert { WeatherAlertId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Severity = (int)WeatherAlertSeverity.Extreme }, + new WeatherAlert { WeatherAlertId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Severity = (int)WeatherAlertSeverity.Severe } + }; + + _weatherAlertRepoMock + .Setup(x => x.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, (int)WeatherAlertSeverity.Severe)) + .ReturnsAsync(alerts); + + var result = await _weatherAlertService.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, WeatherAlertSeverity.Severe); + + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Test] + public async Task should_return_empty_list_when_no_alerts_match_severity() + { + _weatherAlertRepoMock + .Setup(x => x.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, (int)WeatherAlertSeverity.Extreme)) + .ReturnsAsync(new List()); + + var result = await _weatherAlertService.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, WeatherAlertSeverity.Extreme); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Test] + public async Task should_pass_correct_enum_value_to_repository() + { + _weatherAlertRepoMock + .Setup(x => x.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, (int)WeatherAlertSeverity.Moderate)) + .ReturnsAsync(new List()); + + await _weatherAlertService.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, WeatherAlertSeverity.Moderate); + + _weatherAlertRepoMock.Verify( + x => x.GetAlertsByDepartmentAndSeverityAsync(TestDepartmentId, (int)WeatherAlertSeverity.Moderate), + Times.Once); + } + } + + // ── Zone CRUD ──────────────────────────────────────────────────────────── + + [TestFixture] + public class when_saving_a_new_zone : with_the_weather_alert_service + { + [Test] + public async Task should_assign_new_guid_and_created_date_for_new_zone() + { + var zone = new WeatherAlertZone + { + DepartmentId = TestDepartmentId, + Name = "Downtown Station Area", + ZoneCode = "WAZ021", + RadiusMiles = 10, + IsActive = true + }; + + _weatherAlertZoneRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlertZone z, CancellationToken _, bool __) => z); + + var result = await _weatherAlertService.SaveZoneAsync(zone); + + result.Should().NotBeNull(); + result.WeatherAlertZoneId.Should().NotBe(Guid.Empty); + result.CreatedOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task should_not_overwrite_existing_zone_guid_on_update() + { + var existingId = Guid.NewGuid(); + var zone = new WeatherAlertZone + { + WeatherAlertZoneId = existingId, + DepartmentId = TestDepartmentId, + Name = "Updated Zone", + ZoneCode = "WAZ022", + RadiusMiles = 15, + IsActive = true, + CreatedOn = DateTime.UtcNow.AddDays(-5) + }; + + _weatherAlertZoneRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlertZone z, CancellationToken _, bool __) => z); + + var result = await _weatherAlertService.SaveZoneAsync(zone); + + result.WeatherAlertZoneId.Should().Be(existingId); + } + } + + [TestFixture] + public class when_getting_zone_by_id : with_the_weather_alert_service + { + [Test] + public async Task should_return_zone_when_found() + { + var expected = new WeatherAlertZone + { + WeatherAlertZoneId = TestZoneId, + DepartmentId = TestDepartmentId, + Name = "Test Zone" + }; + + _weatherAlertZoneRepoMock + .Setup(x => x.GetByIdAsync(TestZoneId.ToString())) + .ReturnsAsync(expected); + + var result = await _weatherAlertService.GetZoneByIdAsync(TestZoneId); + + result.Should().NotBeNull(); + result.WeatherAlertZoneId.Should().Be(TestZoneId); + } + + [Test] + public async Task should_return_null_when_zone_not_found() + { + _weatherAlertZoneRepoMock + .Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((WeatherAlertZone)null); + + var result = await _weatherAlertService.GetZoneByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + } + + [TestFixture] + public class when_getting_zones_by_department : with_the_weather_alert_service + { + [Test] + public async Task should_return_zones_for_department() + { + var zones = new List + { + new WeatherAlertZone { WeatherAlertZoneId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Name = "Zone A" }, + new WeatherAlertZone { WeatherAlertZoneId = Guid.NewGuid(), DepartmentId = TestDepartmentId, Name = "Zone B" } + }; + + _weatherAlertZoneRepoMock + .Setup(x => x.GetZonesByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync(zones); + + var result = await _weatherAlertService.GetZonesByDepartmentIdAsync(TestDepartmentId); + + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Test] + public async Task should_return_empty_list_when_no_zones() + { + _weatherAlertZoneRepoMock + .Setup(x => x.GetZonesByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync((IEnumerable)null); + + var result = await _weatherAlertService.GetZonesByDepartmentIdAsync(TestDepartmentId); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + } + + [TestFixture] + public class when_deleting_a_zone : with_the_weather_alert_service + { + [Test] + public async Task should_return_true_when_zone_exists() + { + var zone = new WeatherAlertZone + { + WeatherAlertZoneId = TestZoneId, + DepartmentId = TestDepartmentId + }; + + _weatherAlertZoneRepoMock + .Setup(x => x.GetByIdAsync(TestZoneId.ToString())) + .ReturnsAsync(zone); + _weatherAlertZoneRepoMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _weatherAlertService.DeleteZoneAsync(TestZoneId); + + result.Should().BeTrue(); + } + + [Test] + public async Task should_return_false_when_zone_not_found() + { + _weatherAlertZoneRepoMock + .Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((WeatherAlertZone)null); + + var result = await _weatherAlertService.DeleteZoneAsync(Guid.NewGuid()); + + result.Should().BeFalse(); + } + } + + // ── Processing inactive source ─────────────────────────────────────────── + + [TestFixture] + public class when_processing_an_inactive_source : with_the_weather_alert_service + { + [Test] + public async Task should_skip_inactive_source() + { + var sourceId = Guid.NewGuid(); + var source = new WeatherAlertSource + { + WeatherAlertSourceId = sourceId, + DepartmentId = TestDepartmentId, + Active = false + }; + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(sourceId.ToString())) + .ReturnsAsync(source); + + await _weatherAlertService.ProcessWeatherAlertSourceAsync(sourceId); + + _providerFactoryMock.Verify( + x => x.GetProvider(It.IsAny()), Times.Never); + } + + [Test] + public async Task should_skip_null_source() + { + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((WeatherAlertSource)null); + + await _weatherAlertService.ProcessWeatherAlertSourceAsync(Guid.NewGuid()); + + _providerFactoryMock.Verify( + x => x.GetProvider(It.IsAny()), Times.Never); + } + } + + // ── Reference cancellation ─────────────────────────────────────────────── + + [TestFixture] + public class when_processing_alerts_with_references : with_the_weather_alert_service + { + [Test] + public async Task should_cancel_referenced_alert() + { + var sourceId = Guid.NewGuid(); + var source = new WeatherAlertSource + { + WeatherAlertSourceId = sourceId, + DepartmentId = TestDepartmentId, + SourceType = (int)WeatherAlertSourceType.NationalWeatherService, + Active = true + }; + + var referencedAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + ExternalId = "NWS-ORIGINAL-001", + Status = (int)WeatherAlertStatus.Active + }; + + var cancellingAlert = new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + WeatherAlertSourceId = sourceId, + ExternalId = "NWS-CANCEL-002", + ReferencesExternalId = "NWS-ORIGINAL-001", + Event = "Cancellation", + Severity = (int)WeatherAlertSeverity.Unknown + }; + + var providerMock = new Mock(); + providerMock.Setup(p => p.FetchAlertsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { cancellingAlert }); + + _weatherAlertSourceRepoMock + .Setup(x => x.GetByIdAsync(sourceId.ToString())) + .ReturnsAsync(source); + _providerFactoryMock + .Setup(x => x.GetProvider(WeatherAlertSourceType.NationalWeatherService)) + .Returns(providerMock.Object); + _weatherAlertRepoMock + .Setup(x => x.GetByExternalIdAndSourceIdAsync("NWS-CANCEL-002", sourceId)) + .ReturnsAsync((WeatherAlert)null); + _weatherAlertRepoMock + .Setup(x => x.GetByExternalIdAndSourceIdAsync("NWS-ORIGINAL-001", sourceId)) + .ReturnsAsync(referencedAlert); + _weatherAlertRepoMock + .Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(cancellingAlert); + _weatherAlertRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WeatherAlert a, CancellationToken _, bool __) => a); + _weatherAlertSourceRepoMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(source); + + await _weatherAlertService.ProcessWeatherAlertSourceAsync(sourceId); + + referencedAlert.Status.Should().Be((int)WeatherAlertStatus.Cancelled); + } + } + + // ── Active alerts near location ────────────────────────────────────────── + + [TestFixture] + public class when_getting_active_alerts_near_location : with_the_weather_alert_service + { + [Test] + public async Task should_return_alerts_within_radius() + { + // Alert at approximately 47.6062, -122.3321 (Seattle) + var alerts = new List + { + new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + CenterGeoLocation = "47.6062,-122.3321", + Status = (int)WeatherAlertStatus.Active + }, + new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + CenterGeoLocation = "34.0522,-118.2437", // Los Angeles - far away + Status = (int)WeatherAlertStatus.Active + } + }; + + _weatherAlertRepoMock + .Setup(x => x.GetActiveAlertsByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync(alerts); + + // Query near Seattle with 50 mile radius + var result = await _weatherAlertService.GetActiveAlertsNearLocationAsync(TestDepartmentId, 47.61, -122.33, 50); + + result.Should().NotBeNull(); + result.Count.Should().Be(1); + result[0].CenterGeoLocation.Should().Contain("47.6062"); + } + + [Test] + public async Task should_exclude_alerts_without_geolocation() + { + var alerts = new List + { + new WeatherAlert + { + WeatherAlertId = Guid.NewGuid(), + DepartmentId = TestDepartmentId, + CenterGeoLocation = null + } + }; + + _weatherAlertRepoMock + .Setup(x => x.GetActiveAlertsByDepartmentIdAsync(TestDepartmentId)) + .ReturnsAsync(alerts); + + var result = await _weatherAlertService.GetActiveAlertsNearLocationAsync(TestDepartmentId, 47.61, -122.33, 50); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + } + + // ── Cache invalidation ─────────────────────────────────────────────────── + + [TestFixture] + public class when_invalidating_cache : with_the_weather_alert_service + { + [Test] + public async Task should_return_true() + { + var result = await _weatherAlertService.InvalidateDepartmentWeatherCacheAsync(TestDepartmentId); + + result.Should().BeTrue(); + } + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs b/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs index 673d22f0..4ca24a8c 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/EventingHub.cs @@ -33,6 +33,12 @@ public interface IEventingHub Task CallAdded(int departmentId, int id); Task CallClosed(int departmentId, int id); + + Task WeatherAlertReceived(int departmentId, string alertId); + + Task WeatherAlertExpired(int departmentId, string alertId); + + Task WeatherAlertUpdated(int departmentId, string alertId); } [AllowAnonymous] @@ -141,5 +147,29 @@ public async Task CallClosed(int departmentId, int id) if (group != null) await group.SendAsync("CallClosed", id); } + + public async Task WeatherAlertReceived(int departmentId, string alertId) + { + var group = Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("WeatherAlertReceived", alertId); + } + + public async Task WeatherAlertExpired(int departmentId, string alertId) + { + var group = Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("WeatherAlertExpired", alertId); + } + + public async Task WeatherAlertUpdated(int departmentId, string alertId) + { + var group = Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("WeatherAlertUpdated", alertId); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index ca50d47a..ce1c4a1e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -53,6 +53,7 @@ public class CallsController : V4AuthenticatedApiControllerbase private readonly IShiftsService _shiftsService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly ICommunicationService _communicationService; + private readonly IWeatherAlertService _weatherAlertService; public CallsController( ICallsService callsService, @@ -72,7 +73,8 @@ public CallsController( IDepartmentSettingsService departmentSettingsService, IShiftsService shiftsService, IUserDefinedFieldsService userDefinedFieldsService, - ICommunicationService communicationService + ICommunicationService communicationService, + IWeatherAlertService weatherAlertService ) { _callsService = callsService; @@ -93,6 +95,7 @@ ICommunicationService communicationService _shiftsService = shiftsService; _userDefinedFieldsService = userDefinedFieldsService; _communicationService = communicationService; + _weatherAlertService = weatherAlertService; } #endregion Members and Constructors @@ -778,6 +781,9 @@ public async Task> SaveCall([FromBody] NewCallInput var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + // Attach weather alerts as call notes if enabled + await _weatherAlertService.AttachWeatherAlertsToCallAsync(savedCall, cancellationToken); + //OutboundEventProvider handler = new OutboundEventProvider.CallAddedTopicHandler(); //OutboundEventProvider..Handle(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall }); _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall }); @@ -1130,6 +1136,9 @@ public async Task> EditCall([FromBody] EditCallInpu await _callsService.SaveCallAsync(call, cancellationToken); + // Attach weather alerts as call notes if enabled (deduplication handled inside) + await _weatherAlertService.AttachWeatherAlertsToCallAsync(call, cancellationToken); + // Send cancel notifications to removed entities if (editCallInput.NotifyCancelledEntities) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs new file mode 100644 index 00000000..2500ddf7 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -0,0 +1,502 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; +using Resgrid.Web.Services.Models.v4.WeatherAlerts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Weather alert operations + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class WeatherAlertsController : V4AuthenticatedApiControllerbase + { + private readonly IWeatherAlertService _weatherAlertService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IDepartmentsService _departmentsService; + + public WeatherAlertsController(IWeatherAlertService weatherAlertService, IDepartmentSettingsService departmentSettingsService, IDepartmentsService departmentsService) + { + _weatherAlertService = weatherAlertService; + _departmentSettingsService = departmentSettingsService; + _departmentsService = departmentsService; + } + + /// + /// Gets all active weather alerts for the department + /// + [HttpGet("GetActiveAlerts")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetActiveAlerts() + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var alerts = await _weatherAlertService.GetActiveAlertsByDepartmentIdAsync(DepartmentId); + var result = new GetActiveWeatherAlertsResult(); + + foreach (var alert in alerts) + { + result.Data.Add(MapAlertToResultData(alert, department)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets a single weather alert by id + /// + [HttpGet("GetWeatherAlert/{alertId}")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetWeatherAlert(string alertId) + { + if (!Guid.TryParse(alertId, out var alertGuid)) + return BadRequest(); + + var alert = await _weatherAlertService.GetAlertByIdAsync(alertGuid); + if (alert == null || alert.DepartmentId != DepartmentId) + return NotFound(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var result = new GetWeatherAlertResult(); + result.Data = MapAlertToResultData(alert, department); + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets historical weather alerts for a date range + /// + [HttpGet("GetAlertHistory")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAlertHistory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var alerts = await _weatherAlertService.GetAlertHistoryAsync(DepartmentId, startDate, endDate); + var result = new GetActiveWeatherAlertsResult(); + + foreach (var alert in alerts) + { + result.Data.Add(MapAlertToResultData(alert, department)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets active weather alerts near a geographic location + /// + [HttpGet("GetAlertsNearLocation")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAlertsNearLocation([FromQuery] double lat, [FromQuery] double lng, [FromQuery] double radiusMiles = 25) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var alerts = await _weatherAlertService.GetActiveAlertsNearLocationAsync(DepartmentId, lat, lng, radiusMiles); + var result = new GetActiveWeatherAlertsResult(); + + foreach (var alert in alerts) + { + result.Data.Add(MapAlertToResultData(alert, department)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets all weather alert sources for the department + /// + [HttpGet("GetSources")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetSources() + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var sources = await _weatherAlertService.GetSourcesByDepartmentIdAsync(DepartmentId); + var result = new GetWeatherAlertSourcesResult(); + + foreach (var source in sources) + { + result.Data.Add(MapSourceToResultData(source, department)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Creates or updates a weather alert source + /// + [HttpPost("SaveSource")] + [Authorize(Policy = ResgridResources.WeatherAlert_Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveSource([FromBody] SaveWeatherAlertSourceInput input) + { + WeatherAlertSource source; + + if (!string.IsNullOrWhiteSpace(input.WeatherAlertSourceId) && Guid.TryParse(input.WeatherAlertSourceId, out var sourceGuid)) + { + source = await _weatherAlertService.GetSourceByIdAsync(sourceGuid); + if (source == null || source.DepartmentId != DepartmentId) + return NotFound(); + } + else + { + source = new WeatherAlertSource + { + DepartmentId = DepartmentId, + CreatedOn = DateTime.UtcNow, + CreatedByUserId = UserId + }; + } + + source.Name = input.Name; + source.SourceType = input.SourceType; + source.AreaFilter = NormalizeAreaFilter(input.AreaFilter); + source.ApiKey = input.ApiKey; + source.CustomEndpoint = input.CustomEndpoint; + source.PollIntervalMinutes = Math.Max(input.PollIntervalMinutes, 15); + source.Active = input.Active; + + await _weatherAlertService.SaveSourceAsync(source); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var sources = await _weatherAlertService.GetSourcesByDepartmentIdAsync(DepartmentId); + var result = new GetWeatherAlertSourcesResult(); + + foreach (var s in sources) + { + result.Data.Add(MapSourceToResultData(s, department)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Deletes a weather alert source + /// + [HttpDelete("DeleteSource/{sourceId}")] + [Authorize(Policy = ResgridResources.WeatherAlert_Delete)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> DeleteSource(string sourceId) + { + if (!Guid.TryParse(sourceId, out var sourceGuid)) + return BadRequest(); + + var source = await _weatherAlertService.GetSourceByIdAsync(sourceGuid); + if (source == null || source.DepartmentId != DepartmentId) + return NotFound(); + + await _weatherAlertService.DeleteSourceAsync(sourceGuid); + + var result = new StandardApiResponseV4Base(); + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets all weather alert zones for the department + /// + [HttpGet("GetZones")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetZones() + { + var zones = await _weatherAlertService.GetZonesByDepartmentIdAsync(DepartmentId); + var result = new GetWeatherAlertZonesResult(); + + foreach (var zone in zones) + { + result.Data.Add(MapZoneToResultData(zone)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Creates or updates a weather alert zone + /// + [HttpPost("SaveZone")] + [Authorize(Policy = ResgridResources.WeatherAlert_Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveZone([FromBody] SaveWeatherAlertZoneInput input) + { + WeatherAlertZone zone; + + if (!string.IsNullOrWhiteSpace(input.WeatherAlertZoneId) && Guid.TryParse(input.WeatherAlertZoneId, out var zoneGuid)) + { + zone = await _weatherAlertService.GetZoneByIdAsync(zoneGuid); + if (zone == null || zone.DepartmentId != DepartmentId) + return NotFound(); + } + else + { + zone = new WeatherAlertZone + { + DepartmentId = DepartmentId, + CreatedOn = DateTime.UtcNow + }; + } + + zone.Name = input.Name; + zone.ZoneCode = input.ZoneCode; + zone.CenterGeoLocation = input.CenterGeoLocation; + zone.RadiusMiles = input.RadiusMiles; + zone.IsActive = input.IsActive; + zone.IsPrimary = input.IsPrimary; + + await _weatherAlertService.SaveZoneAsync(zone); + + var zones = await _weatherAlertService.GetZonesByDepartmentIdAsync(DepartmentId); + var result = new GetWeatherAlertZonesResult(); + + foreach (var z in zones) + { + result.Data.Add(MapZoneToResultData(z)); + } + + result.PageSize = result.Data.Count; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Deletes a weather alert zone + /// + [HttpDelete("DeleteZone/{zoneId}")] + [Authorize(Policy = ResgridResources.WeatherAlert_Delete)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> DeleteZone(string zoneId) + { + if (!Guid.TryParse(zoneId, out var zoneGuid)) + return BadRequest(); + + var zone = await _weatherAlertService.GetZoneByIdAsync(zoneGuid); + if (zone == null || zone.DepartmentId != DepartmentId) + return NotFound(); + + await _weatherAlertService.DeleteZoneAsync(zoneGuid); + + var result = new StandardApiResponseV4Base(); + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Gets weather alert settings for the department + /// + [HttpGet("GetSettings")] + [Authorize(Policy = ResgridResources.WeatherAlert_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetSettings() + { + var result = new GetWeatherAlertSettingsResult(); + result.Data = await GetWeatherAlertSettingsDataAsync(); + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Saves weather alert settings for the department + /// + [HttpPost("SaveSettings")] + [Authorize(Policy = ResgridResources.WeatherAlert_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveSettings([FromBody] SaveWeatherAlertSettingsInput input) + { + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.WeatherAlertsEnabled.ToString(), DepartmentSettingTypes.WeatherAlertsEnabled); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.MinimumSeverity.ToString(), DepartmentSettingTypes.WeatherAlertMinimumSeverity); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.AutoMessageSeverity.ToString(), DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.CallIntegrationEnabled.ToString(), DepartmentSettingTypes.WeatherAlertCallIntegration); + + var result = new GetWeatherAlertSettingsResult(); + result.Data = new WeatherAlertSettingsData + { + WeatherAlertsEnabled = input.WeatherAlertsEnabled, + MinimumSeverity = input.MinimumSeverity, + AutoMessageSeverity = input.AutoMessageSeverity, + CallIntegrationEnabled = input.CallIntegrationEnabled + }; + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + private async Task GetWeatherAlertSettingsDataAsync() + { + var settings = new WeatherAlertSettingsData(); + + var enabledSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertsEnabled); + var minSeveritySetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertMinimumSeverity); + var autoMsgSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); + var callIntSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertCallIntegration); + + if (enabledSetting != null && !string.IsNullOrWhiteSpace(enabledSetting.Setting)) + settings.WeatherAlertsEnabled = bool.TryParse(enabledSetting.Setting, out var enabled) && enabled; + + if (minSeveritySetting != null && !string.IsNullOrWhiteSpace(minSeveritySetting.Setting)) + settings.MinimumSeverity = int.TryParse(minSeveritySetting.Setting, out var minSev) ? minSev : 0; + + if (autoMsgSetting != null && !string.IsNullOrWhiteSpace(autoMsgSetting.Setting)) + settings.AutoMessageSeverity = int.TryParse(autoMsgSetting.Setting, out var autoSev) ? autoSev : 0; + + if (callIntSetting != null && !string.IsNullOrWhiteSpace(callIntSetting.Setting)) + settings.CallIntegrationEnabled = bool.TryParse(callIntSetting.Setting, out var callInt) && callInt; + + return settings; + } + + private static WeatherAlertResultData MapAlertToResultData(WeatherAlert alert, Department department) + { + return new WeatherAlertResultData + { + WeatherAlertId = alert.WeatherAlertId.ToString(), + DepartmentId = alert.DepartmentId, + WeatherAlertSourceId = alert.WeatherAlertSourceId.ToString(), + ExternalId = alert.ExternalId, + Sender = alert.Sender, + Event = alert.Event, + AlertCategory = alert.AlertCategory, + Severity = alert.Severity, + Urgency = alert.Urgency, + Certainty = alert.Certainty, + Status = alert.Status, + Headline = alert.Headline, + Description = alert.Description, + Instruction = alert.Instruction, + AreaDescription = alert.AreaDescription, + Polygon = alert.Polygon, + Geocodes = alert.Geocodes, + CenterGeoLocation = alert.CenterGeoLocation, + OnsetUtc = alert.OnsetUtc?.TimeConverterToString(department), + ExpiresUtc = alert.ExpiresUtc?.TimeConverterToString(department), + EffectiveUtc = alert.EffectiveUtc.TimeConverterToString(department), + SentUtc = alert.SentUtc?.TimeConverterToString(department), + FirstSeenUtc = alert.FirstSeenUtc.TimeConverterToString(department), + LastUpdatedUtc = alert.LastUpdatedUtc.TimeConverterToString(department), + ReferencesExternalId = alert.ReferencesExternalId, + NotificationSent = alert.NotificationSent, + SystemMessageId = alert.SystemMessageId + }; + } + + private static WeatherAlertSourceResultData MapSourceToResultData(WeatherAlertSource source, Department department) + { + return new WeatherAlertSourceResultData + { + WeatherAlertSourceId = source.WeatherAlertSourceId.ToString(), + DepartmentId = source.DepartmentId, + Name = source.Name, + SourceType = source.SourceType, + AreaFilter = FormatAreaFilterForDisplay(source.AreaFilter), + ApiKey = source.ApiKey, + CustomEndpoint = source.CustomEndpoint, + PollIntervalMinutes = source.PollIntervalMinutes, + Active = source.Active, + LastPollUtc = source.LastPollUtc?.TimeConverterToString(department), + LastSuccessUtc = source.LastSuccessUtc?.TimeConverterToString(department), + IsFailure = source.IsFailure, + ErrorMessage = source.ErrorMessage + }; + } + + private static WeatherAlertZoneResultData MapZoneToResultData(WeatherAlertZone zone) + { + return new WeatherAlertZoneResultData + { + WeatherAlertZoneId = zone.WeatherAlertZoneId.ToString(), + DepartmentId = zone.DepartmentId, + Name = zone.Name, + ZoneCode = zone.ZoneCode, + CenterGeoLocation = zone.CenterGeoLocation, + RadiusMiles = zone.RadiusMiles, + IsActive = zone.IsActive, + IsPrimary = zone.IsPrimary + }; + } + + /// + /// Converts a comma-separated area filter string (e.g. "TX, WAZ021, WAZ022") + /// into a JSON array (e.g. ["TX","WAZ021","WAZ022"]) for the weather provider. + /// If the input is already valid JSON array, it is returned as-is. + /// + private static string NormalizeAreaFilter(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + var trimmed = input.Trim(); + + // Already a JSON array + if (trimmed.StartsWith("[")) + { + try + { + JsonSerializer.Deserialize(trimmed); + return trimmed; + } + catch { } + } + + // Comma-separated list — split, trim, remove empties, serialize to JSON array + var codes = trimmed.Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray(); + + return codes.Length > 0 ? JsonSerializer.Serialize(codes) : null; + } + + /// + /// Converts a JSON array area filter back to a comma-separated string for display. + /// + private static string FormatAreaFilterForDisplay(string jsonArrayOrRaw) + { + if (string.IsNullOrWhiteSpace(jsonArrayOrRaw)) + return null; + + var trimmed = jsonArrayOrRaw.Trim(); + if (trimmed.StartsWith("[")) + { + try + { + var codes = JsonSerializer.Deserialize(trimmed); + return codes != null ? string.Join(", ", codes) : trimmed; + } + catch { } + } + + return trimmed; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetActiveWeatherAlertsResult.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetActiveWeatherAlertsResult.cs new file mode 100644 index 00000000..05f8a248 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetActiveWeatherAlertsResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class GetActiveWeatherAlertsResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public GetActiveWeatherAlertsResult() + { + Data = new List(); + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertResult.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertResult.cs new file mode 100644 index 00000000..fc2271d1 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertResult.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class GetWeatherAlertResult : StandardApiResponseV4Base + { + public WeatherAlertResultData Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSettingsResult.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSettingsResult.cs new file mode 100644 index 00000000..33c79a43 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSettingsResult.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class GetWeatherAlertSettingsResult : StandardApiResponseV4Base + { + public WeatherAlertSettingsData Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSourcesResult.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSourcesResult.cs new file mode 100644 index 00000000..32940b35 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertSourcesResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class GetWeatherAlertSourcesResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public GetWeatherAlertSourcesResult() + { + Data = new List(); + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertZonesResult.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertZonesResult.cs new file mode 100644 index 00000000..7e358673 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/GetWeatherAlertZonesResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class GetWeatherAlertZonesResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public GetWeatherAlertZonesResult() + { + Data = new List(); + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs new file mode 100644 index 00000000..d4006623 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class SaveWeatherAlertSettingsInput + { + public bool WeatherAlertsEnabled { get; set; } + public int MinimumSeverity { get; set; } + public int AutoMessageSeverity { get; set; } + public bool CallIntegrationEnabled { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSourceInput.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSourceInput.cs new file mode 100644 index 00000000..1e34727f --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSourceInput.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class SaveWeatherAlertSourceInput + { + public string WeatherAlertSourceId { get; set; } + public string Name { get; set; } + public int SourceType { get; set; } + public string AreaFilter { get; set; } + public string ApiKey { get; set; } + public string CustomEndpoint { get; set; } + public int PollIntervalMinutes { get; set; } + public bool Active { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertZoneInput.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertZoneInput.cs new file mode 100644 index 00000000..8bb3e61a --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertZoneInput.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class SaveWeatherAlertZoneInput + { + public string WeatherAlertZoneId { get; set; } + public string Name { get; set; } + public string ZoneCode { get; set; } + public string CenterGeoLocation { get; set; } + public double RadiusMiles { get; set; } + public bool IsActive { get; set; } + public bool IsPrimary { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs new file mode 100644 index 00000000..1b594ec4 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs @@ -0,0 +1,33 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class WeatherAlertResultData + { + public string WeatherAlertId { get; set; } + public int DepartmentId { get; set; } + public string WeatherAlertSourceId { get; set; } + public string ExternalId { get; set; } + public string Sender { get; set; } + public string Event { get; set; } + public int AlertCategory { get; set; } + public int Severity { get; set; } + public int Urgency { get; set; } + public int Certainty { get; set; } + public int Status { get; set; } + public string Headline { get; set; } + public string Description { get; set; } + public string Instruction { get; set; } + public string AreaDescription { get; set; } + public string Polygon { get; set; } + public string Geocodes { get; set; } + public string CenterGeoLocation { get; set; } + public string OnsetUtc { get; set; } + public string ExpiresUtc { get; set; } + public string EffectiveUtc { get; set; } + public string SentUtc { get; set; } + public string FirstSeenUtc { get; set; } + public string LastUpdatedUtc { get; set; } + public string ReferencesExternalId { get; set; } + public bool NotificationSent { get; set; } + public int? SystemMessageId { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs new file mode 100644 index 00000000..7767edb6 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class WeatherAlertSettingsData + { + public bool WeatherAlertsEnabled { get; set; } + public int MinimumSeverity { get; set; } + public int AutoMessageSeverity { get; set; } + public bool CallIntegrationEnabled { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs new file mode 100644 index 00000000..cd4af849 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs @@ -0,0 +1,19 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class WeatherAlertSourceResultData + { + public string WeatherAlertSourceId { get; set; } + public int DepartmentId { get; set; } + public string Name { get; set; } + public int SourceType { get; set; } + public string AreaFilter { get; set; } + public string ApiKey { get; set; } + public string CustomEndpoint { get; set; } + public int PollIntervalMinutes { get; set; } + public bool Active { get; set; } + public string LastPollUtc { get; set; } + public string LastSuccessUtc { get; set; } + public bool IsFailure { get; set; } + public string ErrorMessage { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertZoneResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertZoneResultData.cs new file mode 100644 index 00000000..9aed0546 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertZoneResultData.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Web.Services.Models.v4.WeatherAlerts +{ + public class WeatherAlertZoneResultData + { + public string WeatherAlertZoneId { get; set; } + public int DepartmentId { get; set; } + public string Name { get; set; } + public string ZoneCode { get; set; } + public string CenterGeoLocation { get; set; } + public double RadiusMiles { get; set; } + public bool IsActive { get; set; } + public bool IsPrimary { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj index 724a5eff..445f4191 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj @@ -137,6 +137,7 @@ + diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index e5920b6f..025893d3 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -456,7 +456,8 @@ - Saves a communication test definition (admin only) + Saves a communication test definition (admin only). + Creates require CommunicationTest_Create; updates require CommunicationTest_Update. @@ -1700,6 +1701,83 @@ Array of RecipientResult objects for each responding option in the department + + + Weather alert operations + + + + + Gets all active weather alerts for the department + + + + + Gets a single weather alert by id + + + + + Gets historical weather alerts for a date range + + + + + Gets active weather alerts near a geographic location + + + + + Gets all weather alert sources for the department + + + + + Creates or updates a weather alert source + + + + + Deletes a weather alert source + + + + + Gets all weather alert zones for the department + + + + + Creates or updates a weather alert zone + + + + + Deletes a weather alert zone + + + + + Gets weather alert settings for the department + + + + + Saves weather alert settings for the department + + + + + Converts a comma-separated area filter string (e.g. "TX, WAZ021, WAZ022") + into a JSON array (e.g. ["TX","WAZ021","WAZ022"]) for the weather provider. + If the input is already valid JSON array, it is returned as-is. + + + + + Converts a JSON array area filter back to a comma-separated string for display. + + Manages encrypted credentials used by workflow action executors. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index a898ffe8..8597c660 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -407,6 +407,11 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.CommunicationTest_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.CommunicationTest_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.CommunicationTest_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Delete)); + + options.AddPolicy(ResgridResources.WeatherAlert_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.WeatherAlert_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.WeatherAlert_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Create)); + options.AddPolicy(ResgridResources.WeatherAlert_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Delete)); }); #endregion Auth Roles @@ -624,6 +629,7 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new VoipProviderModule()); builder.RegisterModule(new MessagingProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index f34b0436..b243d4ee 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -66,6 +66,7 @@ public class DispatchController : SecureBaseController private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; private readonly ICheckInTimerService _checkInTimerService; + private readonly IWeatherAlertService _weatherAlertService; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -75,7 +76,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService ITemplatesService templatesService, IPdfProvider pdfProvider, IProtocolsService protocolsService, IFormsService formsService, IShiftsService shiftsService, IContactsService contactsService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - ICheckInTimerService checkInTimerService) + ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService) { _departmentsService = departmentsService; _usersService = usersService; @@ -102,6 +103,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; _checkInTimerService = checkInTimerService; + _weatherAlertService = weatherAlertService; } #endregion Private Members and Constructors @@ -489,6 +491,9 @@ public async Task NewCall(NewCallView model, IFormCollection coll } var call = await _callsService.SaveCallAsync(model.Call, cancellationToken); + // Attach weather alerts as call notes if enabled + await _weatherAlertService.AttachWeatherAlertsToCallAsync(call, cancellationToken); + // Save UDF field values for the new call. // Keys named "udf_" carry the submitted value; keys named "udf__exists" // are hidden sentinels posted for every rendered UDF field regardless of whether @@ -853,6 +858,9 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio await _callsService.SaveCallAsync(call, cancellationToken); + // Attach weather alerts as call notes if enabled (deduplication handled inside) + await _weatherAlertService.AttachWeatherAlertsToCallAsync(call, cancellationToken); + // Save UDF field values for the updated call. // Keys named "udf_" carry the submitted value; keys named "udf__exists" // are hidden sentinels posted for every rendered UDF field regardless of whether diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WeatherAlertsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WeatherAlertsController.cs new file mode 100644 index 00000000..3a5578eb --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WeatherAlertsController.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User")] + public class WeatherAlertsController : SecureBaseController + { + #region Private Members and Constructors + + private readonly IWeatherAlertService _weatherAlertService; + private readonly IDepartmentsService _departmentsService; + + public WeatherAlertsController(IWeatherAlertService weatherAlertService, IDepartmentsService departmentsService) + { + _weatherAlertService = weatherAlertService; + _departmentsService = departmentsService; + } + + #endregion Private Members and Constructors + + public async Task Index() + { + ViewBag.DepartmentId = DepartmentId; + + return View(); + } + + public async Task Details(string id) + { + if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var alertId)) + return RedirectToAction("Index"); + + var alert = await _weatherAlertService.GetAlertByIdAsync(alertId); + + if (alert == null || alert.DepartmentId != DepartmentId) + return RedirectToAction("Index"); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + ViewBag.Department = department; + + return View(alert); + } + + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Settings() + { + var sources = await _weatherAlertService.GetSourcesByDepartmentIdAsync(DepartmentId); + + ViewBag.Sources = sources; + ViewBag.DepartmentId = DepartmentId; + + return View(); + } + + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Zones() + { + var zones = await _weatherAlertService.GetZonesByDepartmentIdAsync(DepartmentId); + + ViewBag.Zones = zones; + ViewBag.DepartmentId = DepartmentId; + + return View(); + } + + public async Task History() + { + ViewBag.DepartmentId = DepartmentId; + + return View(); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Notifications/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Notifications/Index.cshtml index e8161394..d24c23f3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Notifications/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Notifications/Index.cshtml @@ -21,14 +21,15 @@ - @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) - { -
- +
+
+ Weather Alerts + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + { + @localizer["NewNotification"] + }
- } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml index 01191333..a71e2f88 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml @@ -37,6 +37,7 @@ @*
  • @commonLocalizer["Orders"]
  • *@
  • @commonLocalizer["Links"]
  • @commonLocalizer["Notifications"]
  • + @*
  • Weather Alerts
  • *@ @*
  • @commonLocalizer["Commands"]
  • *@
  • @commonLocalizer["BigBoard"]
  • @commonLocalizer["Dispatch"]
  • diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Details.cshtml new file mode 100644 index 00000000..35416fb1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Details.cshtml @@ -0,0 +1,94 @@ +@using Resgrid.Model.Helpers +@model Resgrid.Model.WeatherAlert +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AlertDetail"]; + var department = ViewBag.Department as Resgrid.Model.Department; + + var severityNames = new[] { localizer["SeverityExtreme"].Value, localizer["SeveritySevere"].Value, localizer["SeverityModerate"].Value, localizer["SeverityMinor"].Value, localizer["SeverityUnknown"].Value }; + var severityClasses = new[] { "danger", "danger", "warning", "info", "default" }; + var urgencyNames = new[] { localizer["UrgencyImmediate"].Value, localizer["UrgencyExpected"].Value, localizer["UrgencyFuture"].Value, localizer["UrgencyPast"].Value, localizer["UrgencyUnknown"].Value }; + var certaintyNames = new[] { "Observed", "Likely", "Possible", "Unlikely", localizer["SeverityUnknown"].Value }; + var categoryNames = new[] { localizer["CategoryMet"].Value, localizer["CategoryFire"].Value, localizer["CategoryHealth"].Value, localizer["CategoryEnv"].Value, localizer["CategoryOther"].Value }; + var statusNames = new[] { localizer["StatusActive"].Value, localizer["StatusUpdated"].Value, localizer["StatusExpired"].Value, localizer["StatusCancelled"].Value }; +} + +
    +
    +

    @localizer["AlertDetail"]

    + +
    + +
    + +
    +
    +
    +
    +
    +
    @Model.Event
    + @(severityNames[Model.Severity]) +
    +
    + @if (!string.IsNullOrEmpty(Model.Headline)) + { +
    + @Model.Headline +
    + } + +
    +
    + + + + + + +
    @localizer["Status"]@(statusNames[Model.Status])
    @localizer["Severity"]@(severityNames[Model.Severity])
    @localizer["Urgency"]@(urgencyNames[Model.Urgency])
    @localizer["Certainty"]@(certaintyNames[Model.Certainty])
    @localizer["Category"]@(categoryNames[Model.AlertCategory])
    +
    +
    + + + + + + +
    @localizer["Sender"]@(Model.Sender ?? "N/A")
    @localizer["Area"]@(Model.AreaDescription ?? "N/A")
    @localizer["EffectiveDate"]@Model.EffectiveUtc.TimeConverterToString(department)
    @localizer["OnsetDate"]@(Model.OnsetUtc.HasValue ? Model.OnsetUtc.Value.TimeConverterToString(department) : "N/A")
    @localizer["ExpiresDate"]@(Model.ExpiresUtc.HasValue ? Model.ExpiresUtc.Value.TimeConverterToString(department) : "N/A")
    +
    +
    + + @if (!string.IsNullOrEmpty(Model.Description)) + { +
    +
    @localizer["Description"]
    +
    @Model.Description
    +
    + } + + @if (!string.IsNullOrEmpty(Model.Instruction)) + { +
    +
    @localizer["Instructions"]
    +
    @Model.Instruction
    +
    + } +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml new file mode 100644 index 00000000..344dd5f1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml @@ -0,0 +1,176 @@ +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AlertHistory"]; +} + +
    +
    +

    @localizer["AlertHistory"]

    + +
    + +
    + +
    +
    +
    +
    +
    +
    @localizer["AlertHistory"]
    +
    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    @localizer["AlertHistory"]
    +
    +
    +
    +
    @localizer["NoHistoryFound"]
    +
    +
    +
    +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml new file mode 100644 index 00000000..74622d90 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml @@ -0,0 +1,99 @@ +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["PageTitle"]; +} + +
    +
    +

    @localizer["WeatherAlerts"]

    + +
    + +
    + +
    +
    +
    +
    +
    +
    @localizer["ActiveAlerts"]
    +
    +
    +
    +

    @localizer["LoadingAlerts"]

    +
    +
    +
    +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml new file mode 100644 index 00000000..8b8d4a68 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml @@ -0,0 +1,440 @@ +@using Resgrid.Model +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Settings"]; + var sources = ViewBag.Sources as List ?? new List(); + var sourceTypeNames = new[] { localizer["SourceTypeNWS"].Value, localizer["SourceTypeEC"].Value, localizer["SourceTypeMeteoAlarm"].Value }; +} + +
    +
    +

    @localizer["Settings"]

    + +
    + +
    + +
    + + +
    +
    +
    +
    +
    @localizer["NotificationSettings"]
    +
    +
    +

    @localizer["NotificationSettingsDesc"]

    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    + + @localizer["MinimumSeverityDisplayHelp"] +
    +
    +
    + +
    + + @localizer["AutoMessageSeverityHelp"] +
    +
    +
    + +
    +
    + +
    + @localizer["EnableCallIntegrationHelp"] +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    @localizer["AlertSources"]
    +
    +
    + @if (sources.Any()) + { + + + + + + + + + + + + + + + @foreach (var source in sources) + { + + + + + + + + + + + } + +
    @localizer["SourceName"]@localizer["SourceType"]@localizer["AreaFilter"]@localizer["PollIntervalMinutes"]@localizer["Status"]@localizer["LastPoll"]@localizer["SourceStatus"]@localizer["Actions"]
    @source.Name@(sourceTypeNames[source.SourceType])@(source.AreaFilter ?? "-")@source.PollIntervalMinutes min + @if (source.Active) + { + @localizer["Active"] + } + else + { + @localizer["Inactive"] + } + @(source.LastPollUtc?.ToString("g") ?? localizer["NeverPolled"].Value) + @if (source.IsFailure) + { + @localizer["Error"] + } + else + { + OK + } + + @localizer["Edit"] + @if (source.Active) + { + @localizer["Disable"] + } + else + { + @localizer["Enable"] + } + @localizer["Delete"] +
    + } + else + { +
    + @localizer["NoSourcesConfigured"] +
    + } +
    +
    + + +
    +
    +
    @localizer["AddEditSource"]
    +
    +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + + @localizer["AreaFilterHelp"] +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + + + + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Zones.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Zones.cshtml new file mode 100644 index 00000000..c0eb76ca --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Zones.cshtml @@ -0,0 +1,278 @@ +@using Resgrid.Model +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["MonitoringZones"]; + var zones = ViewBag.Zones as List ?? new List(); +} + +
    +
    +

    @localizer["MonitoringZones"]

    + +
    + +
    + +
    +
    +
    +
    +
    +
    @localizer["MonitoringZones"]
    +
    +
    + @if (zones.Any()) + { + + + + + + + + + + + + + + @foreach (var zone in zones) + { + + + + + + + + + + } + +
    @localizer["ZoneName"]@localizer["ZoneCode"]@localizer["CenterLocation"]@localizer["RadiusMiles"]@localizer["Primary"]@localizer["Status"]@localizer["Actions"]
    @zone.Name@(zone.ZoneCode ?? "-")@(zone.CenterGeoLocation ?? "-")@zone.RadiusMiles + @if (zone.IsPrimary) + { + @localizer["Primary"] + } + + @if (zone.IsActive) + { + @localizer["Active"] + } + else + { + @localizer["Inactive"] + } + + @localizer["Edit"] + @if (zone.IsActive) + { + @localizer["Disable"] + } + else + { + @localizer["Enable"] + } + @localizer["Delete"] +
    + } + else + { +
    + @localizer["NoZonesConfigured"] +
    + } +
    +
    + +
    +
    +
    @localizer["AddZone"]
    +
    +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Resgrid.Web.csproj b/Web/Resgrid.Web/Resgrid.Web.csproj index f1c7b631..6fe9484a 100644 --- a/Web/Resgrid.Web/Resgrid.Web.csproj +++ b/Web/Resgrid.Web/Resgrid.Web.csproj @@ -178,6 +178,7 @@ + diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 827b3344..f7f81a0e 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -505,6 +505,7 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new VoipProviderModule()); builder.RegisterModule(new MessagingProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Workers/Resgrid.Workers.Console/Commands/WeatherAlertImportCommand.cs b/Workers/Resgrid.Workers.Console/Commands/WeatherAlertImportCommand.cs new file mode 100644 index 00000000..f359e391 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/WeatherAlertImportCommand.cs @@ -0,0 +1,18 @@ +using Quidjibo.Commands; +using System; +using System.Collections.Generic; + +namespace Resgrid.Workers.Console.Commands +{ + public class WeatherAlertImportCommand : IQuidjiboCommand + { + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + + public WeatherAlertImportCommand(int id) + { + Id = id; + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index b3d4f796..415792ee 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -368,6 +368,12 @@ await Client.ScheduleAsync("Communication Test", new Commands.CommunicationTestCommand(17), Cron.MinuteIntervals(15), stoppingToken); + + _logger.Log(LogLevel.Information, "Scheduling Weather Alert Import"); + await Client.ScheduleAsync("Weather Alert Import", + new Commands.WeatherAlertImportCommand(20), + Cron.MinuteIntervals(5), + stoppingToken); } else { diff --git a/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs b/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs new file mode 100644 index 00000000..25f73da7 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Model.Services; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; + +namespace Resgrid.Workers.Console.Tasks +{ + public class WeatherAlertImportTask : IQuidjiboHandler + { + public string Name => "Weather Alert Import"; + public int Priority => 1; + public ILogger _logger; + + public WeatherAlertImportTask(ILogger logger) + { + _logger = logger; + } + + public async Task ProcessAsync(WeatherAlertImportCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + try + { + progress.Report(1, $"Starting the {Name} Task"); + + var weatherAlertService = Bootstrapper.GetKernel().Resolve(); + + _logger.LogInformation("WeatherAlertImport::Processing all active sources"); + await weatherAlertService.ProcessAllActiveSourcesAsync(cancellationToken); + + _logger.LogInformation("WeatherAlertImport::Expiring old alerts"); + await weatherAlertService.ExpireOldAlertsAsync(cancellationToken); + + _logger.LogInformation("WeatherAlertImport::Sending pending notifications"); + await weatherAlertService.SendPendingNotificationsAsync(cancellationToken); + + progress.Report(100, $"Finishing the {Name} Task"); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + _logger.LogError(ex.ToString()); + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Bootstrapper.cs b/Workers/Resgrid.Workers.Framework/Bootstrapper.cs index 8a49426c..e88c733e 100644 --- a/Workers/Resgrid.Workers.Framework/Bootstrapper.cs +++ b/Workers/Resgrid.Workers.Framework/Bootstrapper.cs @@ -48,6 +48,7 @@ public static void Initialize() builder.RegisterModule(new VoipProviderModule()); builder.RegisterModule(new MessagingProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); _container = builder.Build(); diff --git a/Workers/Resgrid.Workers.Framework/Resgrid.Workers.Framework.csproj b/Workers/Resgrid.Workers.Framework/Resgrid.Workers.Framework.csproj index 01d13420..5f027427 100644 --- a/Workers/Resgrid.Workers.Framework/Resgrid.Workers.Framework.csproj +++ b/Workers/Resgrid.Workers.Framework/Resgrid.Workers.Framework.csproj @@ -30,5 +30,6 @@ + From 79395b4323f18ec77451327e8159ef1a05b7cb2c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 9 Apr 2026 17:12:27 -0700 Subject: [PATCH 2/4] RE1-T112 PR#320 fixes --- .../Services/WeatherAlertServiceTests.cs | 19 ++- .../Controllers/v4/WeatherAlertsController.cs | 39 ++++- .../Resgrid.Web.Services.xml | 7 + .../User/Views/WeatherAlerts/History.cshtml | 42 ++++-- .../User/Views/WeatherAlerts/Index.cshtml | 32 +++- .../User/Views/WeatherAlerts/Settings.cshtml | 138 +++++++++--------- 6 files changed, 188 insertions(+), 89 deletions(-) diff --git a/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs b/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs index 2bf003d5..dd4c2542 100644 --- a/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs @@ -28,6 +28,7 @@ public class with_the_weather_alert_service : TestBase protected readonly Mock _departmentSettingsRepoMock; protected readonly Mock _departmentsServiceMock; protected readonly Mock _messageServiceMock; + protected readonly Mock _callNotesRepoMock; protected readonly Mock _cacheProviderMock; protected readonly Mock _eventAggregatorMock; @@ -44,6 +45,7 @@ protected with_the_weather_alert_service() _departmentSettingsRepoMock = new Mock(); _departmentsServiceMock = new Mock(); _messageServiceMock = new Mock(); + _callNotesRepoMock = new Mock(); _cacheProviderMock = new Mock(); _eventAggregatorMock = new Mock(); @@ -55,6 +57,7 @@ protected with_the_weather_alert_service() _departmentSettingsRepoMock.Object, _departmentsServiceMock.Object, _messageServiceMock.Object, + _callNotesRepoMock.Object, _cacheProviderMock.Object, _eventAggregatorMock.Object); } @@ -79,7 +82,13 @@ public async Task should_assign_new_guid_and_created_date_for_new_source() _weatherAlertSourceRepoMock .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((WeatherAlertSource s, CancellationToken _, bool __) => s); + .ReturnsAsync((WeatherAlertSource s, CancellationToken _, bool __) => + { + // Simulate what RepositoryBase.SaveOrUpdateAsync does for new entities + if (s.WeatherAlertSourceId == Guid.Empty) + s.WeatherAlertSourceId = Guid.NewGuid(); + return s; + }); var result = await _weatherAlertService.SaveSourceAsync(source); @@ -458,7 +467,13 @@ public async Task should_assign_new_guid_and_created_date_for_new_zone() _weatherAlertZoneRepoMock .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((WeatherAlertZone z, CancellationToken _, bool __) => z); + .ReturnsAsync((WeatherAlertZone z, CancellationToken _, bool __) => + { + // Simulate what RepositoryBase.SaveOrUpdateAsync does for new entities + if (z.WeatherAlertZoneId == Guid.Empty) + z.WeatherAlertZoneId = Guid.NewGuid(); + return z; + }); var result = await _weatherAlertService.SaveZoneAsync(zone); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 2500ddf7..26b79279 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -159,6 +159,9 @@ public async Task> SaveSource([FromBo if (!string.IsNullOrWhiteSpace(input.WeatherAlertSourceId) && Guid.TryParse(input.WeatherAlertSourceId, out var sourceGuid)) { + if (!User.HasClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)) + return Forbid(); + source = await _weatherAlertService.GetSourceByIdAsync(sourceGuid); if (source == null || source.DepartmentId != DepartmentId) return NotFound(); @@ -177,7 +180,7 @@ public async Task> SaveSource([FromBo source.SourceType = input.SourceType; source.AreaFilter = NormalizeAreaFilter(input.AreaFilter); source.ApiKey = input.ApiKey; - source.CustomEndpoint = input.CustomEndpoint; + source.CustomEndpoint = ValidateCustomEndpoint(input.CustomEndpoint, input.SourceType); source.PollIntervalMinutes = Math.Max(input.PollIntervalMinutes, 15); source.Active = input.Active; @@ -253,6 +256,9 @@ public async Task> SaveZone([FromBody] if (!string.IsNullOrWhiteSpace(input.WeatherAlertZoneId) && Guid.TryParse(input.WeatherAlertZoneId, out var zoneGuid)) { + if (!User.HasClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)) + return Forbid(); + zone = await _weatherAlertService.GetZoneByIdAsync(zoneGuid); if (zone == null || zone.DepartmentId != DepartmentId) return NotFound(); @@ -498,5 +504,36 @@ private static string FormatAreaFilterForDisplay(string jsonArrayOrRaw) return trimmed; } + + /// + /// Validates a custom endpoint URL to prevent SSRF. Enforces HTTPS and + /// restricts the host to known weather API domains per source type. + /// Returns null if the URL is empty, invalid, or not on the allowlist. + /// + private static string ValidateCustomEndpoint(string url, int sourceType) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) + return null; + + if (uri.Scheme != Uri.UriSchemeHttps) + return null; + + var allowedHosts = sourceType switch + { + 0 => new[] { "api.weather.gov" }, // NWS + 1 => new[] { "dd.weather.gc.ca", "dd.meteo.gc.ca" }, // Environment Canada + 2 => new[] { "feeds.meteoalarm.org", "meteoalarm.org" }, // MeteoAlarm + _ => Array.Empty() + }; + + var host = uri.Host.ToLowerInvariant(); + if (!Array.Exists(allowedHosts, h => host == h || host.EndsWith("." + h))) + return null; + + return uri.GetLeftPart(UriPartial.Query); + } } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 025893d3..d89ae4b7 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1778,6 +1778,13 @@ Converts a JSON array area filter back to a comma-separated string for display.
    + + + Validates a custom endpoint URL to prevent SSRF. Enforces HTTPS and + restricts the host to known weather API domains per source type. + Returns null if the URL is empty, invalid, or not on the allowlist. + + Manages encrypted credentials used by workflow action executors. diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml index 344dd5f1..6a779df5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml @@ -142,7 +142,19 @@ var categoryNames = [loc.categoryMet, loc.categoryFire, loc.categoryHealth, loc.categoryEnv, loc.categoryOther]; var statusNames = [loc.statusActive, loc.statusUpdated, loc.statusExpired, loc.statusCancelled]; - var table = ''; + var $table = $('
    ' + loc.event + '' + loc.severity + '' + loc.category + '' + loc.area + '' + loc.effectiveDate + '' + loc.expires + '' + loc.status + '
    '); + var $thead = $('').append( + $('') + .append($(''); $.each(data.Data, function(i, alert) { var sevClass = severityClasses[alert.Severity] || 'default'; @@ -150,19 +162,23 @@ var catName = categoryNames[alert.AlertCategory] || loc.categoryOther; var statName = statusNames[alert.Status] || loc.severityUnknown; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; + var $row = $(''); + $row.append($('
    ').text(loc.event)) + .append($('').text(loc.severity)) + .append($('').text(loc.category)) + .append($('').text(loc.area)) + .append($('').text(loc.effectiveDate)) + .append($('').text(loc.expires)) + .append($('').text(loc.status)) + .append($('')) + ); + var $tbody = $('
    ' + alert.Event + '' + sevName + '' + catName + '' + (alert.AreaDescription || '') + '' + (alert.EffectiveUtc || '') + '' + (alert.ExpiresUtc || 'N/A') + '' + statName + '' + loc.details + '
    ').text(alert.Event)); + $row.append($('').append($('').addClass('label label-' + sevClass).text(sevName))); + $row.append($('').text(catName)); + $row.append($('').text(alert.AreaDescription || '')); + $row.append($('').text(alert.EffectiveUtc || '')); + $row.append($('').text(alert.ExpiresUtc || 'N/A')); + $row.append($('').text(statName)); + $row.append($('').append( + $('').addClass('btn btn-xs btn-primary') + .attr('href', '/User/WeatherAlerts/Details/' + encodeURIComponent(alert.WeatherAlertId)) + .text(loc.details) + )); + $tbody.append($row); }); - table += '
    '; - container.html(table); + $table.append($thead).append($tbody); + container.append($table); } else { container.html('
    ' + loc.noHistoryFound + '
    '); } diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml index 74622d90..38c20192 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Index.cshtml @@ -78,14 +78,34 @@ container.empty(); if (data.Data && data.Data.length > 0) { var severityNames = [loc.severityExtreme, loc.severitySevere, loc.severityModerate, loc.severityMinor, loc.severityUnknown]; - var table = ''; + var severityClasses = ['danger', 'danger', 'warning', 'info', 'default']; + var $table = $('
    ' + loc.event + '' + loc.severity + '' + loc.area + '' + loc.expires + '
    '); + var $thead = $('').append( + $('') + .append($(''); $.each(data.Data, function(i, alert) { - var severityClass = ['danger', 'danger', 'warning', 'info', 'default'][alert.Severity] || 'default'; - var severityName = severityNames[alert.Severity] || loc.severityUnknown; - table += ''; + var sevClass = severityClasses[alert.Severity] || 'default'; + var sevName = severityNames[alert.Severity] || loc.severityUnknown; + var $row = $(''); + $row.append($('
    ').text(loc.event)) + .append($('').text(loc.severity)) + .append($('').text(loc.area)) + .append($('').text(loc.expires)) + .append($('')) + ); + var $tbody = $('
    ' + alert.Event + '' + severityName + '' + (alert.AreaDescription || '') + '' + (alert.ExpiresUtc || 'N/A') + '' + loc.details + '
    ').text(alert.Event)); + $row.append($('').append($('').addClass('label label-' + sevClass).text(sevName))); + $row.append($('').text(alert.AreaDescription || '')); + $row.append($('').text(alert.ExpiresUtc || 'N/A')); + $row.append($('').append( + $('').addClass('btn btn-xs btn-primary') + .attr('href', '/User/WeatherAlerts/Details/' + encodeURIComponent(alert.WeatherAlertId)) + .text(loc.details) + )); + $tbody.append($row); }); - table += '
    '; - container.html(table); + $table.append($thead).append($tbody); + container.append($table); } else { container.html('
    ' + loc.noActiveAlerts + '
    '); } diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml index 8b8d4a68..ae398567 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml @@ -1,4 +1,5 @@ @using Resgrid.Model +@using System.Text.Json @inject IStringLocalizer localizer @{ ViewBag.Title = "Resgrid | " + localizer["Settings"]; @@ -138,10 +139,21 @@ } @(source.LastPollUtc?.ToString("g") ?? localizer["NeverPolled"].Value) + @{ + var sourceData = JsonSerializer.Serialize(new { + id = source.WeatherAlertSourceId.ToString(), + name = source.Name, + sourceType = source.SourceType, + areaFilter = source.AreaFilter ?? "", + pollInterval = source.PollIntervalMinutes, + active = source.Active, + errorMessage = source.ErrorMessage ?? "" + }); + } @if (source.IsFailure) { - @localizer["Error"] + @localizer["Error"] } else { @@ -149,16 +161,16 @@ } - @localizer["Edit"] + @localizer["Edit"] @if (source.Active) { - @localizer["Disable"] + @localizer["Disable"] } else { - @localizer["Enable"] + @localizer["Enable"] } - @localizer["Delete"] + @localizer["Delete"] } @@ -328,46 +340,37 @@ }); } - // --- Error Modal --- - function showError(sourceName, errorMessage) { - $('#errorModalSourceName').text(sourceName); - $('#errorModalMessage').text(errorMessage || 'Unknown error'); + // --- Event delegation for source actions (no inline onclick) --- + $(document).on('click', '.js-show-error', function () { + var s = $(this).data('source'); + $('#errorModalSourceName').text(s.name); + $('#errorModalMessage').text(s.errorMessage || 'Unknown error'); $('#errorModal').modal('show'); - } - - // --- Sources --- - function resetSourceForm() { - $('#editSourceId').val(''); - $('#sourceName').val(''); - $('#sourceType').val('0'); - $('#areaFilter').val(''); - $('#apiKey').val(''); - $('#pollInterval').val(15); - $('#sourceActive').prop('checked', true); - $('#sourceFormTitle').text(loc.addEditSource); - } + }); - function editSource(id, name, type, areaFilter, pollInterval, isActive, apiKey) { - $('#editSourceId').val(id); - $('#sourceName').val(name); - $('#sourceType').val(type); - $('#areaFilter').val(areaFilter); - $('#apiKey').val(apiKey || ''); - $('#pollInterval').val(pollInterval); - $('#sourceActive').prop('checked', isActive); + $(document).on('click', '.js-edit-source', function () { + var s = $(this).data('source'); + $('#editSourceId').val(s.id); + $('#sourceName').val(s.name); + $('#sourceType').val(s.sourceType); + $('#areaFilter').val(s.areaFilter); + $('#apiKey').val(''); // API key not in DOM; user re-enters if changing + $('#pollInterval').val(s.pollInterval); + $('#sourceActive').prop('checked', s.active); $('#sourceFormTitle').text(loc.editSource); $('html, body').animate({ scrollTop: $('#sourceFormPanel').offset().top - 60 }, 300); - } + }); - function toggleSource(id, name, type, areaFilter, pollInterval, newActiveState, apiKey) { + $(document).on('click', '.js-toggle-source', function () { + var s = $(this).data('source'); + var newActive = $(this).data('newactive'); var data = { - WeatherAlertSourceId: id, - Name: name, - SourceType: parseInt(type), - AreaFilter: areaFilter, - ApiKey: apiKey || '', - PollIntervalMinutes: parseInt(pollInterval), - Active: newActiveState + WeatherAlertSourceId: s.id, + Name: s.name, + SourceType: s.sourceType, + AreaFilter: s.areaFilter, + PollIntervalMinutes: s.pollInterval, + Active: newActive }; $.ajax({ @@ -376,13 +379,34 @@ contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + getAuthToken() }, data: JSON.stringify(data), - success: function () { - location.reload(); - }, - error: function () { - alert(loc.failedToToggleSource); - } + success: function () { location.reload(); }, + error: function () { alert(loc.failedToToggleSource); } }); + }); + + $(document).on('click', '.js-delete-source', function () { + var sourceId = $(this).data('sourceid'); + if (confirm(loc.deleteSourceConfirm)) { + $.ajax({ + url: resgrid.absoluteApiBaseUrl + '/api/v4/WeatherAlerts/DeleteSource/' + encodeURIComponent(sourceId), + type: 'DELETE', + headers: { 'Authorization': 'Bearer ' + getAuthToken() }, + success: function () { location.reload(); }, + error: function () { alert(loc.failedToDeleteSource); } + }); + } + }); + + // --- Sources --- + function resetSourceForm() { + $('#editSourceId').val(''); + $('#sourceName').val(''); + $('#sourceType').val('0'); + $('#areaFilter').val(''); + $('#apiKey').val(''); + $('#pollInterval').val(15); + $('#sourceActive').prop('checked', true); + $('#sourceFormTitle').text(loc.addEditSource); } function saveSource() { @@ -412,29 +436,9 @@ contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + getAuthToken() }, data: JSON.stringify(data), - success: function () { - location.reload(); - }, - error: function () { - alert(loc.failedToSaveSource); - } + success: function () { location.reload(); }, + error: function () { alert(loc.failedToSaveSource); } }); } - - function deleteSource(sourceId) { - if (confirm(loc.deleteSourceConfirm)) { - $.ajax({ - url: resgrid.absoluteApiBaseUrl + '/api/v4/WeatherAlerts/DeleteSource/' + sourceId, - type: 'DELETE', - headers: { 'Authorization': 'Bearer ' + getAuthToken() }, - success: function () { - location.reload(); - }, - error: function () { - alert(loc.failedToDeleteSource); - } - }); - } - } } From 2d0fad6657f99292219c4628fcc0ba501a1f545a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 9 Apr 2026 17:27:32 -0700 Subject: [PATCH 3/4] RE1-T112 PR#320 fixes --- .../Controllers/v4/WeatherAlertsController.cs | 19 ++++++++++++++----- .../WeatherAlertSourceResultData.cs | 2 +- .../User/Views/WeatherAlerts/History.cshtml | 10 ++++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 26b79279..8baeed5c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -157,8 +157,11 @@ public async Task> SaveSource([FromBo { WeatherAlertSource source; - if (!string.IsNullOrWhiteSpace(input.WeatherAlertSourceId) && Guid.TryParse(input.WeatherAlertSourceId, out var sourceGuid)) + if (!string.IsNullOrWhiteSpace(input.WeatherAlertSourceId)) { + if (!Guid.TryParse(input.WeatherAlertSourceId, out var sourceGuid)) + return BadRequest(); + if (!User.HasClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)) return Forbid(); @@ -254,8 +257,11 @@ public async Task> SaveZone([FromBody] { WeatherAlertZone zone; - if (!string.IsNullOrWhiteSpace(input.WeatherAlertZoneId) && Guid.TryParse(input.WeatherAlertZoneId, out var zoneGuid)) + if (!string.IsNullOrWhiteSpace(input.WeatherAlertZoneId)) { + if (!Guid.TryParse(input.WeatherAlertZoneId, out var zoneGuid)) + return BadRequest(); + if (!User.HasClaim(ResgridClaimTypes.Resources.WeatherAlert, ResgridClaimTypes.Actions.Update)) return Forbid(); @@ -425,7 +431,7 @@ private static WeatherAlertSourceResultData MapSourceToResultData(WeatherAlertSo Name = source.Name, SourceType = source.SourceType, AreaFilter = FormatAreaFilterForDisplay(source.AreaFilter), - ApiKey = source.ApiKey, + HasApiKey = !string.IsNullOrEmpty(source.ApiKey), CustomEndpoint = source.CustomEndpoint, PollIntervalMinutes = source.PollIntervalMinutes, Active = source.Active, @@ -463,7 +469,7 @@ private static string NormalizeAreaFilter(string input) var trimmed = input.Trim(); - // Already a JSON array + // Already a JSON array — return as-is if valid, reject if malformed if (trimmed.StartsWith("[")) { try @@ -471,7 +477,10 @@ private static string NormalizeAreaFilter(string input) JsonSerializer.Deserialize(trimmed); return trimmed; } - catch { } + catch + { + return null; + } } // Comma-separated list — split, trim, remove empties, serialize to JSON array diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs index cd4af849..6c632b4f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs @@ -7,7 +7,7 @@ public class WeatherAlertSourceResultData public string Name { get; set; } public int SourceType { get; set; } public string AreaFilter { get; set; } - public string ApiKey { get; set; } + public bool HasApiKey { get; set; } public string CustomEndpoint { get; set; } public int PollIntervalMinutes { get; set; } public bool Active { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml index 6a779df5..9bf35e58 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml @@ -107,13 +107,19 @@ } catch (e) {} return ''; } + function formatLocalDate(d) { + var y = d.getFullYear(); + var m = ('0' + (d.getMonth() + 1)).slice(-2); + var day = ('0' + d.getDate()).slice(-2); + return y + '-' + m + '-' + day; + } $(document).ready(function() { var today = new Date(); var thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(today.getDate() - 30); - $('#endDate').val(today.toISOString().split('T')[0]); - $('#startDate').val(thirtyDaysAgo.toISOString().split('T')[0]); + $('#endDate').val(formatLocalDate(today)); + $('#startDate').val(formatLocalDate(thirtyDaysAgo)); loadHistory(); }); From 6cfee051aec1124b82e03a4d481b07fe2bb23513 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 9 Apr 2026 17:34:11 -0700 Subject: [PATCH 4/4] RE1-T112 PR#320 fixes --- .../Controllers/v4/WeatherAlertsController.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 8baeed5c..e4bde09b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -89,6 +89,9 @@ public async Task> GetWeatherAlert(string al [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetAlertHistory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { + if (startDate > endDate) + return BadRequest(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var alerts = await _weatherAlertService.GetAlertHistoryAsync(DepartmentId, startDate, endDate); var result = new GetActiveWeatherAlertsResult(); @@ -111,6 +114,9 @@ public async Task> GetAlertHistory([F [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetAlertsNearLocation([FromQuery] double lat, [FromQuery] double lng, [FromQuery] double radiusMiles = 25) { + if (lat < -90 || lat > 90 || lng < -180 || lng > 180 || radiusMiles < 0 || radiusMiles > 500) + return BadRequest(); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var alerts = await _weatherAlertService.GetActiveAlertsNearLocationAsync(DepartmentId, lat, lng, radiusMiles); var result = new GetActiveWeatherAlertsResult();