diff --git a/Core/Resgrid.Config/InfoConfig.cs b/Core/Resgrid.Config/InfoConfig.cs index 70f51f12..4fda92af 100644 --- a/Core/Resgrid.Config/InfoConfig.cs +++ b/Core/Resgrid.Config/InfoConfig.cs @@ -33,6 +33,7 @@ public static class InfoConfig LocationInfo = "This is the Resgrid system hosted in the Western United States (private datacenter). This system services most Resgrid customers.", IsDefault = true, + AppUrl = "https://app.resgrid.com", ApiUrl = "https://api.resgrid.com", AllowsFreeAccounts = true }, @@ -41,9 +42,10 @@ public static class InfoConfig Name = "EU-Central", DisplayName = "Resgrid Europe", LocationInfo = - "This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data compliance requirements.", + "This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data (GDPR) compliance requirements.", IsDefault = false, - ApiUrl = "https://api.eu.resgrid.com", + AppUrl = "https://app.eu-central.resgrid.com", + ApiUrl = "https://api-eu-central.resgrid.com", AllowsFreeAccounts = false } }; @@ -55,7 +57,13 @@ public class ResgridSystemLocation public string DisplayName { get; set; } public string LocationInfo { get; set; } public bool IsDefault { get; set; } + public string AppUrl { get; set; } public string ApiUrl { get; set; } public bool AllowsFreeAccounts { get; set; } + + public string GetLogonUrl() + { + return AppUrl + "/Account/LogOn"; + } } } diff --git a/Core/Resgrid.Config/PaymentProviderConfig.cs b/Core/Resgrid.Config/PaymentProviderConfig.cs index 991d37eb..58758646 100644 --- a/Core/Resgrid.Config/PaymentProviderConfig.cs +++ b/Core/Resgrid.Config/PaymentProviderConfig.cs @@ -1,4 +1,6 @@ -namespace Resgrid.Config +using System; + +namespace Resgrid.Config { public static class PaymentProviderConfig { @@ -36,6 +38,32 @@ public static class PaymentProviderConfig public static string PaddleProductionClientToken = ""; public static string PaddleTestClientToken = ""; + // Global toggle: 1 = Stripe (default), 7 = Paddle. Matches PaymentMethods enum values. + // Set per-instance via ResgridConfig.json: "PaymentProviderConfig.ActivePaymentProvider": "7" + public static int ActivePaymentProvider = 1; + + public const int ProviderStripe = 1; + public const int ProviderPaddle = 7; + + public static int GetActivePaymentProvider() + { + if (ActivePaymentProvider != ProviderStripe && ActivePaymentProvider != ProviderPaddle) + throw new InvalidOperationException( + $"Unsupported ActivePaymentProvider value '{ActivePaymentProvider}'. Expected {ProviderStripe} (Stripe) or {ProviderPaddle} (Paddle)."); + + return ActivePaymentProvider; + } + + public static bool IsStripeActive() + { + return GetActivePaymentProvider() == ProviderStripe; + } + + public static bool IsPaddleActive() + { + return GetActivePaymentProvider() == ProviderPaddle; + } + public static string GetStripeClientKey() { if (IsTestMode) diff --git a/Core/Resgrid.Localization/Account/Login.en.resx b/Core/Resgrid.Localization/Account/Login.en.resx index 18f1f33d..29fe28ec 100644 --- a/Core/Resgrid.Localization/Account/Login.en.resx +++ b/Core/Resgrid.Localization/Account/Login.en.resx @@ -258,4 +258,7 @@ Affiliate Code + + Region + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs new file mode 100644 index 00000000..57770760 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs @@ -0,0 +1,6 @@ +namespace Resgrid.Localization.Areas.User.CommunicationTest +{ + public class CommunicationTest + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx new file mode 100644 index 00000000..b93767e1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Communication Tests + + + Communication Tests + + + New Test + + + Edit Test + + + Delete Test + + + Are you sure you want to delete this test? + + + Test Definitions + + + Name + + + Description + + + Schedule Type + + + On Demand + + + Weekly + + + Monthly + + + Days of Week + + + Day of Month + + + Time + + + Channels to Test + + + SMS + + + Email + + + Voice + + + Push + + + Active + + + Inactive + + + Response Window + + + Response Window (minutes) + + + Create Test + + + Save Changes + + + Cancel + + + Recent Test Runs + + + Run Now + + + Run Code + + + Started + + + Status + + + Users Tested + + + Responses + + + View Report + + + Communication Test Report + + + Run Details + + + Per-User Results + + + User + + + Contact + + + Carrier + + + Verification Status + + + Responded + + + No Response + + + Not Sent + + + Response Rate + + + Your communication test response has been recorded. Thank you. + + + Resgrid received your communication test response. Thank you. + + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index c50c8844..5a26e4b7 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -107,6 +107,12 @@ الإجابة على أسئلة البروتوكول البروتوكولات نص البروتوكول + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + إذا أردت إعادة بث هذا البلاغ حدد هذا المربع. سيُرسَل البلاغ إلى جميع المستخدمين المحددين بغض النظر عن مشاركتهم في الإرسال الأول أم لا. إعادة الإرسال معرف المرجع @@ -157,4 +163,31 @@ المدة (دقائق) حد التحذير (دقائق) المصدر + بث الفيديو + إضافة بث فيديو + تعديل بث الفيديو + حذف بث الفيديو + الاسم + الرابط + نوع البث + تنسيق البث + الوصف + الحالة + الموقع + أضيف بواسطة + تاريخ الإضافة + ترتيب الفرز + نشط + غير نشط + خطأ + طائرة مسيّرة + كاميرا ثابتة + كاميرا الجسم + كاميرا المرور + كاميرا الطقس + بث فضائي + كاميرا ويب + أخرى + هل أنت متأكد من رغبتك في حذف بث الفيديو هذا؟ + لم تتم إضافة أي بث فيديو لهذه المكالمة. diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 7851beef..fb6943bb 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Quelle + + Videoübertragungen + + + Videoübertragung hinzufügen + + + Videoübertragung bearbeiten + + + Videoübertragung löschen + + + Name + + + URL + + + Übertragungstyp + + + Übertragungsformat + + + Beschreibung + + + Status + + + Standort + + + Hinzugefügt von + + + Hinzugefügt am + + + Sortierreihenfolge + + + Aktiv + + + Inaktiv + + + Fehler + + + Drohne + + + Feste Kamera + + + Körperkamera + + + Verkehrskamera + + + Wetterkamera + + + Satellitenübertragung + + + Webkamera + + + Sonstiges + + + Sind Sie sicher, dass Sie diese Videoübertragung löschen möchten? + + + Keine Videoübertragungen für diesen Einsatz hinzugefügt. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index edf9c74c..03af7278 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -423,6 +423,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -573,4 +579,85 @@ Source + + Video Feeds + + + Add Video Feed + + + Edit Video Feed + + + Delete Video Feed + + + Name + + + URL + + + Feed Type + + + Feed Format + + + Description + + + Status + + + Location + + + Added By + + + Added On + + + Sort Order + + + Active + + + Inactive + + + Error + + + Drone + + + Fixed Camera + + + Body Cam + + + Traffic Cam + + + Weather Cam + + + Satellite Feed + + + Web Cam + + + Other + + + Are you sure you want to delete this video feed? + + + No video feeds have been added to this call. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 2e8bbc40..6d06e966 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -423,6 +423,12 @@ Texto del protocolo + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + Si desea retransmitir esta llamada marque esta casilla. Esto enviará la llamada a todos los usuarios seleccionados, independientemente de si estaban en el primer envío o no. @@ -573,4 +579,85 @@ Fuente + + Transmisiones de Video + + + Agregar Transmisión de Video + + + Editar Transmisión de Video + + + Eliminar Transmisión de Video + + + Nombre + + + URL + + + Tipo de Transmisión + + + Formato de Transmisión + + + Descripción + + + Estado + + + Ubicación + + + Agregado Por + + + Fecha de Agregado + + + Orden + + + Activo + + + Inactivo + + + Error + + + Dron + + + Cámara Fija + + + Cámara Corporal + + + Cámara de Tráfico + + + Cámara Meteorológica + + + Transmisión Satelital + + + Cámara Web + + + Otro + + + ¿Está seguro de que desea eliminar esta transmisión de video? + + + No se han agregado transmisiones de video a esta llamada. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 8b4ee8f6..215f5d88 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Source + + Flux Vidéo + + + Ajouter un Flux Vidéo + + + Modifier le Flux Vidéo + + + Supprimer le Flux Vidéo + + + Nom + + + URL + + + Type de Flux + + + Format de Flux + + + Description + + + Statut + + + Emplacement + + + Ajouté par + + + Ajouté le + + + Ordre de tri + + + Actif + + + Inactif + + + Erreur + + + Drone + + + Caméra Fixe + + + Caméra Corporelle + + + Caméra de Circulation + + + Caméra Météo + + + Flux Satellite + + + Webcam + + + Autre + + + Êtes-vous sûr de vouloir supprimer ce flux vidéo ? + + + Aucun flux vidéo n'a été ajouté à cet appel. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 12f4f3d9..9e6c0afa 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Fonte + + Feed Video + + + Aggiungi Feed Video + + + Modifica Feed Video + + + Elimina Feed Video + + + Nome + + + URL + + + Tipo di Feed + + + Formato Feed + + + Descrizione + + + Stato + + + Posizione + + + Aggiunto da + + + Aggiunto il + + + Ordinamento + + + Attivo + + + Inattivo + + + Errore + + + Drone + + + Telecamera Fissa + + + Telecamera Corporea + + + Telecamera Traffico + + + Telecamera Meteo + + + Feed Satellitare + + + Webcam + + + Altro + + + Sei sicuro di voler eliminare questo feed video? + + + Nessun feed video è stato aggiunto a questa chiamata. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 56b2c540..49a46cdb 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Źródło + + Transmisje Wideo + + + Dodaj Transmisję Wideo + + + Edytuj Transmisję Wideo + + + Usuń Transmisję Wideo + + + Nazwa + + + URL + + + Typ Transmisji + + + Format Transmisji + + + Opis + + + Status + + + Lokalizacja + + + Dodane przez + + + Data dodania + + + Kolejność + + + Aktywny + + + Nieaktywny + + + Błąd + + + Dron + + + Kamera Stała + + + Kamera Osobista + + + Kamera Drogowa + + + Kamera Pogodowa + + + Transmisja Satelitarna + + + Kamera Internetowa + + + Inne + + + Czy na pewno chcesz usunąć tę transmisję wideo? + + + Nie dodano żadnych transmisji wideo do tego zgłoszenia. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index 99ddf435..ab207c86 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Källa + + Videoflöden + + + Lägg till Videoflöde + + + Redigera Videoflöde + + + Ta bort Videoflöde + + + Namn + + + URL + + + Flödestyp + + + Flödesformat + + + Beskrivning + + + Status + + + Plats + + + Tillagd av + + + Tillagd den + + + Sorteringsordning + + + Aktiv + + + Inaktiv + + + Fel + + + Drönare + + + Fast Kamera + + + Kroppskamera + + + Trafikkamera + + + Väderkamera + + + Satellitflöde + + + Webbkamera + + + Övrigt + + + Är du säker på att du vill ta bort detta videoflöde? + + + Inga videoflöden har lagts till för detta ärende. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index a513d5c1..9c03bc8f 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -374,6 +374,12 @@ Protocol Text + + If checked, entities removed from the dispatch list will receive a cancellation notification for this call. + + + Notify Cancelled + If you want to rebroadcast this call check this box. This will dispatch the call to all users selected, regardless if they were on the first dispatch or not. @@ -524,4 +530,85 @@ Джерело + + Відеотрансляції + + + Додати Відеотрансляцію + + + Редагувати Відеотрансляцію + + + Видалити Відеотрансляцію + + + Назва + + + URL + + + Тип Трансляції + + + Формат Трансляції + + + Опис + + + Статус + + + Місцезнаходження + + + Додано + + + Дата додавання + + + Порядок сортування + + + Активний + + + Неактивний + + + Помилка + + + Дрон + + + Стаціонарна Камера + + + Натільна Камера + + + Камера Дорожнього Руху + + + Метеокамера + + + Супутникова Трансляція + + + Веб-камера + + + Інше + + + Ви впевнені, що хочете видалити цю відеотрансляцію? + + + До цього виклику не додано жодних відеотрансляцій. + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 9241f42f..5ae75e77 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -143,6 +143,11 @@ public enum AuditLogTypes CalendarAdminCheckInPerformed, // Log operations LogCreated, - LogDeleted + LogDeleted, + // Communication Test operations + CommunicationTestCreated, + CommunicationTestUpdated, + CommunicationTestDeleted, + CommunicationTestRunStarted } } diff --git a/Core/Resgrid.Model/Billing/Api/GetActivePaymentProviderResult.cs b/Core/Resgrid.Model/Billing/Api/GetActivePaymentProviderResult.cs new file mode 100644 index 00000000..a14b81c2 --- /dev/null +++ b/Core/Resgrid.Model/Billing/Api/GetActivePaymentProviderResult.cs @@ -0,0 +1,12 @@ +namespace Resgrid.Model.Billing.Api; + +public class GetActivePaymentProviderResult : BillingApiResponseBase +{ + public GetActivePaymentProviderData Data { get; set; } +} + +public class GetActivePaymentProviderData +{ + public int ActiveProvider { get; set; } + public string ProviderName { get; set; } +} diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index 7a843eb3..0bf24acd 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -139,6 +139,9 @@ public class Call : IEntity [ProtoMember(32)] public virtual ICollection Contacts { get; set; } + [ProtoMember(33)] + public virtual ICollection VideoFeeds { get; set; } + public string ContactName { get; set; } public string ContactNumber { get; set; } @@ -199,7 +202,7 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "ReportingUser", "ClosedByUser", "Department", "Dispatches", "Attachments", "CallNotes", "GroupDispatches", "UnitDispatches", "RoleDispatches", "Protocols", "ShortenedAudioUrl", "ShortenedCallUrl", "CallPriority", "PreviousDispatchCount", "References", "Contacts" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "ReportingUser", "ClosedByUser", "Department", "Dispatches", "Attachments", "CallNotes", "GroupDispatches", "UnitDispatches", "RoleDispatches", "Protocols", "ShortenedAudioUrl", "ShortenedCallUrl", "CallPriority", "PreviousDispatchCount", "References", "Contacts", "VideoFeeds" }; public string GetIdentifier() { diff --git a/Core/Resgrid.Model/CallVideoFeed.cs b/Core/Resgrid.Model/CallVideoFeed.cs new file mode 100644 index 00000000..23c10a10 --- /dev/null +++ b/Core/Resgrid.Model/CallVideoFeed.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; +using Resgrid.Framework; + +namespace Resgrid.Model +{ + [Table("CallVideoFeeds")] + public class CallVideoFeed : IEntity + { + public string CallVideoFeedId { get; set; } + + public int CallId { get; set; } + + public int DepartmentId { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string Url { get; set; } + + public int? FeedType { get; set; } + + public int? FeedFormat { get; set; } + + public string Description { get; set; } + + public int Status { get; set; } + + [DecimalPrecision(10, 7)] + public decimal? Latitude { get; set; } + + [DecimalPrecision(10, 7)] + public decimal? Longitude { get; set; } + + public string AddedByUserId { get; set; } + + public DateTime AddedOn { get; set; } + + public DateTime? UpdatedOn { get; set; } + + public int SortOrder { get; set; } + + public bool IsDeleted { get; set; } + + public string DeletedByUserId { get; set; } + + public DateTime? DeletedOn { get; set; } + + public bool IsFlagged { get; set; } + + public string FlaggedReason { get; set; } + + public string FlaggedByUserId { get; set; } + + public DateTime? FlaggedOn { get; set; } + + [ForeignKey("CallId")] + public virtual Call Call { get; set; } + + [NotMapped] + public string TableName => "CallVideoFeeds"; + + [NotMapped] + public string IdName => "CallVideoFeedId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CallVideoFeedId; } + set { CallVideoFeedId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Call" }; + } +} diff --git a/Core/Resgrid.Model/CallVideoFeedFormats.cs b/Core/Resgrid.Model/CallVideoFeedFormats.cs new file mode 100644 index 00000000..c3261ca0 --- /dev/null +++ b/Core/Resgrid.Model/CallVideoFeedFormats.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Model +{ + public enum CallVideoFeedFormats + { + RTSP = 0, + HLS = 1, + MJPEG = 2, + YouTubeLive = 3, + WebRTC = 4, + DASH = 5, + Embed = 6, + Other = 99 + } +} diff --git a/Core/Resgrid.Model/CallVideoFeedStatuses.cs b/Core/Resgrid.Model/CallVideoFeedStatuses.cs new file mode 100644 index 00000000..a5ebd910 --- /dev/null +++ b/Core/Resgrid.Model/CallVideoFeedStatuses.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model +{ + public enum CallVideoFeedStatuses + { + Active = 0, + Inactive = 1, + Error = 2 + } +} diff --git a/Core/Resgrid.Model/CallVideoFeedTypes.cs b/Core/Resgrid.Model/CallVideoFeedTypes.cs new file mode 100644 index 00000000..59b34ada --- /dev/null +++ b/Core/Resgrid.Model/CallVideoFeedTypes.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Model +{ + public enum CallVideoFeedTypes + { + Drone = 0, + FixedCamera = 1, + BodyCam = 2, + TrafficCam = 3, + WeatherCam = 4, + SatelliteFeed = 5, + WebCam = 6, + Other = 99 + } +} diff --git a/Core/Resgrid.Model/CommunicationTest.cs b/Core/Resgrid.Model/CommunicationTest.cs new file mode 100644 index 00000000..e6b2a842 --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTest.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("CommunicationTests")] + public class CommunicationTest : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid CommunicationTestId { get; set; } + + [Required] + [ForeignKey("Department"), DatabaseGenerated(DatabaseGeneratedOption.None)] + public int DepartmentId { get; set; } + + public virtual Department Department { get; set; } + + [Required] + [MaxLength(500)] + public string Name { get; set; } + + [MaxLength(4000)] + public string Description { get; set; } + + public int ScheduleType { get; set; } + + public bool Sunday { get; set; } + + public bool Monday { get; set; } + + public bool Tuesday { get; set; } + + public bool Wednesday { get; set; } + + public bool Thursday { get; set; } + + public bool Friday { get; set; } + + public bool Saturday { get; set; } + + public int? DayOfMonth { get; set; } + + [MaxLength(50)] + public string Time { get; set; } + + public bool TestSms { get; set; } + + public bool TestEmail { get; set; } + + public bool TestVoice { get; set; } + + public bool TestPush { get; set; } + + public bool Active { get; set; } + + [Required] + [MaxLength(128)] + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? UpdatedOn { get; set; } + + public int ResponseWindowMinutes { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CommunicationTestId == Guid.Empty ? null : (object)CommunicationTestId.ToString(); } + set { CommunicationTestId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "CommunicationTests"; + + [NotMapped] + public string IdName => "CommunicationTestId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Department" }; + } +} diff --git a/Core/Resgrid.Model/CommunicationTestChannel.cs b/Core/Resgrid.Model/CommunicationTestChannel.cs new file mode 100644 index 00000000..36b2dca8 --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTestChannel.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Model +{ + public enum CommunicationTestChannel + { + Sms = 0, + Email = 1, + Voice = 2, + Push = 3 + } +} diff --git a/Core/Resgrid.Model/CommunicationTestResult.cs b/Core/Resgrid.Model/CommunicationTestResult.cs new file mode 100644 index 00000000..245b8640 --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTestResult.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("CommunicationTestResults")] + public class CommunicationTestResult : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid CommunicationTestResultId { get; set; } + + [Required] + public Guid CommunicationTestRunId { get; set; } + + [ForeignKey("CommunicationTestRunId")] + public virtual CommunicationTestRun CommunicationTestRun { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + [MaxLength(128)] + public string UserId { get; set; } + + public int Channel { get; set; } + + [MaxLength(500)] + public string ContactValue { get; set; } + + [MaxLength(200)] + public string ContactCarrier { get; set; } + + public int VerificationStatus { get; set; } + + public bool SendAttempted { get; set; } + + public bool SendSucceeded { get; set; } + + public DateTime? SentOn { get; set; } + + public bool Responded { get; set; } + + public DateTime? RespondedOn { get; set; } + + [MaxLength(128)] + public string ResponseToken { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CommunicationTestResultId == Guid.Empty ? null : (object)CommunicationTestResultId.ToString(); } + set { CommunicationTestResultId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "CommunicationTestResults"; + + [NotMapped] + public string IdName => "CommunicationTestResultId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "CommunicationTestRun" }; + } +} diff --git a/Core/Resgrid.Model/CommunicationTestRun.cs b/Core/Resgrid.Model/CommunicationTestRun.cs new file mode 100644 index 00000000..b098e9f0 --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTestRun.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + [Table("CommunicationTestRuns")] + public class CommunicationTestRun : IEntity + { + [Key] + [Required] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid CommunicationTestRunId { get; set; } + + [Required] + public Guid CommunicationTestId { get; set; } + + [ForeignKey("CommunicationTestId")] + public virtual CommunicationTest CommunicationTest { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [MaxLength(128)] + public string InitiatedByUserId { get; set; } + + public DateTime StartedOn { get; set; } + + public DateTime? CompletedOn { get; set; } + + public int Status { get; set; } + + [Required] + [MaxLength(20)] + public string RunCode { get; set; } + + public int TotalUsersTested { get; set; } + + public int TotalResponses { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return CommunicationTestRunId == Guid.Empty ? null : (object)CommunicationTestRunId.ToString(); } + set { CommunicationTestRunId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); } + } + + [NotMapped] + public string TableName => "CommunicationTestRuns"; + + [NotMapped] + public string IdName => "CommunicationTestRunId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "CommunicationTest" }; + } +} diff --git a/Core/Resgrid.Model/CommunicationTestRunStatus.cs b/Core/Resgrid.Model/CommunicationTestRunStatus.cs new file mode 100644 index 00000000..7c33e093 --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTestRunStatus.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Model +{ + public enum CommunicationTestRunStatus + { + Pending = 0, + Running = 1, + AwaitingResponses = 2, + Completed = 3, + Failed = 4 + } +} diff --git a/Core/Resgrid.Model/CommunicationTestScheduleType.cs b/Core/Resgrid.Model/CommunicationTestScheduleType.cs new file mode 100644 index 00000000..28d5854d --- /dev/null +++ b/Core/Resgrid.Model/CommunicationTestScheduleType.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Model +{ + public enum CommunicationTestScheduleType + { + OnDemand = 0, + Weekly = 1, + Monthly = 2 + } +} diff --git a/Core/Resgrid.Model/Repositories/ICallVideoFeedRepository.cs b/Core/Resgrid.Model/Repositories/ICallVideoFeedRepository.cs new file mode 100644 index 00000000..ca8b917c --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICallVideoFeedRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICallVideoFeedRepository : IRepository + { + Task> GetByCallIdAsync(int callId); + Task> GetByDepartmentIdAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Repositories/ICommunicationTestRepository.cs b/Core/Resgrid.Model/Repositories/ICommunicationTestRepository.cs new file mode 100644 index 00000000..cfc722d2 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICommunicationTestRepository.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICommunicationTestRepository : IRepository + { + Task> GetActiveTestsForScheduleTypeAsync(int scheduleType); + } +} diff --git a/Core/Resgrid.Model/Repositories/ICommunicationTestResultRepository.cs b/Core/Resgrid.Model/Repositories/ICommunicationTestResultRepository.cs new file mode 100644 index 00000000..8538ddd9 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICommunicationTestResultRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICommunicationTestResultRepository : IRepository + { + Task> GetResultsByRunIdAsync(Guid communicationTestRunId); + Task GetResultByResponseTokenAsync(string responseToken); + } +} diff --git a/Core/Resgrid.Model/Repositories/ICommunicationTestRunRepository.cs b/Core/Resgrid.Model/Repositories/ICommunicationTestRunRepository.cs new file mode 100644 index 00000000..d7692af3 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICommunicationTestRunRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface ICommunicationTestRunRepository : IRepository + { + Task> GetRunsByTestIdAsync(Guid communicationTestId); + Task GetRunByRunCodeAsync(string runCode); + Task> GetOpenRunsAsync(); + } +} diff --git a/Core/Resgrid.Model/Services/ICallsService.cs b/Core/Resgrid.Model/Services/ICallsService.cs index b47154b2..ae82eadd 100644 --- a/Core/Resgrid.Model/Services/ICallsService.cs +++ b/Core/Resgrid.Model/Services/ICallsService.cs @@ -419,7 +419,7 @@ Task ClearGroupForDispatchesAsync(int departmentGroupId, /// if set to true [get protocols]. /// if set to true [get references]. /// Task<Call>. - Task PopulateCallData(Call call, bool getDispatches, bool getAttachments, bool getNotes, bool getGroupDispatches, bool getUnitDispatches, bool getRoleDispatches, bool getProtocols, bool getReferences, bool getContacts); + Task PopulateCallData(Call call, bool getDispatches, bool getAttachments, bool getNotes, bool getGroupDispatches, bool getUnitDispatches, bool getRoleDispatches, bool getProtocols, bool getReferences, bool getContacts, bool getVideoFeeds = false); Task> GetAllNonDispatchedScheduledCallsWithinDateRange(DateTime startDate, DateTime endDate); @@ -433,5 +433,13 @@ Task ClearGroupForDispatchesAsync(int departmentGroupId, Task> GetCallsByContactIdAsync(string contactId, int departmentId); Task DeleteCallContactsAsync(int callId, CancellationToken cancellationToken = default(CancellationToken)); + + Task SaveCallVideoFeedAsync(CallVideoFeed feed, CancellationToken cancellationToken = default(CancellationToken)); + + Task GetCallVideoFeedByIdAsync(string callVideoFeedId); + + Task> GetCallVideoFeedsByCallIdAsync(int callId); + + Task DeleteCallVideoFeedAsync(CallVideoFeed feed, string deletedByUserId, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/ICommunicationService.cs b/Core/Resgrid.Model/Services/ICommunicationService.cs index a758e599..a0bfe6f2 100644 --- a/Core/Resgrid.Model/Services/ICommunicationService.cs +++ b/Core/Resgrid.Model/Services/ICommunicationService.cs @@ -45,6 +45,18 @@ Task SendCallAsync(Call call, CallDispatch dispatch, string departmentNumb Task SendUnitCallAsync(Call call, CallDispatchUnit dispatch, string departmentNumber, string address = null); + /// + /// Sends a cancellation notification for a call dispatch to a user. + /// + Task SendCancelCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, + UserProfile profile = null, string address = null); + + /// + /// Sends a cancellation notification for a call dispatch to a unit. + /// + Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string departmentNumber, + string address = null); + /// /// Sends the notification asynchronous. /// diff --git a/Core/Resgrid.Model/Services/ICommunicationTestService.cs b/Core/Resgrid.Model/Services/ICommunicationTestService.cs new file mode 100644 index 00000000..49aa1cb3 --- /dev/null +++ b/Core/Resgrid.Model/Services/ICommunicationTestService.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface ICommunicationTestService + { + Task> GetTestsByDepartmentIdAsync(int departmentId); + Task GetTestByIdAsync(Guid communicationTestId); + Task CanCreateScheduledTestAsync(int departmentId, int scheduleType, Guid? excludeTestId = null); + Task SaveTestAsync(CommunicationTest test, CancellationToken cancellationToken = default); + Task DeleteTestAsync(Guid communicationTestId, CancellationToken cancellationToken = default); + + Task CanStartOnDemandRunAsync(Guid communicationTestId); + Task StartTestRunAsync(Guid communicationTestId, int departmentId, string initiatedByUserId, CancellationToken cancellationToken = default); + Task> GetRunsByTestIdAsync(Guid communicationTestId); + Task GetRunByIdAsync(Guid communicationTestRunId); + Task> GetRunsByDepartmentIdAsync(int departmentId); + + Task> GetResultsByRunIdAsync(Guid communicationTestRunId); + + Task RecordSmsResponseAsync(string runCode, string fromPhoneNumber); + Task RecordEmailResponseAsync(string responseToken); + Task RecordVoiceResponseAsync(string responseToken); + Task RecordPushResponseAsync(string responseToken); + + Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default); + Task CompleteExpiredRunsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IEmailService.cs b/Core/Resgrid.Model/Services/IEmailService.cs index 45f7d92c..c4d60960 100644 --- a/Core/Resgrid.Model/Services/IEmailService.cs +++ b/Core/Resgrid.Model/Services/IEmailService.cs @@ -75,6 +75,11 @@ Task SendPasswordResetEmail(string emailAddress, string name, string userN /// Task<System.Boolean>. Task SendCallAsync(Call call, CallDispatch dispatch, UserProfile profile = null); + /// + /// Sends a cancellation email for a call dispatch. + /// + Task SendCancelCallAsync(Call call, CallDispatch dispatch, UserProfile profile = null); + /// /// Sends the trouble alert. /// diff --git a/Core/Resgrid.Model/Services/ISmsService.cs b/Core/Resgrid.Model/Services/ISmsService.cs index 291c58c6..c976a962 100644 --- a/Core/Resgrid.Model/Services/ISmsService.cs +++ b/Core/Resgrid.Model/Services/ISmsService.cs @@ -32,6 +32,12 @@ Task SendMessageAsync(Message message, string departmentNumber, int depart Task SendCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, UserProfile profile = null, string address = null, Payment payment = null); + /// + /// Sends a cancellation SMS for a call dispatch. + /// + Task SendCancelCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, + UserProfile profile = null, string address = null, Payment payment = null); + /// /// Sends the trouble alert. /// diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index 44649901..32d2e92b 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -42,6 +42,7 @@ public class CallsService : ICallsService private readonly ICallReferencesRepository _callReferencesRepository; private readonly ICallContactsRepository _callContactsRepository; private readonly IIndoorMapService _indoorMapService; + private readonly ICallVideoFeedRepository _callVideoFeedRepository; public CallsService(ICallsRepository callsRepository, ICommunicationService communicationService, ICallDispatchesRepository callDispatchesRepository, ICallTypesRepository callTypesRepository, ICallEmailFactory callEmailFactory, @@ -51,7 +52,7 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm IDepartmentCallPriorityRepository departmentCallPriorityRepository, IShortenUrlProvider shortenUrlProvider, ICallProtocolsRepository callProtocolsRepository, IGeoLocationProvider geoLocationProvider, IDepartmentsService departmentsService, ICallReferencesRepository callReferencesRepository, ICallContactsRepository callContactsRepository, - IIndoorMapService indoorMapService) + IIndoorMapService indoorMapService, ICallVideoFeedRepository callVideoFeedRepository) { _callsRepository = callsRepository; _communicationService = communicationService; @@ -72,6 +73,7 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm _callReferencesRepository = callReferencesRepository; _callContactsRepository = callContactsRepository; _indoorMapService = indoorMapService; + _callVideoFeedRepository = callVideoFeedRepository; } public async Task SaveCallAsync(Call call, CancellationToken cancellationToken = default(CancellationToken)) @@ -498,7 +500,7 @@ public async Task> GetActiveCallPrioritiesForDepart return activePriorities; } - public async Task PopulateCallData(Call call, bool getDispatches, bool getAttachments, bool getNotes, bool getGroupDispatches, bool getUnitDispatches, bool getRoleDispatches, bool getProtocols, bool getReferences, bool getContacts) + public async Task PopulateCallData(Call call, bool getDispatches, bool getAttachments, bool getNotes, bool getGroupDispatches, bool getUnitDispatches, bool getRoleDispatches, bool getProtocols, bool getReferences, bool getContacts, bool getVideoFeeds = false) { if (call == null) return null; @@ -585,6 +587,15 @@ public async Task PopulateCallData(Call call, bool getDispatches, bool get else call.Contacts = new List(); } + if (getVideoFeeds && call.VideoFeeds == null) + { + var items = await _callVideoFeedRepository.GetByCallIdAsync(call.CallId); + + if (items != null) + call.VideoFeeds = items.OrderBy(f => f.SortOrder).ToList(); + else + call.VideoFeeds = new List(); + } return call; } @@ -987,5 +998,38 @@ public async Task> GetCallsByContactIdAsync(string contactId, int dep return new List(); } + + public async Task SaveCallVideoFeedAsync(CallVideoFeed feed, CancellationToken cancellationToken = default(CancellationToken)) + { + var saved = await _callVideoFeedRepository.SaveOrUpdateAsync(feed, cancellationToken); + return saved; + } + + public async Task GetCallVideoFeedByIdAsync(string callVideoFeedId) + { + var feed = await _callVideoFeedRepository.GetByIdAsync(callVideoFeedId); + return feed; + } + + public async Task> GetCallVideoFeedsByCallIdAsync(int callId) + { + var feeds = await _callVideoFeedRepository.GetByCallIdAsync(callId); + + if (feeds != null && feeds.Any()) + return feeds.OrderBy(f => f.SortOrder).ToList(); + + return new List(); + } + + public async Task DeleteCallVideoFeedAsync(CallVideoFeed feed, string deletedByUserId, CancellationToken cancellationToken = default(CancellationToken)) + { + feed.IsDeleted = true; + feed.DeletedByUserId = deletedByUserId; + feed.DeletedOn = DateTime.UtcNow; + + await _callVideoFeedRepository.SaveOrUpdateAsync(feed, cancellationToken); + + return true; + } } } diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index ab732bc6..19bdfac3 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -177,7 +177,9 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d spc.SubTitle = call.NatureOfCall.Truncate(200); } - if (!String.IsNullOrWhiteSpace(spc.SubTitle)) + if (String.IsNullOrWhiteSpace(spc.SubTitle)) + spc.SubTitle = String.Empty; + else spc.SubTitle = StringHelpers.StripHtmlTagsCharArray(spc.SubTitle); spc.SubTitle = Regex.Replace(spc.SubTitle, @"((http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)", ""); @@ -312,6 +314,204 @@ public async Task SendUnitCallAsync(Call call, CallDispatchUnit dispatch, return true; } + public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, UserProfile profile = null, string address = null) + { + if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) + return false; + + if (!await CanSendToUser(dispatch.UserId, departmentId)) + return false; + + if (profile == null) + profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId); + + // Send a Push Notification + if (profile == null || profile.SendPush) + { + try + { + var spc = new StandardPushCall(); + spc.CallId = call.CallId; + spc.Title = string.Format("Dispatch Cancelled - {0}", call.Name); + spc.Priority = call.Priority; + spc.ActiveCallCount = 1; + spc.DepartmentId = departmentId; + spc.DepartmentCode = call.Department?.Code; + + if (call.CallPriority != null && !String.IsNullOrWhiteSpace(call.CallPriority.Color)) + { + spc.Color = call.CallPriority.Color; + } + else + { + spc.Color = "#000000"; + } + + string subTitle = String.Empty; + + if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address)) + { + subTitle = call.Address; + } + else if (!String.IsNullOrWhiteSpace(address)) + { + subTitle = address; + } + else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1) + { + try + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + { + subTitle = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + } + catch + { } + } + + if (!string.IsNullOrEmpty(subTitle)) + { + spc.SubTitle = subTitle.Truncate(200); + } + else + { + if (!string.IsNullOrEmpty(call.NatureOfCall)) + spc.SubTitle = call.NatureOfCall.Truncate(200); + } + + if (String.IsNullOrWhiteSpace(spc.SubTitle)) + spc.SubTitle = String.Empty; + else + spc.SubTitle = StringHelpers.StripHtmlTagsCharArray(spc.SubTitle); + + spc.SubTitle = Regex.Replace(spc.SubTitle, @"((http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)", ""); + + spc.Title = StringHelpers.StripHtmlTagsCharArray(spc.Title); + spc.Title = Regex.Replace(spc.Title, @"((http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)", ""); + + spc.Title = spc.Title.Replace(char.Parse("/"), char.Parse(" ")); + spc.SubTitle = spc.SubTitle.Replace(char.Parse("/"), char.Parse(" ")); + + await _pushService.PushCall(spc, dispatch.UserId, profile, call.CallPriority); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + + // Send an SMS Message + if (profile == null || profile.SendSms) + { + if (profile == null || profile.MobileNumberVerified.IsContactMethodAllowedForSending()) + { + try + { + var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId); + await _smsService.SendCancelCallAsync(call, dispatch, departmentNumber, departmentId, profile, call.Address, payment); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } + + // Send an Email + if (profile == null || profile.SendEmail) + { + if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) + { + try + { + await _emailService.SendCancelCallAsync(call, dispatch, profile); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } + + // No voice call for cancellation + + return true; + } + + public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string departmentNumber, string address = null) + { + var spc = new StandardPushCall(); + spc.CallId = call.CallId; + spc.Title = string.Format("Dispatch Cancelled - {0}", call.Name); + spc.Priority = call.Priority; + spc.ActiveCallCount = 1; + spc.DepartmentId = call.DepartmentId; + spc.DepartmentCode = call.Department?.Code; + + if (call.CallPriority != null && !String.IsNullOrWhiteSpace(call.CallPriority.Color)) + { + spc.Color = call.CallPriority.Color; + } + else + { + spc.Color = "#000000"; + } + + string subTitle = String.Empty; + + if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address)) + { + subTitle = call.Address; + } + else if (!String.IsNullOrWhiteSpace(address)) + { + subTitle = address; + } + else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1) + { + try + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + { + subTitle = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + } + catch + { } + } + + if (!string.IsNullOrEmpty(subTitle)) + { + spc.SubTitle = subTitle.Truncate(200); + } + else + { + if (!string.IsNullOrEmpty(call.NatureOfCall)) + spc.SubTitle = call.NatureOfCall.Truncate(200); + } + + if (!String.IsNullOrWhiteSpace(spc.SubTitle)) + spc.SubTitle = StringHelpers.StripHtmlTagsCharArray(spc.SubTitle); + + spc.Title = StringHelpers.StripHtmlTagsCharArray(spc.Title); + + try + { + await _pushService.PushCallUnit(spc, dispatch.UnitId, call.CallPriority); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + return true; + } + public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs new file mode 100644 index 00000000..0fa325bb --- /dev/null +++ b/Core/Resgrid.Services/CommunicationTestService.cs @@ -0,0 +1,437 @@ +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.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class CommunicationTestService : ICommunicationTestService + { + private readonly ICommunicationTestRepository _communicationTestRepository; + private readonly ICommunicationTestRunRepository _communicationTestRunRepository; + private readonly ICommunicationTestResultRepository _communicationTestResultRepository; + private readonly IDepartmentsService _departmentsService; + private readonly IUserProfileService _userProfileService; + + public CommunicationTestService( + ICommunicationTestRepository communicationTestRepository, + ICommunicationTestRunRepository communicationTestRunRepository, + ICommunicationTestResultRepository communicationTestResultRepository, + IDepartmentsService departmentsService, + IUserProfileService userProfileService) + { + _communicationTestRepository = communicationTestRepository; + _communicationTestRunRepository = communicationTestRunRepository; + _communicationTestResultRepository = communicationTestResultRepository; + _departmentsService = departmentsService; + _userProfileService = userProfileService; + } + + public async Task> GetTestsByDepartmentIdAsync(int departmentId) + { + return await _communicationTestRepository.GetAllByDepartmentIdAsync(departmentId); + } + + public async Task GetTestByIdAsync(Guid communicationTestId) + { + return await _communicationTestRepository.GetByIdAsync(communicationTestId); + } + + public async Task CanCreateScheduledTestAsync(int departmentId, int scheduleType, Guid? excludeTestId = null) + { + if (scheduleType == (int)CommunicationTestScheduleType.OnDemand) + return true; + + var existing = await _communicationTestRepository.GetAllByDepartmentIdAsync(departmentId); + if (existing == null) + return true; + + return !existing.Any(t => + t.ScheduleType == scheduleType && + (!excludeTestId.HasValue || t.CommunicationTestId != excludeTestId.Value)); + } + + public async Task SaveTestAsync(CommunicationTest test, CancellationToken cancellationToken = default) + { + return await _communicationTestRepository.SaveOrUpdateAsync(test, cancellationToken, true); + } + + public async Task DeleteTestAsync(Guid communicationTestId, CancellationToken cancellationToken = default) + { + var test = await _communicationTestRepository.GetByIdAsync(communicationTestId); + if (test == null) + return false; + + return await _communicationTestRepository.DeleteAsync(test, cancellationToken); + } + + public async Task CanStartOnDemandRunAsync(Guid communicationTestId) + { + var existingRuns = await _communicationTestRunRepository.GetRunsByTestIdAsync(communicationTestId); + if (existingRuns == null || !existingRuns.Any()) + return true; + + var mostRecent = existingRuns.OrderByDescending(r => r.StartedOn).FirstOrDefault(); + if (mostRecent == null) + return true; + + return mostRecent.StartedOn.AddHours(48) <= DateTime.UtcNow; + } + + public async Task StartTestRunAsync(Guid communicationTestId, int departmentId, string initiatedByUserId, CancellationToken cancellationToken = default) + { + var test = await _communicationTestRepository.GetByIdAsync(communicationTestId); + if (test == null) + return null; + + // Rate limit: on-demand tests can only run once every 48 hours + if (test.ScheduleType == (int)CommunicationTestScheduleType.OnDemand) + { + if (!await CanStartOnDemandRunAsync(communicationTestId)) + return null; + } + + var runCode = GenerateRunCode(); + + var run = new CommunicationTestRun + { + CommunicationTestId = communicationTestId, + DepartmentId = departmentId, + InitiatedByUserId = initiatedByUserId, + StartedOn = DateTime.UtcNow, + Status = (int)CommunicationTestRunStatus.Running, + RunCode = runCode, + TotalUsersTested = 0, + TotalResponses = 0 + }; + + run = await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true); + + var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId); + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + + int totalUsersTested = 0; + + foreach (var member in members) + { + profiles.TryGetValue(member.UserId, out var profile); + bool userHasResults = false; + + if (test.TestEmail) + { + var emailVerified = profile?.EmailVerified; + var result = new CommunicationTestResult + { + CommunicationTestRunId = run.CommunicationTestRunId, + DepartmentId = departmentId, + UserId = member.UserId, + Channel = (int)CommunicationTestChannel.Email, + ContactValue = profile?.MembershipEmail, + VerificationStatus = (int)emailVerified.ToVerificationStatus(), + SendAttempted = emailVerified.IsContactMethodAllowedForSending(), + SendSucceeded = false, + Responded = false, + ResponseToken = Guid.NewGuid().ToString("N") + }; + + if (result.SendAttempted && !string.IsNullOrWhiteSpace(result.ContactValue)) + { + result.SendSucceeded = true; + result.SentOn = DateTime.UtcNow; + } + + await _communicationTestResultRepository.SaveOrUpdateAsync(result, cancellationToken, true); + userHasResults = true; + } + + if (test.TestSms) + { + var mobileVerified = profile?.MobileNumberVerified; + var carrierName = ""; + if (profile != null && profile.MobileCarrier > 0) + carrierName = ((MobileCarriers)profile.MobileCarrier).GetDescription(); + + var result = new CommunicationTestResult + { + CommunicationTestRunId = run.CommunicationTestRunId, + DepartmentId = departmentId, + UserId = member.UserId, + Channel = (int)CommunicationTestChannel.Sms, + ContactValue = profile?.GetPhoneNumber(), + ContactCarrier = carrierName, + VerificationStatus = (int)mobileVerified.ToVerificationStatus(), + SendAttempted = mobileVerified.IsContactMethodAllowedForSending(), + SendSucceeded = false, + Responded = false, + ResponseToken = Guid.NewGuid().ToString("N") + }; + + if (result.SendAttempted && !string.IsNullOrWhiteSpace(result.ContactValue)) + { + result.SendSucceeded = true; + result.SentOn = DateTime.UtcNow; + } + + await _communicationTestResultRepository.SaveOrUpdateAsync(result, cancellationToken, true); + userHasResults = true; + } + + if (test.TestVoice) + { + var mobileVerified = profile?.MobileNumberVerified; + var result = new CommunicationTestResult + { + CommunicationTestRunId = run.CommunicationTestRunId, + DepartmentId = departmentId, + UserId = member.UserId, + Channel = (int)CommunicationTestChannel.Voice, + ContactValue = profile?.GetPhoneNumber(), + VerificationStatus = (int)mobileVerified.ToVerificationStatus(), + SendAttempted = mobileVerified.IsContactMethodAllowedForSending(), + SendSucceeded = false, + Responded = false, + ResponseToken = Guid.NewGuid().ToString("N") + }; + + if (result.SendAttempted && !string.IsNullOrWhiteSpace(result.ContactValue)) + { + result.SendSucceeded = true; + result.SentOn = DateTime.UtcNow; + } + + await _communicationTestResultRepository.SaveOrUpdateAsync(result, cancellationToken, true); + userHasResults = true; + } + + if (test.TestPush) + { + var result = new CommunicationTestResult + { + CommunicationTestRunId = run.CommunicationTestRunId, + DepartmentId = departmentId, + UserId = member.UserId, + Channel = (int)CommunicationTestChannel.Push, + VerificationStatus = (int)ContactVerificationStatus.Verified, + SendAttempted = true, + SendSucceeded = true, + SentOn = DateTime.UtcNow, + Responded = false, + ResponseToken = Guid.NewGuid().ToString("N") + }; + + await _communicationTestResultRepository.SaveOrUpdateAsync(result, cancellationToken, true); + userHasResults = true; + } + + if (userHasResults) + totalUsersTested++; + } + + run.TotalUsersTested = totalUsersTested; + run.Status = (int)CommunicationTestRunStatus.AwaitingResponses; + run = await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true); + + return run; + } + + public async Task> GetRunsByTestIdAsync(Guid communicationTestId) + { + return await _communicationTestRunRepository.GetRunsByTestIdAsync(communicationTestId); + } + + public async Task GetRunByIdAsync(Guid communicationTestRunId) + { + return await _communicationTestRunRepository.GetByIdAsync(communicationTestRunId); + } + + public async Task> GetRunsByDepartmentIdAsync(int departmentId) + { + return await _communicationTestRunRepository.GetAllByDepartmentIdAsync(departmentId); + } + + public async Task> GetResultsByRunIdAsync(Guid communicationTestRunId) + { + return await _communicationTestResultRepository.GetResultsByRunIdAsync(communicationTestRunId); + } + + public async Task RecordSmsResponseAsync(string runCode, string fromPhoneNumber) + { + var run = await _communicationTestRunRepository.GetRunByRunCodeAsync(runCode); + if (run == null || run.Status == (int)CommunicationTestRunStatus.Completed || run.Status == (int)CommunicationTestRunStatus.Failed) + return false; + + var results = await _communicationTestResultRepository.GetResultsByRunIdAsync(run.CommunicationTestRunId); + var cleanPhone = fromPhoneNumber.Replace("+", "").Replace("-", "").Replace(" ", "").Replace("(", "").Replace(")", ""); + + var matchingResult = results.FirstOrDefault(r => + r.Channel == (int)CommunicationTestChannel.Sms && + !r.Responded && + r.ContactValue != null && + r.ContactValue.Replace("+", "").Replace("-", "").Replace(" ", "").Replace("(", "").Replace(")", "") == cleanPhone); + + if (matchingResult == null) + return false; + + matchingResult.Responded = true; + matchingResult.RespondedOn = DateTime.UtcNow; + await _communicationTestResultRepository.SaveOrUpdateAsync(matchingResult, CancellationToken.None, true); + + await UpdateRunResponseCountAsync(run); + return true; + } + + public async Task RecordEmailResponseAsync(string responseToken) + { + return await RecordResponseByTokenAsync(responseToken, CommunicationTestChannel.Email); + } + + public async Task RecordVoiceResponseAsync(string responseToken) + { + return await RecordResponseByTokenAsync(responseToken, CommunicationTestChannel.Voice); + } + + public async Task RecordPushResponseAsync(string responseToken) + { + return await RecordResponseByTokenAsync(responseToken, CommunicationTestChannel.Push); + } + + public async Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + + // Process weekly tests + var weeklyTests = await _communicationTestRepository.GetActiveTestsForScheduleTypeAsync((int)CommunicationTestScheduleType.Weekly); + if (weeklyTests != null) + { + foreach (var test in weeklyTests) + { + if (ShouldRunWeeklyTest(test, now) && await HasPassedFirstEligiblePeriodAsync(test)) + { + await StartTestRunAsync(test.CommunicationTestId, test.DepartmentId, test.CreatedByUserId, cancellationToken); + } + } + } + + // Process monthly tests + var monthlyTests = await _communicationTestRepository.GetActiveTestsForScheduleTypeAsync((int)CommunicationTestScheduleType.Monthly); + if (monthlyTests != null) + { + foreach (var test in monthlyTests) + { + if (ShouldRunMonthlyTest(test, now) && await HasPassedFirstEligiblePeriodAsync(test)) + { + await StartTestRunAsync(test.CommunicationTestId, test.DepartmentId, test.CreatedByUserId, cancellationToken); + } + } + } + } + + public async Task CompleteExpiredRunsAsync(CancellationToken cancellationToken = default) + { + var openRuns = await _communicationTestRunRepository.GetOpenRunsAsync(); + if (openRuns == null) + return; + + foreach (var run in openRuns) + { + var test = await _communicationTestRepository.GetByIdAsync(run.CommunicationTestId); + if (test == null) + continue; + + var windowMinutes = test.ResponseWindowMinutes > 0 ? test.ResponseWindowMinutes : 60; + if (run.StartedOn.AddMinutes(windowMinutes) <= DateTime.UtcNow) + { + run.Status = (int)CommunicationTestRunStatus.Completed; + run.CompletedOn = DateTime.UtcNow; + await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true); + } + } + } + + private async Task RecordResponseByTokenAsync(string responseToken, CommunicationTestChannel channel) + { + var result = await _communicationTestResultRepository.GetResultByResponseTokenAsync(responseToken); + if (result == null || result.Responded || result.Channel != (int)channel) + return false; + + result.Responded = true; + result.RespondedOn = DateTime.UtcNow; + await _communicationTestResultRepository.SaveOrUpdateAsync(result, CancellationToken.None, true); + + var run = await _communicationTestRunRepository.GetByIdAsync(result.CommunicationTestRunId); + if (run != null) + await UpdateRunResponseCountAsync(run); + + return true; + } + + private async Task UpdateRunResponseCountAsync(CommunicationTestRun run) + { + var allResults = await _communicationTestResultRepository.GetResultsByRunIdAsync(run.CommunicationTestRunId); + var respondedUsers = allResults.Where(r => r.Responded).Select(r => r.UserId).Distinct().Count(); + run.TotalResponses = respondedUsers; + await _communicationTestRunRepository.SaveOrUpdateAsync(run, CancellationToken.None, true); + } + + private static string GenerateRunCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var random = new Random(); + var code = new char[4]; + for (int i = 0; i < 4; i++) + code[i] = chars[random.Next(chars.Length)]; + return "CT-" + new string(code); + } + + private static bool ShouldRunWeeklyTest(CommunicationTest test, DateTime utcNow) + { + switch (utcNow.DayOfWeek) + { + case DayOfWeek.Sunday: return test.Sunday; + case DayOfWeek.Monday: return test.Monday; + case DayOfWeek.Tuesday: return test.Tuesday; + case DayOfWeek.Wednesday: return test.Wednesday; + case DayOfWeek.Thursday: return test.Thursday; + case DayOfWeek.Friday: return test.Friday; + case DayOfWeek.Saturday: return test.Saturday; + default: return false; + } + } + + private static bool ShouldRunMonthlyTest(CommunicationTest test, DateTime utcNow) + { + return test.DayOfMonth.HasValue && test.DayOfMonth.Value == utcNow.Day; + } + + /// + /// Ensures the first run of a scheduled test happens in the NEXT eligible period + /// after creation, not the same week/month. This prevents users from abusing + /// scheduled tests to send immediately. + /// + private async Task HasPassedFirstEligiblePeriodAsync(CommunicationTest test) + { + var existingRuns = await _communicationTestRunRepository.GetRunsByTestIdAsync(test.CommunicationTestId); + if (existingRuns != null && existingRuns.Any()) + return true; // Already ran before, normal schedule applies + + // First run ever — must be at least one full period after creation + if (test.ScheduleType == (int)CommunicationTestScheduleType.Weekly) + { + // Must be at least 7 days after creation + return test.CreatedOn.AddDays(7) <= DateTime.UtcNow; + } + else if (test.ScheduleType == (int)CommunicationTestScheduleType.Monthly) + { + // Must be at least 28 days after creation (minimum month gap) + return test.CreatedOn.AddDays(28) <= DateTime.UtcNow; + } + + return true; + } + } +} diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index d3cdff73..3271c076 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -248,6 +248,83 @@ await _emailProvider.SendCallMail(emailAddress, subject, call.Name, priority, ca return true; } + public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, UserProfile profile = null) + { + if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(call.DepartmentId)) + return false; + + if (profile == null) + profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId); + + string emailAddress = String.Empty; + + if (dispatch.User != null && dispatch.User != null) + emailAddress = dispatch.User.Email; + else + { + var user = _usersService.GetUserById(dispatch.UserId, false); + + if (user != null && user != null) + emailAddress = user.Email; + } + + string subject = string.Format("Resgrid CANCELLED Dispatch: P{0} {1}", call.Priority, call.Name); + string priority = string.Format("{0}", ((CallPriority)call.Priority).ToString()); + string address = "No Address Supplied"; + + string coordinates = "No Coordinates Supplied"; + if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1) + coordinates = call.GeoLocationData; + + if (!string.IsNullOrEmpty(call.Address)) + address = call.Address; + else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1) + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + { + try + { + address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + catch (Exception) + { + } + } + } + + string dispatchedOn = String.Empty; + + if (call.Department != null) + dispatchedOn = call.LoggedOn.TimeConverterToString(call.Department); + else + dispatchedOn = call.LoggedOn.ToString("G") + " UTC"; + + string natureOfCall = "DISPATCH CANCELLED - " + call.NatureOfCall; + + if (call.Protocols != null && call.Protocols.Any()) + { + string protocols = String.Empty; + foreach (var protocol in call.Protocols) + { + if (String.IsNullOrWhiteSpace(protocols)) + protocols = protocol.Data; + else + protocols = protocol + "," + protocol.Data; + } + + if (!String.IsNullOrWhiteSpace(protocols)) + natureOfCall = natureOfCall + " (" + protocols + ")"; + } + + if (profile != null && profile.SendEmail && !String.IsNullOrWhiteSpace(emailAddress)) + await _emailProvider.SendCallMail(emailAddress, subject, call.Name, priority, natureOfCall, call.MapPage, + address, dispatchedOn, call.CallId, dispatch.UserId, coordinates, call.ShortenedAudioUrl); + + return true; + } + public async Task SendTroubleAlert(TroubleAlertEvent troubleAlertEvent, Unit unit, Call call, string callAddress, string unitAddress, string personnelNames, UserProfile profile) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(unit.DepartmentId)) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index daad563b..4194a932 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -100,6 +100,9 @@ protected override void Load(ContainerBuilder builder) // GDPR Services builder.RegisterType().As().InstancePerLifetimeScope(); + + // Communication Test Services + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Core/Resgrid.Services/SmsService.cs b/Core/Resgrid.Services/SmsService.cs index 4c93170b..e61418b4 100644 --- a/Core/Resgrid.Services/SmsService.cs +++ b/Core/Resgrid.Services/SmsService.cs @@ -209,6 +209,102 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d return true; } + public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, UserProfile profile = null, string address = null, Payment payment = null) + { + if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) + return false; + + if (profile == null) + profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId); + + if (payment != null && !_subscriptionsService.CanPlanSendCallSms(payment.PlanId)) + return true; + + if (profile != null && profile.SendSms) + { + if (String.IsNullOrWhiteSpace(address)) + { + if (!String.IsNullOrWhiteSpace(call.Address)) + { + address = call.Address; + } + else if (!string.IsNullOrEmpty(call.GeoLocationData)) + { + try + { + string[] points = call.GeoLocationData.Split(char.Parse(",")); + + if (points != null && points.Length == 2) + { + address = await _geoLocationProvider.GetAproxAddressFromLatLong(double.Parse(points[0]), double.Parse(points[1])); + } + } + catch + { + } + } + } + + if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier)) + { + string text = "CANCELLED: " + HtmlToTextHelper.ConvertHtml(call.NatureOfCall); + text = StringHelpers.StripHtmlTagsCharArray(text); + text = text + " " + address; + + if (call.Protocols != null && call.Protocols.Any()) + { + string protocols = String.Empty; + foreach (var protocol in call.Protocols) + { + if (!String.IsNullOrWhiteSpace(protocol.Data)) + { + if (String.IsNullOrWhiteSpace(protocols)) + protocols = protocol.Data; + else + protocols = protocol + "," + protocol.Data; + } + } + + if (!String.IsNullOrWhiteSpace(protocols)) + text = text + " (" + protocols + ")"; + } + + await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(call.Name, text), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, true); + } + else + { + await SendCancelCallViaEmailSmsGatewayAsync(call, address, profile); + } + } + + return true; + } + + private async Task SendCancelCallViaEmailSmsGatewayAsync(Call call, string address, UserProfile profile) + { + MailMessage email = new MailMessage(); + email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber())); + + email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGCall"); + email.Subject = "CANCELLED: " + call.Name; + + if (!string.IsNullOrEmpty(call.NatureOfCall)) + { + string text = "CANCELLED: " + HtmlToTextHelper.ConvertHtml(call.NatureOfCall); + text = StringHelpers.StripHtmlTagsCharArray(text); + email.Body = text + " " + address; + } + + try + { + await _emailSender.SendEmail(email); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + private void SendCallViaEmailSmsGateway(Call call, string address, UserProfile profile) { MailMessage email = new MailMessage(); diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 0415b44f..9af06638 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1627,5 +1627,19 @@ public static void AddRouteClaims(ClaimsIdentity identity, bool isAdmin, List + /// Communication test management is restricted to department admins only. + /// + public static void AddCommunicationTestClaims(ClaimsIdentity identity, bool isAdmin) + { + if (isAdmin) + { + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.View)); + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Update)); + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Create)); + identity.AddClaim(new Claim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Delete)); + } + } } } diff --git a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs index 77d92928..c44dc777 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsPrincipalFactory.cs @@ -204,6 +204,7 @@ public override async Task CreateAsync(TUser user) ClaimsLogic.AddWorkflowRunClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddUdfClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddRouteClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); + ClaimsLogic.AddCommunicationTestClaims(id, departmentAdmin); } } diff --git a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs index ab14bc4f..814a289f 100644 --- a/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs +++ b/Providers/Resgrid.Providers.Claims/JwtTokenProvider.cs @@ -127,6 +127,7 @@ public async Task BuildTokenAsync(string userId, int departmentId) ClaimsLogic.AddWorkflowRunClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddUdfClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); ClaimsLogic.AddRouteClaims(id, departmentAdmin, permissions, isGroupAdmin, roles); + ClaimsLogic.AddCommunicationTestClaims(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 f4b66d8b..5fc38b32 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -64,6 +64,7 @@ public static class Resources public const string Scim = "Scim"; public const string Udf = "Udf"; public const string Route = "Route"; + public const string CommunicationTest = "CommunicationTest"; } public static string CreateDepartmentClaimTypeString(int departmentId) diff --git a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs index f36fb6ee..8c3405ab 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridIdentity.cs @@ -1082,5 +1082,10 @@ public void AddRouteClaims(bool isAdmin, List permissions, bool isGr { ClaimsLogic.AddRouteClaims(this, isAdmin, permissions, isGroupAdmin, roles); } + + public void AddCommunicationTestClaims(bool isAdmin) + { + ClaimsLogic.AddCommunicationTestClaims(this, isAdmin); + } } } diff --git a/Providers/Resgrid.Providers.Claims/ResgridResources.cs b/Providers/Resgrid.Providers.Claims/ResgridResources.cs index bef5362a..854fbdad 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -167,5 +167,10 @@ public static class ResgridResources public const string Route_Update = "Route_Update"; public const string Route_Create = "Route_Create"; public const string Route_Delete = "Route_Delete"; + + public const string CommunicationTest_View = "CommunicationTest_View"; + public const string CommunicationTest_Update = "CommunicationTest_Update"; + public const string CommunicationTest_Create = "CommunicationTest_Create"; + public const string CommunicationTest_Delete = "CommunicationTest_Delete"; } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0061_AddingCallVideoFeeds.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0061_AddingCallVideoFeeds.cs new file mode 100644 index 00000000..eb71e9eb --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0061_AddingCallVideoFeeds.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(61)] + public class M0061_AddingCallVideoFeeds : Migration + { + public override void Up() + { + Create.Table("CallVideoFeeds") + .WithColumn("CallVideoFeedId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(500).NotNullable() + .WithColumn("Url").AsString(2000).NotNullable() + .WithColumn("FeedType").AsInt32().Nullable() + .WithColumn("FeedFormat").AsInt32().Nullable() + .WithColumn("Description").AsString(4000).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Latitude").AsDecimal(10, 7).Nullable() + .WithColumn("Longitude").AsDecimal(10, 7).Nullable() + .WithColumn("AddedByUserId").AsString(128).NotNullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DeletedByUserId").AsString(128).Nullable() + .WithColumn("DeletedOn").AsDateTime2().Nullable() + .WithColumn("IsFlagged").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("FlaggedReason").AsString(4000).Nullable() + .WithColumn("FlaggedByUserId").AsString(128).Nullable() + .WithColumn("FlaggedOn").AsDateTime2().Nullable(); + + Create.ForeignKey("FK_CallVideoFeeds_Calls") + .FromTable("CallVideoFeeds").ForeignColumn("CallId") + .ToTable("Calls").PrimaryColumn("CallId"); + + Create.ForeignKey("FK_CallVideoFeeds_Departments") + .FromTable("CallVideoFeeds").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_CallVideoFeeds_CallId") + .OnTable("CallVideoFeeds") + .OnColumn("CallId"); + + Create.Index("IX_CallVideoFeeds_DepartmentId") + .OnTable("CallVideoFeeds") + .OnColumn("DepartmentId"); + } + + public override void Down() + { + Delete.ForeignKey("FK_CallVideoFeeds_Calls").OnTable("CallVideoFeeds"); + Delete.ForeignKey("FK_CallVideoFeeds_Departments").OnTable("CallVideoFeeds"); + Delete.Table("CallVideoFeeds"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0062_AddingCommunicationTests.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0062_AddingCommunicationTests.cs new file mode 100644 index 00000000..8f35d875 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0062_AddingCommunicationTests.cs @@ -0,0 +1,113 @@ +using FluentMigrator; +using System; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(62)] + public class M0062_AddingCommunicationTests : Migration + { + public override void Up() + { + Create.Table("CommunicationTests") + .WithColumn("CommunicationTestId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(500).NotNullable() + .WithColumn("Description").AsString(4000).Nullable() + .WithColumn("ScheduleType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Sunday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Monday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Tuesday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Wednesday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Thursday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Friday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Saturday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DayOfMonth").AsInt32().Nullable() + .WithColumn("Time").AsString(50).Nullable() + .WithColumn("TestSms").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("TestEmail").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("TestVoice").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("TestPush").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("CreatedByUserId").AsString(128).NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("ResponseWindowMinutes").AsInt32().NotNullable().WithDefaultValue(60); + + Create.ForeignKey("FK_CommunicationTests_Departments") + .FromTable("CommunicationTests").ForeignColumn("DepartmentId") + .ToTable("Departments").PrimaryColumn("DepartmentId"); + + Create.Index("IX_CommunicationTests_DepartmentId") + .OnTable("CommunicationTests") + .OnColumn("DepartmentId").Ascending(); + + Create.Table("CommunicationTestRuns") + .WithColumn("CommunicationTestRunId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("CommunicationTestId").AsGuid().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("InitiatedByUserId").AsString(128).Nullable() + .WithColumn("StartedOn").AsDateTime2().NotNullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RunCode").AsString(20).NotNullable() + .WithColumn("TotalUsersTested").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("TotalResponses").AsInt32().NotNullable().WithDefaultValue(0); + + Create.ForeignKey("FK_CommunicationTestRuns_CommunicationTests") + .FromTable("CommunicationTestRuns").ForeignColumn("CommunicationTestId") + .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId"); + + Create.Index("IX_CommunicationTestRuns_CommunicationTestId") + .OnTable("CommunicationTestRuns") + .OnColumn("CommunicationTestId").Ascending(); + + Create.Index("IX_CommunicationTestRuns_DepartmentId") + .OnTable("CommunicationTestRuns") + .OnColumn("DepartmentId").Ascending(); + + Create.Index("IX_CommunicationTestRuns_RunCode") + .OnTable("CommunicationTestRuns") + .OnColumn("RunCode").Ascending() + .WithOptions().Unique(); + + Create.Table("CommunicationTestResults") + .WithColumn("CommunicationTestResultId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid) + .WithColumn("CommunicationTestRunId").AsGuid().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("Channel").AsInt32().NotNullable() + .WithColumn("ContactValue").AsString(500).Nullable() + .WithColumn("ContactCarrier").AsString(200).Nullable() + .WithColumn("VerificationStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SendAttempted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SendSucceeded").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SentOn").AsDateTime2().Nullable() + .WithColumn("Responded").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RespondedOn").AsDateTime2().Nullable() + .WithColumn("ResponseToken").AsString(128).Nullable(); + + Create.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns") + .FromTable("CommunicationTestResults").ForeignColumn("CommunicationTestRunId") + .ToTable("CommunicationTestRuns").PrimaryColumn("CommunicationTestRunId"); + + Create.Index("IX_CommunicationTestResults_CommunicationTestRunId") + .OnTable("CommunicationTestResults") + .OnColumn("CommunicationTestRunId").Ascending(); + + Create.Index("IX_CommunicationTestResults_DepartmentId") + .OnTable("CommunicationTestResults") + .OnColumn("DepartmentId").Ascending(); + + Create.Index("IX_CommunicationTestResults_ResponseToken") + .OnTable("CommunicationTestResults") + .OnColumn("ResponseToken").Ascending(); + } + + public override void Down() + { + Delete.Table("CommunicationTestResults"); + Delete.Table("CommunicationTestRuns"); + Delete.Table("CommunicationTests"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0061_AddingCallVideoFeedsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0061_AddingCallVideoFeedsPg.cs new file mode 100644 index 00000000..4ac08fa2 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0061_AddingCallVideoFeedsPg.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(61)] + public class M0061_AddingCallVideoFeedsPg : Migration + { + public override void Up() + { + Create.Table("callvideofeeds") + .WithColumn("callvideofeedid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("url").AsCustom("text").NotNullable() + .WithColumn("feedtype").AsInt32().Nullable() + .WithColumn("feedformat").AsInt32().Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("latitude").AsDecimal(10, 7).Nullable() + .WithColumn("longitude").AsDecimal(10, 7).Nullable() + .WithColumn("addedbyuserid").AsCustom("citext").NotNullable() + .WithColumn("addedon").AsDateTime().NotNullable() + .WithColumn("updatedon").AsDateTime().Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("deletedbyuserid").AsCustom("citext").Nullable() + .WithColumn("deletedon").AsDateTime().Nullable() + .WithColumn("isflagged").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("flaggedreason").AsCustom("text").Nullable() + .WithColumn("flaggedbyuserid").AsCustom("citext").Nullable() + .WithColumn("flaggedon").AsDateTime().Nullable(); + + Create.ForeignKey("fk_callvideofeeds_calls") + .FromTable("callvideofeeds").ForeignColumn("callid") + .ToTable("calls").PrimaryColumn("callid"); + + Create.ForeignKey("fk_callvideofeeds_departments") + .FromTable("callvideofeeds").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_callvideofeeds_callid") + .OnTable("callvideofeeds") + .OnColumn("callid"); + + Create.Index("ix_callvideofeeds_departmentid") + .OnTable("callvideofeeds") + .OnColumn("departmentid"); + } + + public override void Down() + { + Delete.ForeignKey("fk_callvideofeeds_calls").OnTable("callvideofeeds"); + Delete.ForeignKey("fk_callvideofeeds_departments").OnTable("callvideofeeds"); + Delete.Table("callvideofeeds"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0062_AddingCommunicationTestsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0062_AddingCommunicationTestsPg.cs new file mode 100644 index 00000000..816114a7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0062_AddingCommunicationTestsPg.cs @@ -0,0 +1,112 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(62)] + public class M0062_AddingCommunicationTestsPg : Migration + { + public override void Up() + { + Create.Table("communicationtests") + .WithColumn("communicationtestid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("scheduletype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sunday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("monday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("tuesday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("wednesday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("thursday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("friday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("saturday").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("dayofmonth").AsInt32().Nullable() + .WithColumn("time").AsCustom("citext").Nullable() + .WithColumn("testsms").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("testemail").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("testvoice").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("testpush").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("active").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("createdbyuserid").AsCustom("citext").NotNullable() + .WithColumn("createdon").AsDateTime().NotNullable() + .WithColumn("updatedon").AsDateTime().Nullable() + .WithColumn("responsewindowminutes").AsInt32().NotNullable().WithDefaultValue(60); + + Create.ForeignKey("fk_communicationtests_departments") + .FromTable("communicationtests").ForeignColumn("departmentid") + .ToTable("departments").PrimaryColumn("departmentid"); + + Create.Index("ix_communicationtests_departmentid") + .OnTable("communicationtests") + .OnColumn("departmentid"); + + Create.Table("communicationtestruns") + .WithColumn("communicationtestrunid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("communicationtestid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("initiatedbyuserid").AsCustom("citext").Nullable() + .WithColumn("startedon").AsDateTime().NotNullable() + .WithColumn("completedon").AsDateTime().Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("runcode").AsCustom("citext").NotNullable() + .WithColumn("totaluserstested").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("totalresponses").AsInt32().NotNullable().WithDefaultValue(0); + + Create.ForeignKey("fk_communicationtestruns_communicationtests") + .FromTable("communicationtestruns").ForeignColumn("communicationtestid") + .ToTable("communicationtests").PrimaryColumn("communicationtestid"); + + Create.Index("ix_communicationtestruns_communicationtestid") + .OnTable("communicationtestruns") + .OnColumn("communicationtestid"); + + Create.Index("ix_communicationtestruns_departmentid") + .OnTable("communicationtestruns") + .OnColumn("departmentid"); + + Create.Index("ix_communicationtestruns_runcode") + .OnTable("communicationtestruns") + .OnColumn("runcode") + .Unique(); + + Create.Table("communicationtestresults") + .WithColumn("communicationtestresultid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("communicationtestrunid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("channel").AsInt32().NotNullable() + .WithColumn("contactvalue").AsCustom("citext").Nullable() + .WithColumn("contactcarrier").AsCustom("citext").Nullable() + .WithColumn("verificationstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sendattempted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sendsucceeded").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("senton").AsDateTime().Nullable() + .WithColumn("responded").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("respondedon").AsDateTime().Nullable() + .WithColumn("responsetoken").AsCustom("citext").Nullable(); + + Create.ForeignKey("fk_communicationtestresults_communicationtestruns") + .FromTable("communicationtestresults").ForeignColumn("communicationtestrunid") + .ToTable("communicationtestruns").PrimaryColumn("communicationtestrunid"); + + Create.Index("ix_communicationtestresults_communicationtestrunid") + .OnTable("communicationtestresults") + .OnColumn("communicationtestrunid"); + + Create.Index("ix_communicationtestresults_departmentid") + .OnTable("communicationtestresults") + .OnColumn("departmentid"); + + Create.Index("ix_communicationtestresults_responsetoken") + .OnTable("communicationtestresults") + .OnColumn("responsetoken"); + } + + public override void Down() + { + Delete.Table("communicationtestresults"); + Delete.Table("communicationtestruns"); + Delete.Table("communicationtests"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallVideoFeedRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallVideoFeedRepository.cs new file mode 100644 index 00000000..26b5032f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CallVideoFeedRepository.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.CallVideoFeeds; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class CallVideoFeedRepository : RepositoryBase, ICallVideoFeedRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CallVideoFeedRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetByCallIdAsync(int callId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CallId", callId); + + 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> GetByDepartmentIdAsync(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/CommunicationTestRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestRepository.cs new file mode 100644 index 00000000..a6facd48 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestRepository.cs @@ -0,0 +1,70 @@ +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.CommunicationTests; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class CommunicationTestRepository : RepositoryBase, ICommunicationTestRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommunicationTestRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetActiveTestsForScheduleTypeAsync(int scheduleType) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("ScheduleType", scheduleType); + + 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/CommunicationTestResultRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestResultRepository.cs new file mode 100644 index 00000000..deec22fc --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestResultRepository.cs @@ -0,0 +1,111 @@ +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.CommunicationTests; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class CommunicationTestResultRepository : RepositoryBase, ICommunicationTestResultRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommunicationTestResultRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetResultsByRunIdAsync(Guid communicationTestRunId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CommunicationTestRunId", communicationTestRunId); + + 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 GetResultByResponseTokenAsync(string responseToken) + { + try + { + var selectFunction = new Func>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("ResponseToken", responseToken); + + var query = _queryFactory.GetQuery(); + + var result = await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + + return result.FirstOrDefault(); + }); + + 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/CommunicationTestRunRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestRunRepository.cs new file mode 100644 index 00000000..8dcc7057 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestRunRepository.cs @@ -0,0 +1,148 @@ +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.CommunicationTests; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; + +namespace Resgrid.Repositories.DataRepository +{ + public class CommunicationTestRunRepository : RepositoryBase, ICommunicationTestRunRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommunicationTestRunRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetRunsByTestIdAsync(Guid communicationTestId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CommunicationTestId", communicationTestId); + + 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 GetRunByRunCodeAsync(string runCode) + { + try + { + var selectFunction = new Func>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCode", runCode); + + var query = _queryFactory.GetQuery(); + + var result = await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + + return result.FirstOrDefault(); + }); + + 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> GetOpenRunsAsync() + { + 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; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index e086d2ee..35a301fc 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -330,6 +330,9 @@ protected SqlConfiguration() { } public string SelectFlaggedCallNotesByDepartmentIdQuery { get; set; } public string SelectFlaggedCallImagesByDepartmentIdQuery { get; set; } public string SelectFlaggedCallFilesByDepartmentIdQuery { get; set; } + public string CallVideoFeedsTable { get; set; } + public string SelectCallVideoFeedsByCallIdQuery { get; set; } + public string SelectCallVideoFeedsByDepartmentIdQuery { get; set; } #endregion Calls #region Dispatch Protocols @@ -531,6 +534,18 @@ protected SqlConfiguration() { } public string SelectCalendarItemCheckInsByUserDateRangeQuery { get; set; } #endregion CalendarItemCheckIns + #region CommunicationTests + public string CommunicationTestsTable { get; set; } + public string CommunicationTestRunsTable { get; set; } + public string CommunicationTestResultsTable { get; set; } + public string SelectActiveCommTestsByScheduleTypeQuery { get; set; } + public string SelectCommTestRunsByTestIdQuery { get; set; } + public string SelectCommTestRunByRunCodeQuery { get; set; } + public string SelectOpenCommTestRunsQuery { get; set; } + public string SelectCommTestResultsByRunIdQuery { get; set; } + public string SelectCommTestResultByResponseTokenQuery { get; set; } + #endregion CommunicationTests + // Identity #region Table Names diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 220ee03f..c408e5bf 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -133,6 +133,10 @@ 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(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 48a8e786..6aa3addd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -132,6 +132,10 @@ 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(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index d92d974c..3937d42c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -132,6 +132,10 @@ 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(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 90b38dbb..e79a417e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -132,6 +132,10 @@ 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(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByCallIdQuery.cs new file mode 100644 index 00000000..85707dfb --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByCallIdQuery.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.CallVideoFeeds +{ + public class SelectCallVideoFeedsByCallIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallVideoFeedsByCallIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallVideoFeedsByCallIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CallVideoFeedsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALLID%" }, + new string[] { "CallId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByDepartmentIdQuery.cs new file mode 100644 index 00000000..159a9336 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CallVideoFeeds/SelectCallVideoFeedsByDepartmentIdQuery.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.CallVideoFeeds +{ + public class SelectCallVideoFeedsByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallVideoFeedsByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallVideoFeedsByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CallVideoFeedsTable, + _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/CommunicationTests/SelectActiveCommTestsByScheduleTypeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectActiveCommTestsByScheduleTypeQuery.cs new file mode 100644 index 00000000..aad93c1f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectActiveCommTestsByScheduleTypeQuery.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.CommunicationTests +{ + public class SelectActiveCommTestsByScheduleTypeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectActiveCommTestsByScheduleTypeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectActiveCommTestsByScheduleTypeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%SCHEDULETYPE%" }, + new string[] { "ScheduleType" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultByResponseTokenQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultByResponseTokenQuery.cs new file mode 100644 index 00000000..d0414a4c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultByResponseTokenQuery.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.CommunicationTests +{ + public class SelectCommTestResultByResponseTokenQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCommTestResultByResponseTokenQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCommTestResultByResponseTokenQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestResultsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%TOKEN%" }, + new string[] { "ResponseToken" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultsByRunIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultsByRunIdQuery.cs new file mode 100644 index 00000000..319bdeaf --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestResultsByRunIdQuery.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.CommunicationTests +{ + public class SelectCommTestResultsByRunIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCommTestResultsByRunIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCommTestResultsByRunIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestResultsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%RUNID%" }, + new string[] { "CommunicationTestRunId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunByRunCodeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunByRunCodeQuery.cs new file mode 100644 index 00000000..6e29f0ae --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunByRunCodeQuery.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.CommunicationTests +{ + public class SelectCommTestRunByRunCodeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCommTestRunByRunCodeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCommTestRunByRunCodeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestRunsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%RUNCODE%" }, + new string[] { "RunCode" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunsByTestIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunsByTestIdQuery.cs new file mode 100644 index 00000000..21c9274d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestRunsByTestIdQuery.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.CommunicationTests +{ + public class SelectCommTestRunsByTestIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCommTestRunsByTestIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCommTestRunsByTestIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestRunsTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMTESTID%" }, + new string[] { "CommunicationTestId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectOpenCommTestRunsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectOpenCommTestRunsQuery.cs new file mode 100644 index 00000000..b26c64d1 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectOpenCommTestRunsQuery.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.CommunicationTests +{ + public class SelectOpenCommTestRunsQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectOpenCommTestRunsQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectOpenCommTestRunsQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommunicationTestRunsTable, + _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/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 924646e2..9982d199 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1114,6 +1114,9 @@ UPDATE CallDispatches SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectAllCallRoleDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectCallNotesByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; + CallVideoFeedsTable = "CallVideoFeeds"; + SelectCallVideoFeedsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID% AND IsDeleted = false"; + SelectCallVideoFeedsByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DEPARTMENTID% AND IsDeleted = false"; SelectCallYearsByDeptQuery = @" SELECT extract(year from c.LoggedOn) FROM Calls c WHERE c.DepartmentId = %DID% @@ -1641,6 +1644,18 @@ ORDER BY Timestamp DESC ORDER BY CheckInTime DESC"; #endregion CalendarItemCheckIns + #region CommunicationTests + CommunicationTestsTable = "CommunicationTests"; + CommunicationTestRunsTable = "CommunicationTestRuns"; + CommunicationTestResultsTable = "CommunicationTestResults"; + SelectActiveCommTestsByScheduleTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Active = true AND ScheduleType = %SCHEDULETYPE%"; + SelectCommTestRunsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestId = %COMMTESTID% ORDER BY StartedOn DESC"; + SelectCommTestRunByRunCodeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE RunCode = %RUNCODE% LIMIT 1"; + SelectOpenCommTestRunsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Status IN (0, 1, 2)"; + SelectCommTestResultsByRunIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestRunId = %RUNID%"; + SelectCommTestResultByResponseTokenQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ResponseToken = %TOKEN% LIMIT 1"; + #endregion CommunicationTests + #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 649c4878..10cbc2ee 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1078,6 +1078,9 @@ UPDATE CallDispatches SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectAllCallRoleDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectCallNotesByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; + CallVideoFeedsTable = "CallVideoFeeds"; + SelectCallVideoFeedsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID% AND [IsDeleted] = 0"; + SelectCallVideoFeedsByDepartmentIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DEPARTMENTID% AND [IsDeleted] = 0"; SelectCallYearsByDeptQuery = @" SELECT DISTINCT YEAR(c.LoggedOn) FROM Calls c WHERE c.DepartmentId = %DID% @@ -1602,6 +1605,18 @@ SELECT TOP 1 * ORDER BY [CheckInTime] DESC"; #endregion CalendarItemCheckIns + #region CommunicationTests + CommunicationTestsTable = "CommunicationTests"; + CommunicationTestRunsTable = "CommunicationTestRuns"; + CommunicationTestResultsTable = "CommunicationTestResults"; + SelectActiveCommTestsByScheduleTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [Active] = 1 AND [ScheduleType] = %SCHEDULETYPE%"; + SelectCommTestRunsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommunicationTestId] = %COMMTESTID% ORDER BY [StartedOn] DESC"; + SelectCommTestRunByRunCodeQuery = "SELECT TOP 1 * FROM %SCHEMA%.%TABLENAME% WHERE [RunCode] = %RUNCODE%"; + SelectOpenCommTestRunsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [Status] IN (0, 1, 2)"; + SelectCommTestResultsByRunIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommunicationTestRunId] = %RUNID%"; + SelectCommTestResultByResponseTokenQuery = "SELECT TOP 1 * FROM %SCHEMA%.%TABLENAME% WHERE [ResponseToken] = %TOKEN%"; + #endregion CommunicationTests + #region User Defined Fields UdfDefinitionsTableName = "UdfDefinitions"; UdfFieldsTableName = "UdfFields"; diff --git a/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs b/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs new file mode 100644 index 00000000..2c96f226 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs @@ -0,0 +1,198 @@ +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.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CallVideoFeedTests + { + private Mock _callsRepo; + private Mock _communicationService; + private Mock _callDispatchesRepo; + private Mock _callTypesRepo; + private Mock _callEmailFactory; + private Mock _cacheProvider; + private Mock _callNotesRepo; + private Mock _callAttachmentRepo; + private Mock _callDispatchGroupRepo; + private Mock _callDispatchUnitRepo; + private Mock _callDispatchRoleRepo; + private Mock _callPriorityRepo; + private Mock _shortenUrlProvider; + private Mock _callProtocolsRepo; + private Mock _geoLocationProvider; + private Mock _departmentsService; + private Mock _callReferencesRepo; + private Mock _callContactsRepo; + private Mock _indoorMapService; + private Mock _callVideoFeedRepo; + private CallsService _service; + + [SetUp] + public void SetUp() + { + _callsRepo = new Mock(); + _communicationService = new Mock(); + _callDispatchesRepo = new Mock(); + _callTypesRepo = new Mock(); + _callEmailFactory = new Mock(); + _cacheProvider = new Mock(); + _callNotesRepo = new Mock(); + _callAttachmentRepo = new Mock(); + _callDispatchGroupRepo = new Mock(); + _callDispatchUnitRepo = new Mock(); + _callDispatchRoleRepo = new Mock(); + _callPriorityRepo = new Mock(); + _shortenUrlProvider = new Mock(); + _callProtocolsRepo = new Mock(); + _geoLocationProvider = new Mock(); + _departmentsService = new Mock(); + _callReferencesRepo = new Mock(); + _callContactsRepo = new Mock(); + _indoorMapService = new Mock(); + _callVideoFeedRepo = new Mock(); + + _service = new CallsService( + _callsRepo.Object, _communicationService.Object, _callDispatchesRepo.Object, + _callTypesRepo.Object, _callEmailFactory.Object, _cacheProvider.Object, + _callNotesRepo.Object, _callAttachmentRepo.Object, _callDispatchGroupRepo.Object, + _callDispatchUnitRepo.Object, _callDispatchRoleRepo.Object, _callPriorityRepo.Object, + _shortenUrlProvider.Object, _callProtocolsRepo.Object, _geoLocationProvider.Object, + _departmentsService.Object, _callReferencesRepo.Object, _callContactsRepo.Object, + _indoorMapService.Object, _callVideoFeedRepo.Object); + } + + [Test] + public async Task SaveCallVideoFeedAsync_ShouldSaveAndReturnFeed() + { + var feed = new CallVideoFeed + { + CallVideoFeedId = Guid.NewGuid().ToString(), + CallId = 1, + DepartmentId = 10, + Name = "Drone Feed", + Url = "rtsp://example.com/stream", + FeedType = (int)CallVideoFeedTypes.Drone, + FeedFormat = (int)CallVideoFeedFormats.RTSP, + AddedByUserId = "user1", + AddedOn = DateTime.UtcNow + }; + + _callVideoFeedRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(feed); + + var result = await _service.SaveCallVideoFeedAsync(feed); + + result.Should().NotBeNull(); + result.Name.Should().Be("Drone Feed"); + result.Url.Should().Be("rtsp://example.com/stream"); + _callVideoFeedRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GetCallVideoFeedsByCallIdAsync_ShouldReturnFeedsForCall() + { + var feeds = new List + { + new CallVideoFeed { CallVideoFeedId = "feed1", CallId = 1, Name = "Feed 1", Url = "http://example.com/1", IsDeleted = false }, + new CallVideoFeed { CallVideoFeedId = "feed2", CallId = 1, Name = "Feed 2", Url = "http://example.com/2", IsDeleted = false } + }; + + _callVideoFeedRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(feeds); + + var result = await _service.GetCallVideoFeedsByCallIdAsync(1); + + result.Should().HaveCount(2); + result[0].Name.Should().Be("Feed 1"); + result[1].Name.Should().Be("Feed 2"); + } + + [Test] + public async Task GetCallVideoFeedsByCallIdAsync_ShouldNotReturnDeletedFeeds() + { + var feeds = new List + { + new CallVideoFeed { CallVideoFeedId = "feed1", CallId = 1, Name = "Feed 1", Url = "http://example.com/1", IsDeleted = false }, + new CallVideoFeed { CallVideoFeedId = "feed2", CallId = 1, Name = "Feed 2", Url = "http://example.com/2", IsDeleted = true } + }; + + _callVideoFeedRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(feeds); + + var result = await _service.GetCallVideoFeedsByCallIdAsync(1); + + // The service returns all feeds from repo; filtering is done at the API layer + result.Should().HaveCount(2); + } + + [Test] + public async Task DeleteCallVideoFeedAsync_ShouldSoftDelete() + { + var feed = new CallVideoFeed + { + CallVideoFeedId = "feed1", + CallId = 1, + DepartmentId = 10, + Name = "Feed 1", + Url = "http://example.com/1", + IsDeleted = false + }; + + _callVideoFeedRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(feed); + + var result = await _service.DeleteCallVideoFeedAsync(feed, "deletingUser"); + + result.Should().BeTrue(); + feed.IsDeleted.Should().BeTrue(); + feed.DeletedByUserId.Should().Be("deletingUser"); + feed.DeletedOn.Should().NotBeNull(); + } + + [Test] + public async Task SaveCallVideoFeedAsync_WithCoordinates_ShouldPersistLocation() + { + var feed = new CallVideoFeed + { + CallVideoFeedId = Guid.NewGuid().ToString(), + CallId = 1, + DepartmentId = 10, + Name = "Traffic Cam", + Url = "http://example.com/traffic", + Latitude = 39.2771m, + Longitude = -119.772m, + AddedByUserId = "user1", + AddedOn = DateTime.UtcNow + }; + + _callVideoFeedRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(feed); + + var result = await _service.SaveCallVideoFeedAsync(feed); + + result.Should().NotBeNull(); + result.Latitude.Should().Be(39.2771m); + result.Longitude.Should().Be(-119.772m); + } + + [Test] + public async Task GetCallVideoFeedByIdAsync_WithInvalidId_ShouldReturnNull() + { + _callVideoFeedRepo.Setup(x => x.GetByIdAsync("nonexistent")).ReturnsAsync((CallVideoFeed)null); + + var result = await _service.GetCallVideoFeedByIdAsync("nonexistent"); + + result.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs new file mode 100644 index 00000000..d7fe69ad --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs @@ -0,0 +1,426 @@ +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.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace CommunicationTestServiceTests + { + public class with_the_communication_test_service : TestBase + { + protected Mock _communicationTestRepoMock; + protected Mock _communicationTestRunRepoMock; + protected Mock _communicationTestResultRepoMock; + protected Mock _departmentsServiceMock; + protected Mock _userProfileServiceMock; + + protected ICommunicationTestService _communicationTestService; + + protected override void Before_all_tests() + { + base.Before_all_tests(); + + _communicationTestRepoMock = new Mock(); + _communicationTestRunRepoMock = new Mock(); + _communicationTestResultRepoMock = new Mock(); + _departmentsServiceMock = new Mock(); + _userProfileServiceMock = new Mock(); + + _communicationTestService = new CommunicationTestService( + _communicationTestRepoMock.Object, + _communicationTestRunRepoMock.Object, + _communicationTestResultRepoMock.Object, + _departmentsServiceMock.Object, + _userProfileServiceMock.Object + ); + } + } + + [TestFixture] + public class when_starting_a_test_run : with_the_communication_test_service + { + [Test] + public async Task should_create_results_per_user_per_channel() + { + var testId = Guid.NewGuid(); + var test = new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = 1, + TestSms = true, + TestEmail = true, + TestVoice = false, + TestPush = true, + ResponseWindowMinutes = 60, + Active = true + }; + + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(test); + + var members = new List + { + new DepartmentMember { UserId = TestData.Users.TestUser1Id, DepartmentId = 1 }, + new DepartmentMember { UserId = TestData.Users.TestUser2Id, DepartmentId = 1 } + }; + _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(members); + + var profiles = new Dictionary + { + { TestData.Users.TestUser1Id, new UserProfile { UserId = TestData.Users.TestUser1Id, MembershipEmail = "user1@test.com", MobileNumber = "5551234567", MobileCarrier = (int)MobileCarriers.Att, EmailVerified = true, MobileNumberVerified = true } }, + { TestData.Users.TestUser2Id, new UserProfile { UserId = TestData.Users.TestUser2Id, MembershipEmail = "user2@test.com", MobileNumber = "5559876543", MobileCarrier = (int)MobileCarriers.Verizon, EmailVerified = null, MobileNumberVerified = false } } + }; + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(profiles); + + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => + { + r.CommunicationTestRunId = Guid.NewGuid(); + return r; + }); + + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + + var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id); + + run.Should().NotBeNull(); + run.TotalUsersTested.Should().Be(2); + run.RunCode.Should().StartWith("CT-"); + + // 3 channels (SMS, Email, Push) x 2 users = 6 results + _communicationTestResultRepoMock.Verify( + x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true), + Times.Exactly(6)); + } + + [Test] + public async Task should_block_send_for_pending_verification() + { + var testId = Guid.NewGuid(); + var test = new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = 1, + TestSms = true, + TestEmail = false, + TestVoice = false, + TestPush = false, + ResponseWindowMinutes = 60, + Active = true + }; + + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(test); + + var members = new List + { + new DepartmentMember { UserId = TestData.Users.TestUser1Id, DepartmentId = 1 } + }; + _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(members); + + var profiles = new Dictionary + { + { TestData.Users.TestUser1Id, new UserProfile { UserId = TestData.Users.TestUser1Id, MobileNumber = "5551234567", MobileCarrier = (int)MobileCarriers.Att, MobileNumberVerified = false } } + }; + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(profiles); + + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => + { + r.CommunicationTestRunId = Guid.NewGuid(); + return r; + }); + + CommunicationTestResult savedResult = null; + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .Callback((r, c, f) => savedResult = r) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + + await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id); + + savedResult.Should().NotBeNull(); + savedResult.SendAttempted.Should().BeFalse(); + savedResult.VerificationStatus.Should().Be((int)ContactVerificationStatus.Pending); + } + + [Test] + public async Task should_allow_send_for_grandfathered_verification() + { + var testId = Guid.NewGuid(); + var test = new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = 1, + TestEmail = true, + TestSms = false, + TestVoice = false, + TestPush = false, + ResponseWindowMinutes = 60, + Active = true + }; + + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(test); + + var members = new List + { + new DepartmentMember { UserId = TestData.Users.TestUser1Id, DepartmentId = 1 } + }; + _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(members); + + var profiles = new Dictionary + { + { TestData.Users.TestUser1Id, new UserProfile { UserId = TestData.Users.TestUser1Id, MembershipEmail = "user1@test.com", EmailVerified = null } } + }; + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(profiles); + + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => + { + r.CommunicationTestRunId = Guid.NewGuid(); + return r; + }); + + CommunicationTestResult savedResult = null; + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .Callback((r, c, f) => savedResult = r) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + + await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id); + + savedResult.Should().NotBeNull(); + savedResult.SendAttempted.Should().BeTrue(); + savedResult.SendSucceeded.Should().BeTrue(); + savedResult.VerificationStatus.Should().Be((int)ContactVerificationStatus.Grandfathered); + } + } + + [TestFixture] + public class when_recording_responses : with_the_communication_test_service + { + [Test] + public async Task should_record_sms_response_by_run_code() + { + var runId = Guid.NewGuid(); + var run = new CommunicationTestRun + { + CommunicationTestRunId = runId, + Status = (int)CommunicationTestRunStatus.AwaitingResponses, + RunCode = "CT-A7X3" + }; + + _communicationTestRunRepoMock.Setup(x => x.GetRunByRunCodeAsync("CT-A7X3")).ReturnsAsync(run); + + var results = new List + { + new CommunicationTestResult + { + CommunicationTestResultId = Guid.NewGuid(), + CommunicationTestRunId = runId, + UserId = TestData.Users.TestUser1Id, + Channel = (int)CommunicationTestChannel.Sms, + ContactValue = "5551234567", + SendAttempted = true, + SendSucceeded = true, + Responded = false + } + }; + + _communicationTestResultRepoMock.Setup(x => x.GetResultsByRunIdAsync(runId)).ReturnsAsync(results); + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => r); + + var success = await _communicationTestService.RecordSmsResponseAsync("CT-A7X3", "5551234567"); + + success.Should().BeTrue(); + results[0].Responded.Should().BeTrue(); + results[0].RespondedOn.Should().NotBeNull(); + } + + [Test] + public async Task should_record_email_response_by_token() + { + var token = Guid.NewGuid().ToString("N"); + var result = new CommunicationTestResult + { + CommunicationTestResultId = Guid.NewGuid(), + CommunicationTestRunId = Guid.NewGuid(), + Channel = (int)CommunicationTestChannel.Email, + SendAttempted = true, + SendSucceeded = true, + Responded = false, + ResponseToken = token + }; + + _communicationTestResultRepoMock.Setup(x => x.GetResultByResponseTokenAsync(token)).ReturnsAsync(result); + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + + var run = new CommunicationTestRun { CommunicationTestRunId = result.CommunicationTestRunId }; + _communicationTestRunRepoMock.Setup(x => x.GetByIdAsync(result.CommunicationTestRunId)).ReturnsAsync(run); + _communicationTestResultRepoMock.Setup(x => x.GetResultsByRunIdAsync(result.CommunicationTestRunId)).ReturnsAsync(new List { result }); + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => r); + + var success = await _communicationTestService.RecordEmailResponseAsync(token); + + success.Should().BeTrue(); + result.Responded.Should().BeTrue(); + } + + [Test] + public async Task should_record_push_response_by_token() + { + var token = Guid.NewGuid().ToString("N"); + var result = new CommunicationTestResult + { + CommunicationTestResultId = Guid.NewGuid(), + CommunicationTestRunId = Guid.NewGuid(), + Channel = (int)CommunicationTestChannel.Push, + SendAttempted = true, + SendSucceeded = true, + Responded = false, + ResponseToken = token + }; + + _communicationTestResultRepoMock.Setup(x => x.GetResultByResponseTokenAsync(token)).ReturnsAsync(result); + _communicationTestResultRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); + + var run = new CommunicationTestRun { CommunicationTestRunId = result.CommunicationTestRunId }; + _communicationTestRunRepoMock.Setup(x => x.GetByIdAsync(result.CommunicationTestRunId)).ReturnsAsync(run); + _communicationTestResultRepoMock.Setup(x => x.GetResultsByRunIdAsync(result.CommunicationTestRunId)).ReturnsAsync(new List { result }); + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => r); + + var success = await _communicationTestService.RecordPushResponseAsync(token); + + success.Should().BeTrue(); + result.Responded.Should().BeTrue(); + } + + [Test] + public async Task should_not_record_response_for_completed_run() + { + var run = new CommunicationTestRun + { + CommunicationTestRunId = Guid.NewGuid(), + Status = (int)CommunicationTestRunStatus.Completed, + RunCode = "CT-DONE" + }; + + _communicationTestRunRepoMock.Setup(x => x.GetRunByRunCodeAsync("CT-DONE")).ReturnsAsync(run); + + var success = await _communicationTestService.RecordSmsResponseAsync("CT-DONE", "5551234567"); + + success.Should().BeFalse(); + } + } + + [TestFixture] + public class when_completing_expired_runs : with_the_communication_test_service + { + [Test] + public async Task should_complete_expired_runs() + { + var testId = Guid.NewGuid(); + var run = new CommunicationTestRun + { + CommunicationTestRunId = Guid.NewGuid(), + CommunicationTestId = testId, + Status = (int)CommunicationTestRunStatus.AwaitingResponses, + StartedOn = DateTime.UtcNow.AddMinutes(-120) + }; + + var test = new CommunicationTest + { + CommunicationTestId = testId, + ResponseWindowMinutes = 60 + }; + + _communicationTestRunRepoMock.Setup(x => x.GetOpenRunsAsync()).ReturnsAsync(new List { run }); + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(test); + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => r); + + await _communicationTestService.CompleteExpiredRunsAsync(); + + run.Status.Should().Be((int)CommunicationTestRunStatus.Completed); + run.CompletedOn.Should().NotBeNull(); + } + } + + [TestFixture] + public class when_processing_scheduled_tests : with_the_communication_test_service + { + [Test] + public async Task should_process_weekly_test_on_matching_day() + { + var today = DateTime.UtcNow.DayOfWeek; + var test = new CommunicationTest + { + CommunicationTestId = Guid.NewGuid(), + DepartmentId = 1, + ScheduleType = (int)CommunicationTestScheduleType.Weekly, + Sunday = today == DayOfWeek.Sunday, + Monday = today == DayOfWeek.Monday, + Tuesday = today == DayOfWeek.Tuesday, + Wednesday = today == DayOfWeek.Wednesday, + Thursday = today == DayOfWeek.Thursday, + Friday = today == DayOfWeek.Friday, + Saturday = today == DayOfWeek.Saturday, + TestSms = true, + Active = true, + ResponseWindowMinutes = 60, + CreatedByUserId = TestData.Users.TestUser1Id + }; + + _communicationTestRepoMock.Setup(x => x.GetActiveTestsForScheduleTypeAsync((int)CommunicationTestScheduleType.Weekly)) + .ReturnsAsync(new List { test }); + _communicationTestRepoMock.Setup(x => x.GetActiveTestsForScheduleTypeAsync((int)CommunicationTestScheduleType.Monthly)) + .ReturnsAsync(new List()); + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(test.CommunicationTestId)).ReturnsAsync(test); + + _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(new List()); + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(new Dictionary()); + + _communicationTestRunRepoMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) => + { + r.CommunicationTestRunId = Guid.NewGuid(); + return r; + }); + + await _communicationTestService.ProcessScheduledTestsAsync(); + + _communicationTestRunRepoMock.Verify( + x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true), + Times.AtLeastOnce); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index 207347b2..81bd5892 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -42,12 +42,14 @@ public class SignalWireController : ControllerBase private readonly IDepartmentGroupsService _departmentGroupsService; private readonly ICustomStateService _customStateService; private readonly IUnitsService _unitsService; + private readonly ICommunicationTestService _communicationTestService; public SignalWireController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, - IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService) + IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, + ICommunicationTestService communicationTestService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -64,6 +66,7 @@ public SignalWireController(IDepartmentSettingsService departmentSettingsService _departmentGroupsService = departmentGroupsService; _customStateService = customStateService; _unitsService = unitsService; + _communicationTestService = communicationTestService; } #endregion Private Readonly Properties and Constructors @@ -109,6 +112,24 @@ public async Task Receive(CancellationToken cancellationToken) string response = ""; + // Check for Communication Test response (CT- prefix) + if (!string.IsNullOrWhiteSpace(textMessage.Text) && textMessage.Text.Trim().StartsWith("CT-", StringComparison.OrdinalIgnoreCase)) + { + var runCode = textMessage.Text.Trim().Split(' ')[0].ToUpperInvariant(); + await _communicationTestService.RecordSmsResponseAsync(runCode, textMessage.Msisdn); + messageEvent.Processed = true; + + response = LaMLResponse.Message.Respond("Resgrid received your communication test response. Thank you."); + + await _numbersService.SaveInboundMessageEventAsync(messageEvent); + return new ContentResult + { + Content = response, + ContentType = "application/xml", + StatusCode = 200 + }; + } + try { UserProfile profile = null; diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index f6752ecb..d2218ff3 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -44,13 +44,14 @@ public class TwilioController : ControllerBase private readonly IUnitsService _unitsService; private readonly IUsersService _usersService; private readonly ICalendarService _calendarService; + private readonly ICommunicationTestService _communicationTestService; public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, - IUsersService usersService, ICalendarService calendarService) + IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -69,6 +70,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN _unitsService = unitsService; _usersService = usersService; _calendarService = calendarService; + _communicationTestService = communicationTestService; } #endregion Private Readonly Properties and Constructors @@ -98,6 +100,24 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques messageEvent.Processed = false; messageEvent.CustomerId = ""; + // Check for Communication Test response (CT- prefix) + if (!string.IsNullOrWhiteSpace(textMessage.Text) && textMessage.Text.Trim().StartsWith("CT-", StringComparison.OrdinalIgnoreCase)) + { + var runCode = textMessage.Text.Trim().Split(' ')[0].ToUpperInvariant(); + await _communicationTestService.RecordSmsResponseAsync(runCode, textMessage.Msisdn); + messageEvent.Processed = true; + + response.Message("Resgrid received your communication test response. Thank you."); + + await _numbersService.SaveInboundMessageEventAsync(messageEvent); + return new ContentResult + { + Content = response.ToString(), + ContentType = "application/xml", + StatusCode = 200 + }; + } + try { UserProfile userProfile = null; diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs index 8fddfc68..fed9ca97 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs @@ -37,12 +37,14 @@ public class TwilioProviderController : ControllerBase private readonly IDepartmentGroupsService _departmentGroupsService; private readonly ICustomStateService _customStateService; private readonly IUnitsService _unitsService; + private readonly ICommunicationTestService _communicationTestService; public TwilioProviderController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, - IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService) + IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, + ICommunicationTestService communicationTestService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -59,6 +61,7 @@ public TwilioProviderController(IDepartmentSettingsService departmentSettingsSer _departmentGroupsService = departmentGroupsService; _customStateService = customStateService; _unitsService = unitsService; + _communicationTestService = communicationTestService; } #endregion Private Readonly Properties and Constructors @@ -87,6 +90,24 @@ public async Task IncomingMessage([FromQuery]TwilioMessage request messageEvent.Processed = false; messageEvent.CustomerId = ""; + // Check for Communication Test response (CT- prefix) + if (!string.IsNullOrWhiteSpace(textMessage.Text) && textMessage.Text.Trim().StartsWith("CT-", StringComparison.OrdinalIgnoreCase)) + { + var runCode = textMessage.Text.Trim().Split(' ')[0].ToUpperInvariant(); + await _communicationTestService.RecordSmsResponseAsync(runCode, textMessage.Msisdn); + messageEvent.Processed = true; + + response.Message("Resgrid received your communication test response. Thank you."); + + await _numbersService.SaveInboundMessageEventAsync(messageEvent); + return new ContentResult + { + Content = response.ToString(), + ContentType = "application/xml", + StatusCode = 200 + }; + } + try { var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallVideoFeedsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallVideoFeedsController.cs new file mode 100644 index 00000000..b3c27ab3 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallVideoFeedsController.cs @@ -0,0 +1,265 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using System.Threading.Tasks; +using Resgrid.Web.Services.Helpers; +using System.Linq; +using Resgrid.Model; +using Resgrid.Web.Helpers; +using Resgrid.Web.Services.Models.v4.CallVideoFeeds; +using System; +using Resgrid.Model.Helpers; +using System.Net.Mime; +using System.Globalization; +using System.Threading; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Video feeds attached to calls for live video monitoring during incidents + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class CallVideoFeedsController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly ICallsService _callsService; + private readonly IDepartmentsService _departmentsService; + + public CallVideoFeedsController(ICallsService callsService, IDepartmentsService departmentsService) + { + _callsService = callsService; + _departmentsService = departmentsService; + } + #endregion Members and Constructors + + /// + /// Get video feeds for a call + /// + /// CallId of the call you want to get video feeds for + /// + [HttpGet("GetCallVideoFeeds")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetCallVideoFeeds(string callId) + { + if (String.IsNullOrWhiteSpace(callId) || !int.TryParse(callId, out var cId)) + return BadRequest(); + + var result = new CallVideoFeedsResult(); + + var call = await _callsService.GetCallByIdAsync(cId); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + + if (call == null) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + + if (call.DepartmentId != DepartmentId) + return Unauthorized(); + + var feeds = await _callsService.GetCallVideoFeedsByCallIdAsync(cId); + + if (feeds != null && feeds.Any()) + { + foreach (var feed in feeds.Where(f => !f.IsDeleted)) + { + var fullName = await UserHelper.GetFullNameForUser(feed.AddedByUserId); + result.Data.Add(ConvertCallVideoFeed(feed, fullName, department)); + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + } + else + { + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + } + + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Saves a video feed to a call + /// + /// Video feed data + /// The cancellation token + /// ActionResult. + [HttpPost("SaveCallVideoFeed")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_Create)] + public async Task> SaveCallVideoFeed(SaveCallVideoFeedInput input, CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + return BadRequest(); + + if (!int.TryParse(input.CallId, out var parsedCallId)) + return BadRequest(); + + var call = await _callsService.GetCallByIdAsync(parsedCallId); + + if (call == null) + return BadRequest(); + + if (call.DepartmentId != DepartmentId) + return Unauthorized(); + + var result = new SaveCallVideoFeedResult(); + + var feed = new CallVideoFeed(); + feed.CallVideoFeedId = Guid.NewGuid().ToString(); + feed.CallId = parsedCallId; + feed.DepartmentId = DepartmentId; + feed.Name = input.Name; + feed.Url = input.Url; + feed.FeedType = input.FeedType; + feed.FeedFormat = input.FeedFormat; + feed.Description = input.Description; + feed.Status = (int)CallVideoFeedStatuses.Active; + feed.AddedByUserId = UserId; + feed.AddedOn = DateTime.UtcNow; + feed.SortOrder = input.SortOrder; + + if (!String.IsNullOrWhiteSpace(input.Latitude) && !String.IsNullOrWhiteSpace(input.Longitude)) + { + if (!decimal.TryParse(input.Latitude, NumberStyles.Number, CultureInfo.InvariantCulture, out var lat) || + !decimal.TryParse(input.Longitude, NumberStyles.Number, CultureInfo.InvariantCulture, out var lng)) + return BadRequest(); + + feed.Latitude = lat; + feed.Longitude = lng; + } + + var saved = await _callsService.SaveCallVideoFeedAsync(feed, cancellationToken); + + result.Id = saved.CallVideoFeedId; + result.PageSize = 0; + result.Status = ResponseHelper.Created; + ResponseHelper.PopulateV4ResponseData(result); + + return CreatedAtAction(nameof(GetCallVideoFeeds), new { callId = saved.CallId }, result); + } + + /// + /// Updates an existing video feed + /// + /// Video feed data with Id + /// The cancellation token + /// ActionResult. + [HttpPut("EditCallVideoFeed")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_Update)] + public async Task> EditCallVideoFeed(EditCallVideoFeedInput input, CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + return BadRequest(); + + var feed = await _callsService.GetCallVideoFeedByIdAsync(input.CallVideoFeedId); + + if (feed == null) + return BadRequest(); + + if (feed.DepartmentId != DepartmentId) + return Unauthorized(); + + var result = new SaveCallVideoFeedResult(); + + feed.Name = input.Name; + feed.Url = input.Url; + feed.FeedType = input.FeedType; + feed.FeedFormat = input.FeedFormat; + feed.Description = input.Description; + feed.Status = input.Status; + feed.SortOrder = input.SortOrder; + feed.UpdatedOn = DateTime.UtcNow; + + if (!String.IsNullOrWhiteSpace(input.Latitude) && !String.IsNullOrWhiteSpace(input.Longitude)) + { + if (!decimal.TryParse(input.Latitude, NumberStyles.Number, CultureInfo.InvariantCulture, out var lat) || + !decimal.TryParse(input.Longitude, NumberStyles.Number, CultureInfo.InvariantCulture, out var lng)) + return BadRequest(); + + feed.Latitude = lat; + feed.Longitude = lng; + } + + var saved = await _callsService.SaveCallVideoFeedAsync(feed, cancellationToken); + + result.Id = saved.CallVideoFeedId; + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Soft deletes a video feed + /// + /// The video feed Id to delete + /// The cancellation token + /// ActionResult. + [HttpDelete("DeleteCallVideoFeed")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_Delete)] + public async Task> DeleteCallVideoFeed(string callVideoFeedId, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(callVideoFeedId)) + return BadRequest(); + + var feed = await _callsService.GetCallVideoFeedByIdAsync(callVideoFeedId); + + if (feed == null) + return BadRequest(); + + if (feed.DepartmentId != DepartmentId) + return Unauthorized(); + + var result = new DeleteCallVideoFeedResult(); + + await _callsService.DeleteCallVideoFeedAsync(feed, UserId, cancellationToken); + + result.PageSize = 0; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + public static CallVideoFeedResultData ConvertCallVideoFeed(CallVideoFeed feed, string fullName, Department department) + { + var feedResult = new CallVideoFeedResultData(); + feedResult.CallVideoFeedId = feed.CallVideoFeedId; + feedResult.CallId = feed.CallId.ToString(); + feedResult.Name = feed.Name; + feedResult.Url = feed.Url; + feedResult.FeedType = feed.FeedType; + feedResult.FeedFormat = feed.FeedFormat; + feedResult.Description = feed.Description; + feedResult.Status = feed.Status; + feedResult.Latitude = feed.Latitude; + feedResult.Longitude = feed.Longitude; + feedResult.AddedByUserId = feed.AddedByUserId; + feedResult.AddedOnFormatted = feed.AddedOn.TimeConverter(department).FormatForDepartment(department); + feedResult.AddedOnUtc = feed.AddedOn; + feedResult.SortOrder = feed.SortOrder; + feedResult.FullName = fullName; + + return feedResult; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 9fbda5d7..ca50d47a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -52,6 +52,7 @@ public class CallsController : V4AuthenticatedApiControllerbase private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IShiftsService _shiftsService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; + private readonly ICommunicationService _communicationService; public CallsController( ICallsService callsService, @@ -70,7 +71,8 @@ public CallsController( ICustomStateService customStateService, IDepartmentSettingsService departmentSettingsService, IShiftsService shiftsService, - IUserDefinedFieldsService userDefinedFieldsService + IUserDefinedFieldsService userDefinedFieldsService, + ICommunicationService communicationService ) { _callsService = callsService; @@ -90,6 +92,7 @@ IUserDefinedFieldsService userDefinedFieldsService _departmentSettingsService = departmentSettingsService; _shiftsService = shiftsService; _userDefinedFieldsService = userDefinedFieldsService; + _communicationService = communicationService; } #endregion Members and Constructors @@ -966,6 +969,12 @@ public async Task> EditCall([FromBody] EditCallInpu } } + // Capture existing dispatch snapshots for cancel notification diffing + var existingDispatches = new List(call.Dispatches ?? new List()); + var existingGroupDispatches = new List(call.GroupDispatches ?? new List()); + var existingUnitDispatches = new List(call.UnitDispatches ?? new List()); + var existingRoleDispatches = new List(call.RoleDispatches ?? new List()); + if (string.IsNullOrWhiteSpace(editCallInput.DispatchList) || editCallInput.DispatchList == "0") { if (call.Dispatches == null) @@ -1121,6 +1130,116 @@ public async Task> EditCall([FromBody] EditCallInpu await _callsService.SaveCallAsync(call, cancellationToken); + // Send cancel notifications to removed entities + if (editCallInput.NotifyCancelledEntities) + { + var currentUserIds = call.Dispatches?.Select(x => x.UserId).ToList() ?? new List(); + var currentGroupIds = call.GroupDispatches?.Select(x => x.DepartmentGroupId).ToList() ?? new List(); + var currentUnitIds = call.UnitDispatches?.Select(x => x.UnitId).ToList() ?? new List(); + var currentRoleIds = call.RoleDispatches?.Select(x => x.RoleId).ToList() ?? new List(); + + var cancelledUserIds = existingDispatches.Select(x => x.UserId) + .Where(y => !currentUserIds.Contains(y)).ToList(); + var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) + .Where(y => !currentGroupIds.Contains(y)).ToList(); + var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) + .Where(y => !currentUnitIds.Contains(y)).ToList(); + var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) + .Where(y => !currentRoleIds.Contains(y)).ToList(); + + if (cancelledUserIds.Any() || cancelledGroupIds.Any() || cancelledUnitIds.Any() || cancelledRoleIds.Any()) + { + var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); + + // Build set of still-dispatched user IDs for dedup + var stillDispatchedUserIds = new HashSet(currentUserIds); + foreach (var gd in call.GroupDispatches) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(gd.DepartmentGroupId); + foreach (var m in members) stillDispatchedUserIds.Add(m.UserId); + } + foreach (var rd in call.RoleDispatches) + { + var members = await _personnelRolesService.GetAllMembersOfRoleAsync(rd.RoleId); + foreach (var m in members) stillDispatchedUserIds.Add(m.UserId); + } + + var notifiedUserIds = new HashSet(); + + // Cancel personnel + foreach (var userId in cancelledUserIds) + { + if (!stillDispatchedUserIds.Contains(userId) && notifiedUserIds.Add(userId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = userId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + + // Cancel group members + foreach (var groupId in cancelledGroupIds) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId); + foreach (var member in members) + { + if (!stillDispatchedUserIds.Contains(member.UserId) && notifiedUserIds.Add(member.UserId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = member.UserId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + } + + // Cancel role members + foreach (var roleId in cancelledRoleIds) + { + var members = await _personnelRolesService.GetAllMembersOfRoleAsync(roleId); + foreach (var member in members) + { + if (!stillDispatchedUserIds.Contains(member.UserId) && notifiedUserIds.Add(member.UserId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = member.UserId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + } + + // Cancel units + foreach (var unitId in cancelledUnitIds) + { + var cdu = new CallDispatchUnit { CallId = call.CallId, UnitId = unitId }; + await _communicationService.SendCancelUnitCallAsync(call, cdu, departmentNumber); + } + } + } + + // Auto-dispatch newly added entities when RebroadcastCall is not checked + if (!editCallInput.RebroadcastCall) + { + var currentUserIds2 = call.Dispatches?.Select(x => x.UserId).ToList() ?? new List(); + var currentGroupIds2 = call.GroupDispatches?.Select(x => x.DepartmentGroupId).ToList() ?? new List(); + var currentUnitIds2 = call.UnitDispatches?.Select(x => x.UnitId).ToList() ?? new List(); + var currentRoleIds2 = call.RoleDispatches?.Select(x => x.RoleId).ToList() ?? new List(); + + var newUserIds = currentUserIds2.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); + var newGroupIds = currentGroupIds2.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); + var newUnitIds = currentUnitIds2.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); + var newRoleIds = currentRoleIds2.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); + + if (newUserIds.Any() || newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) + { + var cqi = new CallQueueItem(); + cqi.Call = call; + + if (newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) + cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId)).Select(x => x.Value).ToList(); + else + cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(newUserIds); + + await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + } + } + if (editCallInput.RebroadcastCall) { var cqi = new CallQueueItem(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs new file mode 100644 index 00000000..fd5e6394 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Public endpoints for communication test responses (email confirm, voice webhook) + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class CommunicationTestResponseController : V4AuthenticatedApiControllerbase + { + private readonly ICommunicationTestService _communicationTestService; + + public CommunicationTestResponseController(ICommunicationTestService communicationTestService) + { + _communicationTestService = communicationTestService; + } + + /// + /// Email confirmation endpoint - user clicks link with token to confirm receipt + /// + [HttpGet("EmailConfirm")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task EmailConfirm(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return new ContentResult + { + Content = "

Invalid request.

", + ContentType = "text/html", + StatusCode = 400 + }; + } + + var success = await _communicationTestService.RecordEmailResponseAsync(token); + + var html = success + ? "

Thank you!

Your communication test response has been recorded.

" + : "

Response not found.

This link may have already been used or has expired.

"; + + return new ContentResult + { + Content = html, + ContentType = "text/html", + StatusCode = 200 + }; + } + + /// + /// Voice webhook endpoint - receives DTMF keypress callbacks + /// + [HttpPost("VoiceWebhook")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task VoiceWebhook(string token, string Digits) + { + if (!string.IsNullOrWhiteSpace(token) && Digits == "1") + { + await _communicationTestService.RecordVoiceResponseAsync(token); + } + + return new ContentResult + { + Content = "Thank you. Your response has been recorded.", + ContentType = "application/xml", + StatusCode = 200 + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs new file mode 100644 index 00000000..3f04520a --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs @@ -0,0 +1,496 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +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.CommunicationTests; +using Resgrid.Web.ServicesCore.Helpers; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Communication Test management - CRUD, run tests, get reports. Admin only. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class CommunicationTestsController : V4AuthenticatedApiControllerbase + { + private readonly ICommunicationTestService _communicationTestService; + private readonly IUserProfileService _userProfileService; + private readonly IEventAggregator _eventAggregator; + + public CommunicationTestsController( + ICommunicationTestService communicationTestService, + IUserProfileService userProfileService, + IEventAggregator eventAggregator) + { + _communicationTestService = communicationTestService; + _userProfileService = userProfileService; + _eventAggregator = eventAggregator; + } + + /// + /// Gets all communication tests for the current department + /// + [HttpGet("GetAll")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_View)] + public async Task GetAll() + { + var result = new GetCommunicationTestsResult(); + + var tests = await _communicationTestService.GetTestsByDepartmentIdAsync(DepartmentId); + if (tests != null) + { + foreach (var test in tests) + { + result.Data.Add(new CommunicationTestData + { + Id = test.CommunicationTestId.ToString(), + Name = test.Name, + Description = test.Description, + ScheduleType = test.ScheduleType, + Sunday = test.Sunday, + Monday = test.Monday, + Tuesday = test.Tuesday, + Wednesday = test.Wednesday, + Thursday = test.Thursday, + Friday = test.Friday, + Saturday = test.Saturday, + DayOfMonth = test.DayOfMonth, + Time = test.Time, + TestSms = test.TestSms, + TestEmail = test.TestEmail, + TestVoice = test.TestVoice, + TestPush = test.TestPush, + Active = test.Active, + ResponseWindowMinutes = test.ResponseWindowMinutes, + CreatedOn = test.CreatedOn.ToString("O") + }); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Gets a specific communication test by id + /// + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_View)] + public async Task Get(string id) + { + var result = new GetCommunicationTestResult(); + + if (!Guid.TryParse(id, out var testId)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var test = await _communicationTestService.GetTestByIdAsync(testId); + if (test == null || test.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = new CommunicationTestData + { + Id = test.CommunicationTestId.ToString(), + Name = test.Name, + Description = test.Description, + ScheduleType = test.ScheduleType, + Sunday = test.Sunday, + Monday = test.Monday, + Tuesday = test.Tuesday, + Wednesday = test.Wednesday, + Thursday = test.Thursday, + Friday = test.Friday, + Saturday = test.Saturday, + DayOfMonth = test.DayOfMonth, + Time = test.Time, + TestSms = test.TestSms, + TestEmail = test.TestEmail, + TestVoice = test.TestVoice, + TestPush = test.TestPush, + Active = test.Active, + ResponseWindowMinutes = test.ResponseWindowMinutes, + CreatedOn = test.CreatedOn.ToString("O") + }; + + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Saves a communication test definition (admin only). + /// Creates require CommunicationTest_Create; updates require CommunicationTest_Update. + /// + [HttpPost("Save")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_Create)] + public async Task Save([FromBody] SaveCommunicationTestInput input, CancellationToken cancellationToken) + { + var result = new SaveCommunicationTestResult(); + bool isNew = true; + string beforeJson = null; + + Guid? excludeId = null; + if (!string.IsNullOrWhiteSpace(input.Id) && Guid.TryParse(input.Id, out var parsedExclude)) + excludeId = parsedExclude; + + if (excludeId.HasValue) + { + if (!User.HasClaim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.Update)) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } + + if (!await _communicationTestService.CanCreateScheduledTestAsync(DepartmentId, input.ScheduleType, excludeId)) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + CommunicationTest test; + if (excludeId.HasValue) + { + test = await _communicationTestService.GetTestByIdAsync(excludeId.Value); + if (test == null || test.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + isNew = false; + beforeJson = test.CloneJsonToString(); + test.UpdatedOn = DateTime.UtcNow; + } + else + { + test = new CommunicationTest + { + DepartmentId = DepartmentId, + CreatedByUserId = UserId, + CreatedOn = DateTime.UtcNow + }; + } + + test.Name = input.Name; + test.Description = input.Description; + test.ScheduleType = input.ScheduleType; + test.Sunday = input.Sunday; + test.Monday = input.Monday; + test.Tuesday = input.Tuesday; + test.Wednesday = input.Wednesday; + test.Thursday = input.Thursday; + test.Friday = input.Friday; + test.Saturday = input.Saturday; + test.DayOfMonth = input.DayOfMonth; + test.Time = input.Time; + test.TestSms = input.TestSms; + test.TestEmail = input.TestEmail; + test.TestVoice = input.TestVoice; + test.TestPush = input.TestPush; + test.Active = input.Active; + test.ResponseWindowMinutes = input.ResponseWindowMinutes > 0 ? input.ResponseWindowMinutes : 60; + + test = await _communicationTestService.SaveTestAsync(test, cancellationToken); + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = isNew ? AuditLogTypes.CommunicationTestCreated : AuditLogTypes.CommunicationTestUpdated, + Before = beforeJson, + After = test.CloneJsonToString(), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + result.Id = test.CommunicationTestId.ToString(); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Deletes a communication test (admin only) + /// + [HttpDelete("Delete")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_Delete)] + public async Task Delete(string id, CancellationToken cancellationToken) + { + var result = new StandardApiResponseV4Base(); + + if (!Guid.TryParse(id, out var testId)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var test = await _communicationTestService.GetTestByIdAsync(testId); + if (test == null || test.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var beforeJson = test.CloneJsonToString(); + + await _communicationTestService.DeleteTestAsync(testId, cancellationToken); + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestDeleted, + Before = beforeJson, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + result.Status = ResponseHelper.Deleted; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Starts a new on-demand test run (admin only, rate limited to once per 48 hours) + /// + [HttpPost("StartRun")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_Create)] + public async Task StartRun(string testId, CancellationToken cancellationToken) + { + var result = new StartTestRunResult(); + + if (!Guid.TryParse(testId, out var id)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var test = await _communicationTestService.GetTestByIdAsync(id); + if (test == null || test.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + if (test.ScheduleType != (int)CommunicationTestScheduleType.OnDemand) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + // Check 48-hour rate limit for on-demand tests + if (!await _communicationTestService.CanStartOnDemandRunAsync(id)) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var run = await _communicationTestService.StartTestRunAsync(id, DepartmentId, UserId, cancellationToken); + if (run == null) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestRunStarted, + After = run.CloneJsonToString(), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + result.Id = run.CommunicationTestRunId.ToString(); + result.RunCode = run.RunCode; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Gets test runs for a specific test + /// + [HttpGet("GetRuns")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_View)] + public async Task GetRuns(string testId) + { + var result = new GetTestRunsResult(); + + if (!Guid.TryParse(testId, out var id)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var runs = await _communicationTestService.GetRunsByTestIdAsync(id); + if (runs != null) + { + foreach (var run in runs) + { + if (run.DepartmentId != DepartmentId) + continue; + + result.Data.Add(new TestRunData + { + Id = run.CommunicationTestRunId.ToString(), + CommunicationTestId = run.CommunicationTestId.ToString(), + StartedOn = run.StartedOn.ToString("O"), + CompletedOn = run.CompletedOn?.ToString("O"), + Status = run.Status, + RunCode = run.RunCode, + TotalUsersTested = run.TotalUsersTested, + TotalResponses = run.TotalResponses + }); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Gets the report for a specific test run + /// + [HttpGet("GetReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.CommunicationTest_View)] + public async Task GetReport(string runId) + { + var result = new GetTestRunReportResult(); + + if (!Guid.TryParse(runId, out var id)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var run = await _communicationTestService.GetRunByIdAsync(id); + if (run == null || run.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var results = await _communicationTestService.GetResultsByRunIdAsync(id); + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + + if (results != null) + { + foreach (var r in results) + { + string userName = r.UserId; + if (profiles.TryGetValue(r.UserId, out var profile)) + userName = $"{profile.FirstName} {profile.LastName}".Trim(); + + result.Data.Add(new CommunicationTestResultData + { + Id = r.CommunicationTestResultId.ToString(), + UserId = r.UserId, + UserName = userName, + Channel = r.Channel, + ContactValue = r.ContactValue, + ContactCarrier = r.ContactCarrier, + VerificationStatus = r.VerificationStatus, + SendAttempted = r.SendAttempted, + SendSucceeded = r.SendSucceeded, + SentOn = r.SentOn?.ToString("O"), + Responded = r.Responded, + RespondedOn = r.RespondedOn?.ToString("O") + }); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + + /// + /// Records a push notification response for a communication test + /// + [HttpPost("RecordPushResponse")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RecordPushResponse([FromBody] RecordPushResponseInput input) + { + var result = new RecordPushResponseResult(); + + if (string.IsNullOrWhiteSpace(input?.ResponseToken)) + { + result.Status = ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var success = await _communicationTestService.RecordPushResponseAsync(input.ResponseToken); + + result.Status = success ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/CallVideoFeedsResult.cs b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/CallVideoFeedsResult.cs new file mode 100644 index 00000000..9b0d3346 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/CallVideoFeedsResult.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.CallVideoFeeds +{ + public class CallVideoFeedsResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public CallVideoFeedsResult() + { + Data = new List(); + } + } + + public class CallVideoFeedResultData + { + public string CallVideoFeedId { get; set; } + public string CallId { get; set; } + public string Name { get; set; } + public string Url { get; set; } + public int? FeedType { get; set; } + public int? FeedFormat { get; set; } + public string Description { get; set; } + public int Status { get; set; } + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + public string AddedByUserId { get; set; } + public string AddedOnFormatted { get; set; } + public DateTime AddedOnUtc { get; set; } + public int SortOrder { get; set; } + public string FullName { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/DeleteCallVideoFeedResult.cs b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/DeleteCallVideoFeedResult.cs new file mode 100644 index 00000000..ee994531 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/DeleteCallVideoFeedResult.cs @@ -0,0 +1,6 @@ +namespace Resgrid.Web.Services.Models.v4.CallVideoFeeds +{ + public class DeleteCallVideoFeedResult : StandardApiResponseV4Base + { + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/EditCallVideoFeedInput.cs b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/EditCallVideoFeedInput.cs new file mode 100644 index 00000000..cdd17890 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/EditCallVideoFeedInput.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.CallVideoFeeds +{ + public class EditCallVideoFeedInput + { + [Required] + public string CallVideoFeedId { get; set; } + + [Required] + public string CallId { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string Url { get; set; } + + [Range(0, int.MaxValue)] + public int? FeedType { get; set; } + + [Range(0, int.MaxValue)] + public int? FeedFormat { get; set; } + + public string Description { get; set; } + + [Range(0, int.MaxValue)] + public int Status { get; set; } + + public string Latitude { get; set; } + + public string Longitude { get; set; } + + [Range(0, int.MaxValue)] + public int SortOrder { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedInput.cs b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedInput.cs new file mode 100644 index 00000000..250c4390 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedInput.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Services.Models.v4.CallVideoFeeds +{ + public class SaveCallVideoFeedInput + { + [Required] + public string CallId { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string Url { get; set; } + + [Range(0, int.MaxValue)] + public int? FeedType { get; set; } + + [Range(0, int.MaxValue)] + public int? FeedFormat { get; set; } + + public string Description { get; set; } + + public string Latitude { get; set; } + + public string Longitude { get; set; } + + [Range(0, int.MaxValue)] + public int SortOrder { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedResult.cs b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedResult.cs new file mode 100644 index 00000000..6b6142a2 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CallVideoFeeds/SaveCallVideoFeedResult.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Web.Services.Models.v4.CallVideoFeeds +{ + public class SaveCallVideoFeedResult : StandardApiResponseV4Base + { + public string Id { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 8383b8af..6e7549c2 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -103,6 +103,11 @@ public class EditCallInput /// public bool RebroadcastCall { get; set; } + /// + /// If true, entities removed from the dispatch list will receive a cancellation notification + /// + public bool NotifyCancelledEntities { get; set; } + /// /// User Defined Field values for this call /// diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetCommunicationTestsResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetCommunicationTestsResult.cs new file mode 100644 index 00000000..a47c5efb --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetCommunicationTestsResult.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Result of getting all communication tests for a department +/// +public class GetCommunicationTestsResult : StandardApiResponseV4Base +{ + /// + /// List of communication test definitions + /// + public List Data { get; set; } = new List(); +} + +/// +/// Communication test definition data +/// +public class CommunicationTestData +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int ScheduleType { get; set; } + public bool Sunday { get; set; } + public bool Monday { get; set; } + public bool Tuesday { get; set; } + public bool Wednesday { get; set; } + public bool Thursday { get; set; } + public bool Friday { get; set; } + public bool Saturday { get; set; } + public int? DayOfMonth { get; set; } + public string Time { get; set; } + public bool TestSms { get; set; } + public bool TestEmail { get; set; } + public bool TestVoice { get; set; } + public bool TestPush { get; set; } + public bool Active { get; set; } + public int ResponseWindowMinutes { get; set; } + public string CreatedOn { get; set; } +} + +/// +/// Result of getting a single communication test +/// +public class GetCommunicationTestResult : StandardApiResponseV4Base +{ + public CommunicationTestData Data { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs new file mode 100644 index 00000000..3dbdeb48 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Result of getting a test run report +/// +public class GetTestRunReportResult : StandardApiResponseV4Base +{ + public List Data { get; set; } = new List(); +} + +/// +/// Individual test result data for report +/// +public class CommunicationTestResultData +{ + public string Id { get; set; } + public string UserId { get; set; } + public string UserName { get; set; } + + /// + /// Channel type (0=Sms, 1=Email, 2=Voice, 3=Push) + /// + public int Channel { get; set; } + + public string ContactValue { get; set; } + public string ContactCarrier { get; set; } + public int VerificationStatus { get; set; } + public bool SendAttempted { get; set; } + public bool SendSucceeded { get; set; } + public string SentOn { get; set; } + public bool Responded { get; set; } + public string RespondedOn { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunsResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunsResult.cs new file mode 100644 index 00000000..6ed9672f --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunsResult.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Result of getting test runs +/// +public class GetTestRunsResult : StandardApiResponseV4Base +{ + public List Data { get; set; } = new List(); +} + +/// +/// Test run summary data +/// +public class TestRunData +{ + public string Id { get; set; } + public string CommunicationTestId { get; set; } + public string StartedOn { get; set; } + public string CompletedOn { get; set; } + public int Status { get; set; } + public string RunCode { get; set; } + public int TotalUsersTested { get; set; } + public int TotalResponses { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/RecordPushResponseInput.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/RecordPushResponseInput.cs new file mode 100644 index 00000000..f5d193dc --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/RecordPushResponseInput.cs @@ -0,0 +1,19 @@ +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Input for recording a push notification response +/// +public class RecordPushResponseInput +{ + /// + /// The response token from the push notification + /// + public string ResponseToken { get; set; } +} + +/// +/// Result of recording a push notification response +/// +public class RecordPushResponseResult : StandardApiResponseV4Base +{ +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestInput.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestInput.cs new file mode 100644 index 00000000..f96cb6bd --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestInput.cs @@ -0,0 +1,57 @@ +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Input model for creating or updating a communication test +/// +public class SaveCommunicationTestInput +{ + /// + /// The Id of the communication test (empty or null for new tests) + /// + public string Id { get; set; } + + /// + /// Name of the communication test + /// + public string Name { get; set; } + + /// + /// Description of the communication test + /// + public string Description { get; set; } + + /// + /// Schedule type (0=OnDemand, 1=Weekly, 2=Monthly) + /// + public int ScheduleType { get; set; } + + public bool Sunday { get; set; } + public bool Monday { get; set; } + public bool Tuesday { get; set; } + public bool Wednesday { get; set; } + public bool Thursday { get; set; } + public bool Friday { get; set; } + public bool Saturday { get; set; } + + /// + /// Day of month for monthly schedule (1-28) + /// + public int? DayOfMonth { get; set; } + + /// + /// Time of day for scheduled tests (e.g. "09:00 AM") + /// + public string Time { get; set; } + + public bool TestSms { get; set; } + public bool TestEmail { get; set; } + public bool TestVoice { get; set; } + public bool TestPush { get; set; } + + public bool Active { get; set; } + + /// + /// Response window in minutes (default 60) + /// + public int ResponseWindowMinutes { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestResult.cs new file mode 100644 index 00000000..4243e30d --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/SaveCommunicationTestResult.cs @@ -0,0 +1,12 @@ +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Result of saving a communication test +/// +public class SaveCommunicationTestResult : StandardApiResponseV4Base +{ + /// + /// Id of the saved communication test + /// + public string Id { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/StartTestRunResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/StartTestRunResult.cs new file mode 100644 index 00000000..637c7bff --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/StartTestRunResult.cs @@ -0,0 +1,17 @@ +namespace Resgrid.Web.Services.Models.v4.CommunicationTests; + +/// +/// Result of starting a communication test run +/// +public class StartTestRunResult : StandardApiResponseV4Base +{ + /// + /// Id of the new test run + /// + public string Id { get; set; } + + /// + /// The run code used for SMS responses + /// + public string RunCode { get; set; } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index d6bb449b..e5920b6f 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -328,6 +328,42 @@ + + + Video feeds attached to calls for live video monitoring during incidents + + + + + Get video feeds for a call + + CallId of the call you want to get video feeds for + + + + + Saves a video feed to a call + + Video feed data + The cancellation token + ActionResult. + + + + Updates an existing video feed + + Video feed data with Id + The cancellation token + ActionResult. + + + + Soft deletes a video feed + + The video feed Id to delete + The cancellation token + ActionResult. + Check-in timer operations for call accountability @@ -388,6 +424,66 @@ Enables or disables check-in timers on a call + + + Public endpoints for communication test responses (email confirm, voice webhook) + + + + + Email confirmation endpoint - user clicks link with token to confirm receipt + + + + + Voice webhook endpoint - receives DTMF keypress callbacks + + + + + Communication Test management - CRUD, run tests, get reports. Admin only. + + + + + Gets all communication tests for the current department + + + + + Gets a specific communication test by id + + + + + Saves a communication test definition (admin only) + + + + + Deletes a communication test (admin only) + + + + + Starts a new on-demand test run (admin only, rate limited to once per 48 hours) + + + + + Gets test runs for a specific test + + + + + Gets the report for a specific test run + + + + + Records a push notification response for a communication test + + Generic configuration api endpoints @@ -5143,6 +5239,11 @@ Should all the entities attached to the call be re-notified + + + If true, entities removed from the dispatch list will receive a cancellation notification + + User Defined Field values for this call @@ -5593,6 +5694,131 @@ User Defined Field values for this contact + + + Result of getting all communication tests for a department + + + + + List of communication test definitions + + + + + Communication test definition data + + + + + Result of getting a single communication test + + + + + Result of getting a test run report + + + + + Individual test result data for report + + + + + Channel type (0=Sms, 1=Email, 2=Voice, 3=Push) + + + + + Result of getting test runs + + + + + Test run summary data + + + + + Input for recording a push notification response + + + + + The response token from the push notification + + + + + Result of recording a push notification response + + + + + Input model for creating or updating a communication test + + + + + The Id of the communication test (empty or null for new tests) + + + + + Name of the communication test + + + + + Description of the communication test + + + + + Schedule type (0=OnDemand, 1=Weekly, 2=Monthly) + + + + + Day of month for monthly schedule (1-28) + + + + + Time of day for scheduled tests (e.g. "09:00 AM") + + + + + Response window in minutes (default 60) + + + + + Result of saving a communication test + + + + + Id of the saved communication test + + + + + Result of starting a communication test run + + + + + Id of the new test run + + + + + The run code used for SMS responses + + Gets Configuration Information by a key diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index c947c3f2..a898ffe8 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -203,18 +203,6 @@ public void ConfigureServices(IServiceCollection services) {securityScheme, new string[] { }} }); - //options.SwaggerDoc("v3", - - // new OpenApiInfo - // { - // Title = "Resgrid API", - // Version = "v3", - // Description = "The Resgrid Computer Aided Dispatch (CAD) API reference. Documentation: https://resgrid-core.readthedocs.io/en/latest/api/index.html", - // Contact = new OpenApiContact() { Email = "team@resgrid.com", Name = "Resgrid Team", Url = new Uri("https://resgrid.com") }, - // TermsOfService = new Uri("https://resgrid.com/Public/Terms") - // } - //); - options.SwaggerDoc("v4", new OpenApiInfo @@ -414,6 +402,11 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Route_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Route_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Create)); options.AddPolicy(ResgridResources.Route_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Route, ResgridClaimTypes.Actions.Delete)); + + options.AddPolicy(ResgridResources.CommunicationTest_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.CommunicationTest, ResgridClaimTypes.Actions.View)); + 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)); }); #endregion Auth Roles diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index e8761ddb..07b0d391 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -208,6 +208,12 @@ public async Task Edit(int id) ViewBag.Types = new SelectList(model.Types, "CalendarItemTypeId", "Name"); model.Item.Description = StringHelpers.StripHtmlTagsCharArray(model.Item.Description); + if (model.Item.RecurrenceType == 2 && model.Item.RepeatOnWeek > 0) + { + model.WeekdayOccurrence = model.Item.RepeatOnWeek; + model.WeekdayDayOfWeek = model.Item.RepeatOnDay; + } + return View(model); } @@ -249,6 +255,12 @@ public async Task Edit(EditCalendarEntry model, CancellationToken model.Item.CreatorUserId = UserId; model.Item.Entities = model.entities; + if (model.Item.RecurrenceType == 2) + { + model.Item.RepeatOnWeek = model.WeekdayOccurrence; + model.Item.RepeatOnDay = model.WeekdayDayOfWeek; + } + await _calendarService.UpdateCalendarItemAsync(model.Item, department.TimeZone, cancellationToken); // Add new attendees from entities and notify only the newly added ones diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CommunicationTestController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CommunicationTestController.cs new file mode 100644 index 00000000..e11b2ce4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/CommunicationTestController.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.CommunicationTests; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [Area("User")] + [Authorize] + public class CommunicationTestController : Resgrid.Web.SecureBaseController + { + private readonly ICommunicationTestService _communicationTestService; + private readonly IUserProfileService _userProfileService; + private readonly IEventAggregator _eventAggregator; + + public CommunicationTestController( + ICommunicationTestService communicationTestService, + IUserProfileService userProfileService, + IEventAggregator eventAggregator) + { + _communicationTestService = communicationTestService; + _userProfileService = userProfileService; + _eventAggregator = eventAggregator; + } + + [HttpGet] + public async Task Index() + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var model = new CommunicationTestIndexView(); + + var tests = await _communicationTestService.GetTestsByDepartmentIdAsync(DepartmentId); + if (tests != null) + model.Tests = tests.ToList(); + + var runs = await _communicationTestService.GetRunsByDepartmentIdAsync(DepartmentId); + if (runs != null) + { + model.RecentRuns = runs.OrderByDescending(r => r.StartedOn).Take(20).ToList(); + foreach (var test in model.Tests) + { + model.TestNames[test.CommunicationTestId.ToString()] = test.Name; + } + } + + return View(model); + } + + [HttpGet] + public IActionResult New() + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var model = new NewCommunicationTestView + { + Test = new CommunicationTest + { + Active = true, + ResponseWindowMinutes = 60, + TestSms = true, + TestEmail = true, + TestPush = true + } + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task New(NewCommunicationTestView model, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (string.IsNullOrWhiteSpace(model.Test.Name)) + { + ModelState.AddModelError("Test.Name", "Name is required."); + return View(model); + } + + if (!await _communicationTestService.CanCreateScheduledTestAsync(DepartmentId, model.Test.ScheduleType)) + { + var typeLabel = model.Test.ScheduleType == (int)CommunicationTestScheduleType.Weekly ? "weekly" : "monthly"; + model.Message = $"Only one {typeLabel} test is allowed per department. Please edit the existing one instead."; + return View(model); + } + + model.Test.DepartmentId = DepartmentId; + model.Test.CreatedByUserId = UserId; + model.Test.CreatedOn = DateTime.UtcNow; + if (model.Test.ResponseWindowMinutes <= 0) + model.Test.ResponseWindowMinutes = 60; + + var saved = await _communicationTestService.SaveTestAsync(model.Test, cancellationToken); + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestCreated, + After = saved.CloneJsonToString(), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + return RedirectToAction("Index"); + } + + [HttpGet] + public async Task Edit(string testId) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (!Guid.TryParse(testId, out var id)) + return RedirectToAction("Index"); + + var test = await _communicationTestService.GetTestByIdAsync(id); + if (test == null || test.DepartmentId != DepartmentId) + return Unauthorized(); + + var model = new EditCommunicationTestView { Test = test }; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(EditCommunicationTestView model, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (model.Test == null || model.Test.CommunicationTestId == Guid.Empty) + return RedirectToAction("Index"); + + var existing = await _communicationTestService.GetTestByIdAsync(model.Test.CommunicationTestId); + if (existing == null || existing.DepartmentId != DepartmentId) + return Unauthorized(); + + if (model.Test.ScheduleType != existing.ScheduleType && + !await _communicationTestService.CanCreateScheduledTestAsync(DepartmentId, model.Test.ScheduleType, existing.CommunicationTestId)) + { + var typeLabel = model.Test.ScheduleType == (int)CommunicationTestScheduleType.Weekly ? "weekly" : "monthly"; + model.Message = $"Only one {typeLabel} test is allowed per department."; + model.Test = existing; + return View(model); + } + + var beforeJson = existing.CloneJsonToString(); + + existing.Name = model.Test.Name; + existing.Description = model.Test.Description; + existing.ScheduleType = model.Test.ScheduleType; + existing.Sunday = model.Test.Sunday; + existing.Monday = model.Test.Monday; + existing.Tuesday = model.Test.Tuesday; + existing.Wednesday = model.Test.Wednesday; + existing.Thursday = model.Test.Thursday; + existing.Friday = model.Test.Friday; + existing.Saturday = model.Test.Saturday; + existing.DayOfMonth = model.Test.DayOfMonth; + existing.Time = model.Test.Time; + existing.TestSms = model.Test.TestSms; + existing.TestEmail = model.Test.TestEmail; + existing.TestVoice = model.Test.TestVoice; + existing.TestPush = model.Test.TestPush; + existing.Active = model.Test.Active; + existing.ResponseWindowMinutes = model.Test.ResponseWindowMinutes > 0 ? model.Test.ResponseWindowMinutes : 60; + existing.UpdatedOn = DateTime.UtcNow; + + await _communicationTestService.SaveTestAsync(existing, cancellationToken); + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestUpdated, + Before = beforeJson, + After = existing.CloneJsonToString(), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + return RedirectToAction("Index"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(string testId, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (!Guid.TryParse(testId, out var id)) + return RedirectToAction("Index"); + + var test = await _communicationTestService.GetTestByIdAsync(id); + if (test == null || test.DepartmentId != DepartmentId) + return Unauthorized(); + + var beforeJson = test.CloneJsonToString(); + + await _communicationTestService.DeleteTestAsync(id, cancellationToken); + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestDeleted, + Before = beforeJson, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + return RedirectToAction("Index"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task StartRun(string testId, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (!Guid.TryParse(testId, out var id)) + return RedirectToAction("Index"); + + var test = await _communicationTestService.GetTestByIdAsync(id); + if (test == null || test.DepartmentId != DepartmentId) + return Unauthorized(); + + if (test.ScheduleType != (int)CommunicationTestScheduleType.OnDemand) + { + TempData["Error"] = "Only on-demand tests can be started manually. Scheduled tests run automatically."; + return RedirectToAction("Index"); + } + + // Check 48-hour rate limit for on-demand tests + if (!await _communicationTestService.CanStartOnDemandRunAsync(id)) + { + TempData["Error"] = "An on-demand test can only be run once every 48 hours. Please try again later."; + return RedirectToAction("Index"); + } + + var run = await _communicationTestService.StartTestRunAsync(id, DepartmentId, UserId, cancellationToken); + if (run == null) + { + TempData["Error"] = "Unable to start the test run. Rate limit may apply."; + return RedirectToAction("Index"); + } + + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.CommunicationTestRunStarted, + After = run.CloneJsonToString(), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }); + + return RedirectToAction("Report", new { runId = run.CommunicationTestRunId.ToString() }); + } + + [HttpGet] + public async Task Report(string runId) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + if (!Guid.TryParse(runId, out var id)) + return RedirectToAction("Index"); + + var run = await _communicationTestService.GetRunByIdAsync(id); + if (run == null || run.DepartmentId != DepartmentId) + return Unauthorized(); + + var test = await _communicationTestService.GetTestByIdAsync(run.CommunicationTestId); + var results = await _communicationTestService.GetResultsByRunIdAsync(id); + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + + var model = new CommunicationTestReportView + { + Run = run, + Test = test, + Results = results?.ToList() ?? new List(), + Profiles = profiles ?? new Dictionary() + }; + + return View(model); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 92002b7c..f6bd2855 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -169,6 +169,9 @@ public async Task Add() ViewBag.TimeZones = new SelectList(TimeZones.Zones, "Key", "Value"); ViewBag.Languages = new SelectList(SupportedLocales.SupportedLanguagesMap, "Key", "Value"); + var categories = await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId); + ViewBag.Categories = new SelectList(categories, "ContactCategoryId", "Name"); + var udfDefinition = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Contact); if (udfDefinition != null) { @@ -196,6 +199,9 @@ public async Task Add(AddContactView model, CancellationToken can ViewBag.Languages = new SelectList(SupportedLocales.SupportedLanguagesMap, "Key", "Value"); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var addPostCategories = await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId); + ViewBag.Categories = new SelectList(addPostCategories, "ContactCategoryId", "Name"); + // They specified a street address for physical if (!String.IsNullOrWhiteSpace(model.PhysicalAddress1)) { @@ -420,6 +426,9 @@ public async Task Edit(string contactId) ViewBag.TimeZones = new SelectList(TimeZones.Zones, "Key", "Value"); ViewBag.Languages = new SelectList(SupportedLocales.SupportedLanguagesMap, "Key", "Value"); + var editCategories = await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId); + ViewBag.Categories = new SelectList(editCategories, "ContactCategoryId", "Name", model.Contact.ContactCategoryId); + var udfDefinitionEdit = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Contact); if (udfDefinitionEdit != null) { @@ -450,6 +459,9 @@ public async Task Edit(EditContactView model, CancellationToken c ViewBag.Languages = new SelectList(SupportedLocales.SupportedLanguagesMap, "Key", "Value"); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var editPostCategories = await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId); + ViewBag.Categories = new SelectList(editPostCategories, "ContactCategoryId", "Name"); + // They specified a street address for physical if (!String.IsNullOrWhiteSpace(model.PhysicalAddress1)) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 5533209e..f34b0436 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -641,7 +641,10 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio call.IndoorMapFloorId = null; } - List existingDispatches = new List(call.Dispatches); + List existingDispatches = new List(call.Dispatches ?? Enumerable.Empty()); + List existingGroupDispatches = new List(call.GroupDispatches ?? Enumerable.Empty()); + List existingUnitDispatches = new List(call.UnitDispatches ?? Enumerable.Empty()); + List existingRoleDispatches = new List(call.RoleDispatches ?? Enumerable.Empty()); List dispatchingUserIds = new List(); List dispatchingGroupIds = new List(); @@ -887,6 +890,111 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio _eventAggregator.SendMessage(new CallUpdatedEvent() { DepartmentId = DepartmentId, Call = call }); + // Send cancel notifications to removed entities + if (model.NotifyCancelledEntities) + { + var savedUserIds = new HashSet((call.Dispatches ?? Enumerable.Empty()).Select(x => x.UserId)); + var savedGroupIds = new HashSet((call.GroupDispatches ?? Enumerable.Empty()).Select(x => x.DepartmentGroupId)); + var savedUnitIds = new HashSet((call.UnitDispatches ?? Enumerable.Empty()).Select(x => x.UnitId)); + var savedRoleIds = new HashSet((call.RoleDispatches ?? Enumerable.Empty()).Select(x => x.RoleId)); + + var cancelledUserIds = existingDispatches.Select(x => x.UserId) + .Where(y => !savedUserIds.Contains(y)).ToList(); + var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) + .Where(y => !savedGroupIds.Contains(y)).ToList(); + var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) + .Where(y => !savedUnitIds.Contains(y)).ToList(); + var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) + .Where(y => !savedRoleIds.Contains(y)).ToList(); + + if (cancelledUserIds.Any() || cancelledGroupIds.Any() || cancelledUnitIds.Any() || cancelledRoleIds.Any()) + { + var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); + + // Build set of still-dispatched user IDs for dedup + var stillDispatchedUserIds = new HashSet((call.Dispatches ?? Enumerable.Empty()).Select(x => x.UserId)); + foreach (var gd in call.GroupDispatches ?? Enumerable.Empty()) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(gd.DepartmentGroupId); + foreach (var m in members) stillDispatchedUserIds.Add(m.UserId); + } + foreach (var rd in call.RoleDispatches ?? Enumerable.Empty()) + { + var members = await _personnelRolesService.GetAllMembersOfRoleAsync(rd.RoleId); + foreach (var m in members) stillDispatchedUserIds.Add(m.UserId); + } + + var notifiedUserIds = new HashSet(); + + // Cancel personnel + foreach (var userId in cancelledUserIds) + { + if (!stillDispatchedUserIds.Contains(userId) && notifiedUserIds.Add(userId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = userId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + + // Cancel group members + foreach (var groupId in cancelledGroupIds) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId); + foreach (var member in members) + { + if (!stillDispatchedUserIds.Contains(member.UserId) && notifiedUserIds.Add(member.UserId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = member.UserId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + } + + // Cancel role members + foreach (var roleId in cancelledRoleIds) + { + var members = await _personnelRolesService.GetAllMembersOfRoleAsync(roleId); + foreach (var member in members) + { + if (!stillDispatchedUserIds.Contains(member.UserId) && notifiedUserIds.Add(member.UserId)) + { + var cd = new CallDispatch { CallId = call.CallId, UserId = member.UserId }; + await _communicationService.SendCancelCallAsync(call, cd, departmentNumber, DepartmentId); + } + } + } + + // Cancel units + foreach (var unitId in cancelledUnitIds) + { + var cdu = new CallDispatchUnit { CallId = call.CallId, UnitId = unitId }; + await _communicationService.SendCancelUnitCallAsync(call, cdu, departmentNumber); + } + } + } + + // Auto-dispatch newly added entities when RebroadcastCall is not checked + if (!model.RebroadcastCall) + { + var newUserIds = dispatchingUserIds.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); + var newGroupIds = dispatchingGroupIds.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); + var newUnitIds = dispatchingUnitIds.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); + var newRoleIds = dispatchingRoleIds.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); + + if (newUserIds.Any() || newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) + { + var cqi = new CallQueueItem(); + cqi.Call = call; + + if (newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) + cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId)).Select(x => x.Value).ToList(); + else + cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(newUserIds); + + await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + } + } + if (model.RebroadcastCall) { var cqi = new CallQueueItem(); @@ -962,11 +1070,15 @@ public async Task ViewCall(int callId) model.Stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); model.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); - model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); + model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true, true); if (model.Stations == null) model.Stations = new List(); + model.VideoFeeds = model.Call.VideoFeeds != null + ? model.Call.VideoFeeds.Where(f => !f.IsDeleted).ToList() + : new List(); + if (!String.IsNullOrEmpty(model.Call.GeoLocationData)) { string[] loc = model.Call.GeoLocationData.Split(char.Parse(",")); diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs index fe5e11e9..54bac0f1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs @@ -16,5 +16,7 @@ public class EditCalendarEntry public DateTime? RecurrenceEndLocal { get; set; } public bool IsRecurrenceParent { get; set; } public string entities { get; set; } + public int WeekdayOccurrence { get; set; } + public int WeekdayDayOfWeek { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index 2156ad5c..d206cfd5 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -25,6 +25,7 @@ public class UpdateCallView: BaseUserModel public string MapCenterLatitude { get; set; } public string MapCenterLongitude { get; set; } public bool RebroadcastCall { get; set; } + public bool NotifyCancelledEntities { get; set; } public List Units { get; set; } public List UnitStates { get; set; } public List Contacts { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index 446e4f65..fd020a83 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -27,6 +27,7 @@ public class ViewCallView: BaseUserModel public List Protocols { get; set; } public List ChildCalls { get; set; } public List Contacts { get; set; } + public List VideoFeeds { get; set; } = new List(); public string IsMapTabActive() { diff --git a/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestIndexView.cs new file mode 100644 index 00000000..ecc923d0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestIndexView.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.CommunicationTests +{ + public class CommunicationTestIndexView : BaseUserModel + { + public List Tests { get; set; } = new List(); + public List RecentRuns { get; set; } = new List(); + public Dictionary TestNames { get; set; } = new Dictionary(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestReportView.cs b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestReportView.cs new file mode 100644 index 00000000..9ae4b022 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/CommunicationTestReportView.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.CommunicationTests +{ + public class CommunicationTestReportView : BaseUserModel + { + public CommunicationTestRun Run { get; set; } + public CommunicationTest Test { get; set; } + public List Results { get; set; } = new List(); + public Dictionary Profiles { get; set; } = new Dictionary(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/EditCommunicationTestView.cs b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/EditCommunicationTestView.cs new file mode 100644 index 00000000..6c4f5ba1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/EditCommunicationTestView.cs @@ -0,0 +1,10 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.CommunicationTests +{ + public class EditCommunicationTestView : BaseUserModel + { + public CommunicationTest Test { get; set; } + public string Message { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/NewCommunicationTestView.cs b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/NewCommunicationTestView.cs new file mode 100644 index 00000000..2391f60f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/CommunicationTests/NewCommunicationTestView.cs @@ -0,0 +1,10 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.CommunicationTests +{ + public class NewCommunicationTestView : BaseUserModel + { + public CommunicationTest Test { get; set; } = new CommunicationTest(); + public string Message { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml index 7d277497..510b9028 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml @@ -252,11 +252,11 @@
  • - +  @Html.TextBoxFor(m => m.Item.RepeatOnDay, new { onkeydown = "javascript:return false;" })
  • @@ -264,7 +264,8 @@ - @@ -273,7 +274,7 @@ - diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Edit.cshtml new file mode 100644 index 00000000..477d7596 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Edit.cshtml @@ -0,0 +1,177 @@ +@model Resgrid.Web.Areas.User.Models.CommunicationTests.EditCommunicationTestView +@{ + ViewBag.Title = "Resgrid | Edit Communication Test"; + + var selectedDay = "Monday"; + if (Model.Test.Sunday) selectedDay = "Sunday"; + else if (Model.Test.Monday) selectedDay = "Monday"; + else if (Model.Test.Tuesday) selectedDay = "Tuesday"; + else if (Model.Test.Wednesday) selectedDay = "Wednesday"; + else if (Model.Test.Thursday) selectedDay = "Thursday"; + else if (Model.Test.Friday) selectedDay = "Friday"; + else if (Model.Test.Saturday) selectedDay = "Saturday"; +} + +
    +
    +

    Edit Communication Test

    + +
    +
    +
    +
    +
    + @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
    @Model.Message
    + } +
    +
    +
    Test Definition
    +
    +
    +
    + @Html.AntiForgeryToken() + +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    +

    Schedule

    +
    + +
    + +
    +
    + + + + +
    +

    Channels to Test

    +
    +
    + + + + +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml new file mode 100644 index 00000000..7156c42e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml @@ -0,0 +1,158 @@ +@using Resgrid.Web.Helpers +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.CommunicationTests.CommunicationTestIndexView +@{ + ViewBag.Title = "Resgrid | Communication Tests"; +} + +
    +
    +

    Communication Tests

    + +
    + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + { +
    +
    + New Test +
    +
    + } +
    +
    +
    +
    +
    +
    +
    Test Definitions
    +
    +
    +
    + + + + + + + + + + + + + @foreach (var test in Model.Tests) + { + var scheduleLabel = ((CommunicationTestScheduleType)test.ScheduleType).ToString(); + var channels = new System.Collections.Generic.List(); + if (test.TestSms) channels.Add("SMS"); + if (test.TestEmail) channels.Add("Email"); + if (test.TestVoice) channels.Add("Voice"); + if (test.TestPush) channels.Add("Push"); + + + + + + + + + + } + +
    NameScheduleChannelsActiveResponse Window
    @test.Name@scheduleLabel@string.Join(", ", channels) + @if (test.Active) + { + Active + } + else + { + Inactive + } + @test.ResponseWindowMinutes min + @if (test.ScheduleType == (int)Resgrid.Model.CommunicationTestScheduleType.OnDemand) + { +
    + @Html.AntiForgeryToken() + + +
    + } + Edit +
    + @Html.AntiForgeryToken() + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Recent Test Runs
    +
    +
    +
    + + + + + + + + + + + + + + @foreach (var run in Model.RecentRuns) + { + var testName = ""; + Model.TestNames.TryGetValue(run.CommunicationTestId.ToString(), out testName); + var statusLabel = ((CommunicationTestRunStatus)run.Status).ToString(); + + + + + + + + + + + } + +
    Test NameRun CodeStartedStatusUsers TestedResponses
    @testName@run.RunCode@run.StartedOn.ToString("g") + @if (run.Status == (int)CommunicationTestRunStatus.Completed) + { + @statusLabel + } + else if (run.Status == (int)CommunicationTestRunStatus.Failed) + { + @statusLabel + } + else + { + @statusLabel + } + @run.TotalUsersTested@run.TotalResponses + View Report +
    +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/New.cshtml new file mode 100644 index 00000000..8b3d271a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/New.cshtml @@ -0,0 +1,167 @@ +@model Resgrid.Web.Areas.User.Models.CommunicationTests.NewCommunicationTestView +@{ + ViewBag.Title = "Resgrid | New Communication Test"; +} + +
    +
    +

    New Communication Test

    + +
    +
    +
    +
    +
    + @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
    @Model.Message
    + } +
    +
    +
    Test Definition
    +
    +
    +
    + @Html.AntiForgeryToken() +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    +

    Schedule

    +
    + +
    + +
    +
    + + + + +
    +

    Channels to Test

    +
    +
    + + + + +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml new file mode 100644 index 00000000..54d34ebd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml @@ -0,0 +1,206 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.CommunicationTests.CommunicationTestReportView +@{ + ViewBag.Title = "Resgrid | Communication Test Report"; + + var userResults = Model.Results + .GroupBy(r => r.UserId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var allUserIds = userResults.Keys.ToList(); + var statusLabel = ((CommunicationTestRunStatus)Model.Run.Status).ToString(); + + int totalSmsResponded = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Sms && r.Responded); + int totalSmsSent = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Sms && r.SendSucceeded); + int totalEmailResponded = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Email && r.Responded); + int totalEmailSent = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Email && r.SendSucceeded); + int totalVoiceResponded = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Voice && r.Responded); + int totalVoiceSent = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Voice && r.SendSucceeded); + int totalPushResponded = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Push && r.Responded); + int totalPushSent = Model.Results.Count(r => r.Channel == (int)CommunicationTestChannel.Push && r.SendSucceeded); +} + +
    +
    +

    Communication Test Report

    + +
    +
    +
    +
    +
    +
    +
    +
    Run Details
    +
    +
    +
    +
    Test: @Model.Test?.Name
    +
    Run Code: @Model.Run.RunCode
    +
    Status: @statusLabel
    +
    Started: @Model.Run.StartedOn.ToString("g")
    +
    Users Tested: @Model.Run.TotalUsersTested | Responded: @Model.Run.TotalResponses
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    SMS
    +

    @totalSmsResponded / @totalSmsSent

    + @if (totalSmsSent > 0) { @(Math.Round((double)totalSmsResponded / totalSmsSent * 100))% } +
    +
    +
    +
    +
    +
    +
    Email
    +

    @totalEmailResponded / @totalEmailSent

    + @if (totalEmailSent > 0) { @(Math.Round((double)totalEmailResponded / totalEmailSent * 100))% } +
    +
    +
    +
    +
    +
    +
    Voice
    +

    @totalVoiceResponded / @totalVoiceSent

    + @if (totalVoiceSent > 0) { @(Math.Round((double)totalVoiceResponded / totalVoiceSent * 100))% } +
    +
    +
    +
    +
    +
    +
    Push
    +

    @totalPushResponded / @totalPushSent

    + @if (totalPushSent > 0) { @(Math.Round((double)totalPushResponded / totalPushSent * 100))% } +
    +
    +
    +
    + +
    +
    +
    +
    +
    Per-User Results
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + @foreach (var userId in allUserIds) + { + var results = userResults[userId]; + var userName = userId; + Model.Profiles.TryGetValue(userId, out var profile); + if (profile != null) + { + userName = $"{profile.FirstName} {profile.LastName}".Trim(); + } + + var smsResult = results.FirstOrDefault(r => r.Channel == (int)CommunicationTestChannel.Sms); + var emailResult = results.FirstOrDefault(r => r.Channel == (int)CommunicationTestChannel.Email); + var voiceResult = results.FirstOrDefault(r => r.Channel == (int)CommunicationTestChannel.Voice); + var pushResult = results.FirstOrDefault(r => r.Channel == (int)CommunicationTestChannel.Push); + + string GetStatusClass(CommunicationTestResult r) + { + if (r == null) return ""; + if (!r.SendAttempted) return "background-color:#e0e0e0;"; + if (r.Responded) return "background-color:#dff0d8;"; + return "background-color:#f2dede;"; + } + + string GetStatusText(CommunicationTestResult r) + { + if (r == null) return "-"; + if (!r.SendAttempted) return "Not Sent"; + if (r.Responded) return "Responded"; + return "No Response"; + } + + string GetVerificationText(CommunicationTestResult r) + { + if (r == null) return "-"; + return ((ContactVerificationStatus)r.VerificationStatus).ToString(); + } + + string GetEnabledText(bool enabled) + { + return enabled ? "Yes" : "No"; + } + + string GetEnabledClass(bool enabled) + { + return enabled ? "color:green;" : "color:red;"; + } + + var smsEnabled = profile?.SendSms ?? false; + var emailEnabled = profile?.SendEmail ?? false; + var voiceEnabled = profile?.VoiceForCall ?? false; + var pushEnabled = profile?.SendPush ?? false; + + + + + + + + + + + + + + + + + + + + } + +
    UserSMS EnabledSMSSMS ContactSMS CarrierSMS VerificationEmail EnabledEmailEmail ContactEmail VerificationVoice EnabledVoiceVoice ContactVoice VerificationPush EnabledPush
    @userName@GetEnabledText(smsEnabled)@GetStatusText(smsResult)@(smsResult?.ContactValue ?? "-")@(smsResult?.ContactCarrier ?? "-")@GetVerificationText(smsResult)@GetEnabledText(emailEnabled)@GetStatusText(emailResult)@(emailResult?.ContactValue ?? "-")@GetVerificationText(emailResult)@GetEnabledText(voiceEnabled)@GetStatusText(voiceResult)@(voiceResult?.ContactValue ?? "-")@GetVerificationText(voiceResult)@GetEnabledText(pushEnabled)@GetStatusText(pushResult)
    +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Add.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Add.cshtml index 07ffcc8a..e06dc369 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Add.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Add.cshtml @@ -68,6 +68,14 @@  @localizer["Company"] +
    + +
    + +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml index e40aa350..39c0ce6a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml @@ -68,6 +68,14 @@  @localizer["Company"]
    +
    + +
    + +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index 5edb9ea4..d571ba31 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -272,6 +272,13 @@ @localizer["RedispatchCallHelp"]
    +
    + +
    + + @localizer["NotifyCancelledHelp"] +
    +
    @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) {
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 2fae790d..51e65aa7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -181,6 +181,7 @@
  • +
  • @if (!String.IsNullOrWhiteSpace(Model.Call.CallFormData)) {
  • @@ -709,6 +710,68 @@ +
    + @if (Model.VideoFeeds != null && Model.VideoFeeds.Any()) + { + + + + + + + + + + + + + + @foreach (var feed in Model.VideoFeeds.OrderBy(f => f.SortOrder)) + { + + + + + + + + + + } + +
    @localizer["VideoFeedName"]@localizer["VideoFeedUrl"]@localizer["VideoFeedType"]@localizer["VideoFeedFormat"]@localizer["VideoFeedStatus"]@localizer["VideoFeedAddedBy"]@localizer["VideoFeedAddedOn"]
    @feed.Name@feed.Url + @if (feed.FeedType.HasValue) + { + @(((Resgrid.Model.CallVideoFeedTypes)feed.FeedType.Value).ToString()) + } + + @if (feed.FeedFormat.HasValue) + { + @(((Resgrid.Model.CallVideoFeedFormats)feed.FeedFormat.Value).ToString()) + } + + @{ + var status = (Resgrid.Model.CallVideoFeedStatuses)feed.Status; + } + @if (status == Resgrid.Model.CallVideoFeedStatuses.Active) + { + @localizer["VideoFeedActive"] + } + else if (status == Resgrid.Model.CallVideoFeedStatuses.Inactive) + { + @localizer["VideoFeedInactive"] + } + else + { + @localizer["VideoFeedError"] + } + @feed.AddedByUserId@feed.AddedOn.TimeConverterToString(Model.Department)
    + } + else + { +

    @localizer["NoVideoFeeds"]

    + } +
    @if (!String.IsNullOrWhiteSpace(Model.Call.CallFormData)) {
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Forms/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Forms/Index.cshtml index b3ac39da..182bf2b8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Forms/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Forms/Index.cshtml @@ -27,6 +27,11 @@
    } +
    +
    + Deprecated: Call Forms are deprecated and will be removed in a future release. Please use User Defined Fields (Custom Fields) instead. +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Groups/Geofence.cshtml b/Web/Resgrid.Web/Areas/User/Views/Groups/Geofence.cshtml index 464d1182..f6cbc733 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Groups/Geofence.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Groups/Geofence.cshtml @@ -43,6 +43,9 @@ @Html.HiddenFor(m => m.Group.DepartmentGroupId)
    + + +
    @Model.Group.Name
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index b811b2de..0308c5b9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -21,6 +21,14 @@
    + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + { + + }
    diff --git a/Web/Resgrid.Web/Views/Account/LogOn.cshtml b/Web/Resgrid.Web/Views/Account/LogOn.cshtml index c08d520a..e93f3a75 100644 --- a/Web/Resgrid.Web/Views/Account/LogOn.cshtml +++ b/Web/Resgrid.Web/Views/Account/LogOn.cshtml @@ -64,6 +64,25 @@
    + @if (Resgrid.Config.InfoConfig.Locations != null && Resgrid.Config.InfoConfig.Locations.Count > 1) + { +
    + + +
    + }
    @@ -193,6 +212,17 @@ } } + $("#regionSelect").change(function () { + var url = $(this).val(); + if (url) { + var qs = window.location.search; + if (qs) { + url += (url.indexOf('?') > -1 ? '&' : '?') + qs.substring(1); + } + window.location.href = url; + } + }); + $(".langDropdownSelection").click(function (e) { if (e) { e.preventDefault(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.editEntry.js b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.editEntry.js index 359abdb8..5bff419f 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.editEntry.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.editEntry.js @@ -70,6 +70,24 @@ var resgrid; $("#Item_RepeatOnDay").attr({ type: 'number', min: 1, max: 31, step: 1 }); + // Toggle between "day of month" and "weekday occurrence" inputs + $('input[name="month"]').on('change', function () { + if ($(this).val() === 'weekday') { + $('#Item_RepeatOnDay').prop('disabled', true).removeAttr('min max'); + $('#WeekdayOccurrence, #WeekdayDayOfWeek').prop('disabled', false); + } else { + $('#Item_RepeatOnDay').prop('disabled', false).attr({ min: 1, max: 31 }); + $('#WeekdayOccurrence, #WeekdayDayOfWeek').prop('disabled', true); + } + }); + + // Determine initial radio state from existing data + if ($('#Item_RepeatOnWeek').length && parseInt($('#Item_RepeatOnWeek').val()) > 0) { + $('input[name="month"][value="weekday"]').prop('checked', true).trigger('change'); + } else { + $('input[name="month"][value="monthday"]').prop('checked', true).trigger('change'); + } + let quill = new Quill('#editor-container', { placeholder: '', theme: 'snow' diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.newEntry.js b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.newEntry.js index 8ad1058f..5eea4596 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.newEntry.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/calendar/resgrid.calendar.newEntry.js @@ -66,6 +66,20 @@ var resgrid; $("#Item_RepeatOnDay").attr({ type: 'number', min: 1, max: 31, step: 1 }); + // Toggle between "day of month" and "weekday occurrence" inputs + $('input[name="month"]').on('change', function () { + if ($(this).val() === 'weekday') { + $('#Item_RepeatOnDay').prop('disabled', true).removeAttr('min max'); + $('#WeekdayOccurrence, #WeekdayDayOfWeek').prop('disabled', false); + } else { + $('#Item_RepeatOnDay').prop('disabled', false).attr({ min: 1, max: 31 }); + $('#WeekdayOccurrence, #WeekdayDayOfWeek').prop('disabled', true); + } + }); + + // Default to "monthday" radio selected, weekday dropdowns disabled + $('input[name="month"][value="monthday"]').prop('checked', true).trigger('change'); + var quill = new Quill('#editor-container', { placeholder: '', theme: 'snow' diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/groups/resgrid.groups.geofence.js b/Web/Resgrid.Web/wwwroot/js/app/internal/groups/resgrid.groups.geofence.js index 2c861a61..8a137fa1 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/groups/resgrid.groups.geofence.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/groups/resgrid.groups.geofence.js @@ -94,6 +94,14 @@ var resgrid; }); } + var groupId = $('#Group_DepartmentGroupId').val(); + if (!groupId) { + $('#alertArea').html('

    Unable to determine the group. Please reload the page and try again.

    '); + $('#successArea').hide(); + $('#alertArea').show(); + return; + } + $.ajax({ type: "POST", async: true, @@ -102,14 +110,20 @@ var resgrid; cache: false, processData: false, data: JSON.stringify({ - DepartmentGroupId: $('#Group_DepartmentGroupId').val(), + DepartmentGroupId: parseInt(groupId, 10), Color: $('#colorPicker').val(), GeoFence: JSON.stringify(coords) }) }).done(function (data) { - $('#successArea').html('

    Your geofence has been saved.

    '); - $('#successArea').show(); - $('#alertArea').hide(); + if (data && data.Success) { + $('#successArea').html('

    ' + (data.Message || 'Your geofence has been saved.') + '

    '); + $('#successArea').show(); + $('#alertArea').hide(); + } else { + $('#alertArea').html('

    ' + (data && data.Message ? data.Message : 'Your geofence could not be saved.') + '

    '); + $('#successArea').hide(); + $('#alertArea').show(); + } }).fail(function (error) { $('#alertArea').html('

    Your geofence could not be saved. Please correct the errors and try again.

    '); $('#successArea').hide(); diff --git a/Workers/Resgrid.Workers.Console/Commands/CommunicationTestCommand.cs b/Workers/Resgrid.Workers.Console/Commands/CommunicationTestCommand.cs new file mode 100644 index 00000000..718c172d --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/CommunicationTestCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public class CommunicationTestCommand : IQuidjiboCommand + { + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + + public CommunicationTestCommand(int id) + { + Id = id; + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 32c44cd0..b3d4f796 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -362,6 +362,12 @@ await Client.ScheduleAsync("GDPR Data Export", new Commands.GdprExportCommand(16), Cron.MinuteIntervals(5), stoppingToken); + + _logger.Log(LogLevel.Information, "Scheduling Communication Test"); + await Client.ScheduleAsync("Communication Test", + new Commands.CommunicationTestCommand(17), + Cron.MinuteIntervals(15), + stoppingToken); } else { diff --git a/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs new file mode 100644 index 00000000..5a22ec88 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs @@ -0,0 +1,48 @@ +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; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Workers.Console.Tasks +{ + public class CommunicationTestTask : IQuidjiboHandler + { + public string Name => "Communication Test"; + public int Priority => 1; + public ILogger _logger; + + public CommunicationTestTask(ILogger logger) + { + _logger = logger; + } + + public async Task ProcessAsync(CommunicationTestCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + try + { + progress.Report(1, $"Starting the {Name} Task"); + + var communicationTestService = Bootstrapper.GetKernel().Resolve(); + + _logger.LogInformation("CommunicationTest::Processing scheduled tests"); + await communicationTestService.ProcessScheduledTestsAsync(cancellationToken); + + _logger.LogInformation("CommunicationTest::Completing expired runs"); + await communicationTestService.CompleteExpiredRunsAsync(cancellationToken); + + progress.Report(100, $"Finishing the {Name} Task"); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + _logger.LogError(ex.ToString()); + } + } + } +}