diff --git a/Core/Resgrid.Config/NumberProviderConfig.cs b/Core/Resgrid.Config/NumberProviderConfig.cs
index 569dfe1b0..07c00084c 100644
--- a/Core/Resgrid.Config/NumberProviderConfig.cs
+++ b/Core/Resgrid.Config/NumberProviderConfig.cs
@@ -17,6 +17,7 @@ public static class NumberProviderConfig
public static string TwilioVoiceCallApiUrl = SystemBehaviorConfig.ResgridApiBaseUrl + "/api/Twilio/VoiceCall?userId={0}&callId={1}";
public static string TwilioVoiceVerificationApiUrl = SystemBehaviorConfig.ResgridApiBaseUrl + "/api/Twilio/VoiceVerification?userId={0}&contactType={1}";
public static string TwilioVoiceApiUrl = SystemBehaviorConfig.ResgridApiBaseUrl + "/api/Twilio/InboundVoice";
+ public static string TwilioCommunicationTestApiUrl = SystemBehaviorConfig.ResgridApiBaseUrl + "/api/v4/CommunicationTestResponse/VoiceCall?token={0}";
// Diafaan (https://www.diafaan.com)
public static string DiafaanSmsGatewayUrl = "";
diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs
index 75e40d62c..0f712dafe 100644
--- a/Core/Resgrid.Config/ServiceBusConfig.cs
+++ b/Core/Resgrid.Config/ServiceBusConfig.cs
@@ -22,6 +22,7 @@ public static class ServiceBusConfig
public static string SecurityRefreshQueueName = "securityrefreshtest";
public static string WorkflowQueueName = "workflowqueuetest";
public static string ChatbotProcessingQueueName = "chatbotprocessingtest";
+ public static string CommunicationTestQueueName = "communicationtesttest";
#else
public static string CallBroadcastQueueName = "callbroadcast";
public static string MessageBroadcastQueueName = "messagebroadcast";
@@ -39,6 +40,7 @@ public static class ServiceBusConfig
public static string SecurityRefreshQueueName = "securityrefresh";
public static string WorkflowQueueName = "workflowqueue";
public static string ChatbotProcessingQueueName = "chatbotprocessing";
+ public static string CommunicationTestQueueName = "communicationtest";
#endif
#region Azure Service Bus Values
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx
index b93767e12..56007d68d 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
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
+ يوم الشهر (1-28)
- 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.
+ تلقّى Resgrid ردك على اختبار الاتصال. شكرًا لك.
-
+
+ مرحبًا {0}،
+
+
+
+تُجري {1} اختبار اتصال ({2}) للتأكد من إمكانية الوصول إليك.
+
+
+
+هذا اختبار. لا توجد حالة طوارئ ولا يلزم اتخاذ أي إجراء سوى تأكيد استلامك لهذا البريد الإلكتروني.
+
+
+
+يرجى النقر على الرابط أدناه لتأكيد استلام هذه الرسالة:
+
+{3}
+
+
+
+فريق Resgrid
+
+
+ اختبار اتصال Resgrid: {0}
+
+
+ {0}: هذا اختبار فقط. افتح تطبيق Resgrid Responder وأكّد استلامك له.
+
+
+ اختبار الاتصال
+
+
+ اختبار اتصال Resgrid ({0}). هذا اختبار فقط. أرسل ردًا بالرمز {1} لتأكيد استلام هذه الرسالة.
+
+
+ اختبار اتصال Resgrid. هذا اختبار فقط. أرسل ردًا بالرمز {0} لتأكيد استلام هذه الرسالة.
+
+
+ هذا اختبار اتصال من Resgrid. هذا اختبار فقط. لا توجد حالة طوارئ.
+
+
+ لم يتم استلام أي تأكيد. مع السلامة.
+
+
+ اضغط 1 لتأكيد استلامك لهذه المكالمة.
+
+
+ شكرًا لك. تم تسجيل ردك.
+
+
+ القنوات
+
+
+ التحقق من جهات الاتصال
+
+
+ حذف
+
+
+ تعديل
+
+
+ عنوان البريد
+
+
+ البريد مفعّل
+
+
+ التحقق من البريد
+
+
+ الجمعة
+
+
+ مستثنى سابقًا
+
+
+ سابق لنظام التحقق. لا يزال يُرسل إليه لكنه غير مؤكد — اطلب من هؤلاء الأعضاء التحقق.
+
+
+ المجموعات
+
+
+ الرئيسية
+
+
+ أفراد
+
+
+ دقيقة
+
+
+ الاثنين
+
+
+ الاسم مطلوب.
+
+
+ غير منطبق
+
+
+ لا يملك الإشعار الفوري وسيلة اتصال قابلة للتحقق — يعتمد التسليم على جهاز مُسجَّل.
+
+
+ لم يُرسل (لا توجد بيانات اتصال)
+
+
+ لم يُرسل (معطّل)
+
+
+ لم يُرسل (غير مُتحقق منه)
+
+
+ يمكن بدء اختبارات عند الطلب يدويًا فقط. أما الاختبارات المجدولة فتعمل تلقائيًا.
+
+
+ يُسمح باختبار شهري واحد فقط لكل قسم. يرجى تعديل الاختبار الحالي بدلاً من ذلك.
+
+
+ يُسمح باختبار أسبوعي واحد فقط لكل قسم. يرجى تعديل الاختبار الحالي بدلاً من ذلك.
+
+
+ النقر على الرابط هو التأكيد. الرابط فريد لكل شخص ويُستخدم مرة واحدة فقط.
+
+
+ يظهر هنا بلغتك. يتلقى كل شخص الرسالة باللغة المحددة في ملفه الشخصي.
+
+
+ يتطلب تطبيق Resgrid Responder مع تسجيل الدخول والسماح بالإشعارات. عند فتح الإشعار يظهر زر تأكيد داخل التطبيق. ومن لا يملك التطبيق يظهر كعدم استجابة في قناة الإشعارات.
+
+
+ يردّون برمز التشغيل (يُنشأ رمز جديد مثل {0} لكل تشغيل) إلى نفس الرقم الذي وصلت منه الرسالة. لا يهم باقي محتوى الرد.
+
+
+ تنطق المكالمة هذا النص مرتين ثم تُغلق. الضغط على 1 يؤكد الاستلام. يتم الاتصال بالرقم المحدد في ملفهم الشخصي.
+
+
+ الإشعارات مفعّلة
+
+
+ إشعار فوري
+
+
+ التحقق من الإشعار
+
+
+ يمكن تشغيل اختبار عند الطلب مرة واحدة كل 48 ساعة. يرجى المحاولة لاحقًا.
+
+
+ الأدوار
+
+
+ لا يزال هذا التشغيل قيد الإرسال. تُرسل الرسائل في الخلفية، لذا حدّث هذه الصفحة بعد لحظات لعرض النتائج.
+
+
+ هل تريد بدء اختبار الاتصال الآن؟
+
+سيتم إرسال رسالة حقيقية إلى كل مستخدم ضمن نطاق الاختبار، عبر كل قناة مفعّلة.
+
+- الرسائل النصية: يردّون برمز التشغيل الظاهر في التقرير
+- البريد الإلكتروني: ينقرون على رابط التأكيد
+- المكالمة الصوتية: مكالمة آلية تطلب الضغط على 1
+- الإشعار الفوري: يتطلب تطبيق Resgrid Responder مع تسجيل الدخول
+
+تُرسل الرسائل في الخلفية، لذا سيمتلئ التقرير تدريجيًا.
+
+
+ السبت
+
+
+ الجدولة
+
+
+ النطاق
+
+
+ الجميع
+
+
+ مجموعة واحدة
+
+
+ {0} مجموعات
+
+
+ {0} أشخاص
+
+
+ شخص واحد
+
+
+ دور واحد
+
+
+ {0} أدوار
+
+
+ فشل الإرسال
+
+
+ مشغل الرسائل
+
+
+ رقم الرسائل
+
+
+ الرسائل النصية مفعّلة
+
+
+ التحقق من الرسائل
+
+
+ الأحد
+
+
+ تستهدف اختبارات الاتصال المستخدمين وليس الوحدات. يحتاج الأفراد إلى تثبيت تطبيق Resgrid Responder وتسجيل الدخول لتأكيد الإشعارات الفورية؛ أما الرسائل النصية والبريد الإلكتروني والمكالمات الصوتية فيتم تأكيدها من الهاتف أو صندوق البريد ولا تتطلب التطبيق. افتح اختبارًا لمعرفة ما سيصل إلى أفرادك بالضبط.
+
+
+ اسم الاختبار
+
+
+ الخميس
+
+
+ الثلاثاء
+
+
+ تعذّر بدء تشغيل الاختبار. قد يكون هناك حد للتكرار.
+
+
+ غير مُتحقق منه
+
+
+ غير مؤكد. لن يرسل Resgrid إلى هذه القنوات.
+
+
+ مُتحقق منه
+
+
+ مؤكد من قِبل المستخدم. يرسل Resgrid إلى هذه القنوات.
+
+
+ مكالمة صوتية
+
+
+ رقم المكالمة
+
+
+ المكالمات مفعّلة
+
+
+ التحقق من المكالمة
+
+
+ الأربعاء
+
+
+ ما سيراه أفرادك
+
+
+ من سيتم اختباره
+
+
+ اترك القوائم الثلاث فارغة لاختبار القسم بأكمله. الاختيارات تراكمية: كل من يطابق مجموعة أو دورًا أو اسمًا محددًا يتم اختباره مرة واحدة.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs
index 577707600..5eafb2413 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.cs
@@ -1,6 +1,69 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+
namespace Resgrid.Localization.Areas.User.CommunicationTest
{
+ /// Marker type used by ASP.NET Core localization for communication test resources.
public class CommunicationTest
{
}
+
+ ///
+ /// Culture-explicit access to communication test resources. The screens use
+ /// IStringLocalizer and render in the request culture, but the messages a test sends are
+ /// composed in a background worker for someone else entirely — they must render in the
+ /// *recipient's* language (UserProfile.Language), which is why those lookups pass a
+ /// culture instead of relying on the ambient thread culture.
+ /// A missing culture for a key falls back to English; a missing key surfaces the key name.
+ ///
+ public static class CommunicationTestResources
+ {
+ private static readonly ResourceManager ResourceManager = new ResourceManager(
+ typeof(CommunicationTest).FullName!, typeof(CommunicationTest).Assembly);
+
+ public static string Get(string key, string? culture, params object[] arguments)
+ {
+ var cultureInfo = GetSupportedCulture(culture);
+ var value = ResourceManager.GetString(key, cultureInfo)
+ ?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en"))
+ ?? key;
+
+ return arguments == null || arguments.Length == 0
+ ? value
+ : string.Format(cultureInfo, value, arguments);
+ }
+
+ public static string GetCurrent(string key, params object[] arguments)
+ {
+ return Get(key, CultureInfo.CurrentUICulture.Name, arguments);
+ }
+
+ public static IReadOnlyDictionary GetAll(string culture)
+ {
+ var resourceSet = ResourceManager.GetResourceSet(GetSupportedCulture(culture), true, false);
+ if (resourceSet == null)
+ return new Dictionary();
+
+ return resourceSet.Cast()
+ .Where(x => x.Key is string && x.Value is string)
+ .ToDictionary(x => (string)x.Key, x => (string)x.Value!);
+ }
+
+ private static CultureInfo GetSupportedCulture(string? culture)
+ {
+ var candidate = string.IsNullOrWhiteSpace(culture) ? "en" : culture.Trim();
+ var separator = candidate.IndexOfAny(new[] { '-', '_' });
+ if (separator > 0)
+ candidate = candidate.Substring(0, separator);
+
+ candidate = candidate.ToLowerInvariant();
+ return SupportedLocales.SupportedLanguagesMap.ContainsKey(candidate)
+ ? CultureInfo.GetCultureInfo(candidate)
+ : CultureInfo.GetCultureInfo("en");
+ }
+ }
}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
index b93767e12..2500155b7 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Kommunikationstests
- Communication Tests
+ Kommunikationstests
- New Test
+ Neuer Test
- Edit Test
+ Test bearbeiten
- Delete Test
+ Test löschen
- Are you sure you want to delete this test?
+ Möchten Sie diesen Test wirklich löschen?
- Test Definitions
+ Testdefinitionen
Name
- Description
+ Beschreibung
- Schedule Type
+ Zeitplantyp
- On Demand
+ Auf Anfrage
- Weekly
+ Wöchentlich
- Monthly
+ Monatlich
- Days of Week
+ Wochentag
- Day of Month
+ Tag des Monats (1-28)
- Time
+ Uhrzeit
- Channels to Test
+ Zu testende Kanäle
SMS
- Email
+ E-Mail
- Voice
+ Sprachanruf
Push
- Active
+ Aktiv
- Inactive
+ Inaktiv
- Response Window
+ Antwortfenster
- Response Window (minutes)
+ Antwortfenster (Minuten)
- Create Test
+ Test erstellen
- Save Changes
+ Änderungen speichern
- Cancel
+ Abbrechen
- Recent Test Runs
+ Letzte Testläufe
- Run Now
+ Jetzt ausführen
- Run Code
+ Testcode
- Started
+ Gestartet
Status
- Users Tested
+ Getestete Benutzer
- Responses
+ Antworten
- View Report
+ Bericht anzeigen
- Communication Test Report
+ Kommunikationstest-Bericht
- Run Details
+ Testlauf-Details
- Per-User Results
+ Ergebnisse je Benutzer
- User
+ Benutzer
- Contact
+ Kontakt
- Carrier
+ Mobilfunkanbieter
- Verification Status
+ Verifizierung
- Responded
+ Geantwortet
- No Response
+ Keine Antwort
- Not Sent
+ Nicht gesendet
- Response Rate
+ Antwortquote
- Your communication test response has been recorded. Thank you.
+ Ihre Antwort auf den Kommunikationstest wurde erfasst. Vielen Dank.
- Resgrid received your communication test response. Thank you.
+ Resgrid hat Ihre Antwort auf den Kommunikationstest erhalten. Vielen Dank.
-
+
+ Hallo {0},
+
+
+
+{1} führt einen Kommunikationstest ({2}) durch, um zu prüfen, ob Sie erreichbar sind.
+
+
+
+Dies ist ein TEST. Es liegt kein Notfall vor und es ist keine Reaktion erforderlich, außer den Empfang dieser E-Mail zu bestätigen.
+
+
+
+Bitte klicken Sie auf den folgenden Link, um den Empfang zu bestätigen:
+
+{3}
+
+
+
+Ihr Resgrid-Team
+
+
+ Resgrid-Kommunikationstest: {0}
+
+
+ {0}: Dies ist nur ein Test. Öffnen Sie die Resgrid Responder App und bestätigen Sie den Empfang.
+
+
+ Kommunikationstest
+
+
+ Resgrid-Kommunikationstest ({0}). Dies ist nur ein Test. Antworten Sie mit {1}, um den Empfang zu bestätigen.
+
+
+ Resgrid-Kommunikationstest. Dies ist nur ein Test. Antworten Sie mit {0}, um den Empfang zu bestätigen.
+
+
+ Dies ist ein Resgrid-Kommunikationstest. Dies ist nur ein Test. Es liegt kein Notfall vor.
+
+
+ Es wurde keine Bestätigung empfangen. Auf Wiederhören.
+
+
+ Drücken Sie die 1, um den Empfang dieses Anrufs zu bestätigen.
+
+
+ Vielen Dank. Ihre Antwort wurde erfasst.
+
+
+ Kanäle
+
+
+ Kontaktverifizierung
+
+
+ Löschen
+
+
+ Bearbeiten
+
+
+ E-Mail-Kontakt
+
+
+ E-Mail aktiviert
+
+
+ E-Mail-Verifizierung
+
+
+ Fr
+
+
+ Bestandsschutz
+
+
+ Stammt aus der Zeit vor der Verifizierung. Wird weiterhin verwendet, ist aber unbestätigt — bitten Sie diese Mitglieder um Verifizierung.
+
+
+ Gruppen
+
+
+ Startseite
+
+
+ Einzelpersonen
+
+
+ Min.
+
+
+ Mo
+
+
+ Der Name ist erforderlich.
+
+
+ —
+
+
+ Push hat keine verifizierbare Kontaktmethode — die Zustellung hängt von einem registrierten Gerät ab.
+
+
+ Nicht gesendet (keine Kontaktdaten)
+
+
+ Nicht gesendet (deaktiviert)
+
+
+ Nicht gesendet (nicht verifiziert)
+
+
+ Nur Tests auf Anfrage können manuell gestartet werden. Geplante Tests laufen automatisch.
+
+
+ Pro Abteilung ist nur ein monatlicher Test zulässig. Bitte bearbeiten Sie stattdessen den vorhandenen.
+
+
+ Pro Abteilung ist nur ein wöchentlicher Test zulässig. Bitte bearbeiten Sie stattdessen den vorhandenen.
+
+
+ Der Klick auf den Link ist die Bestätigung. Der Link ist pro Person einmalig und nur einmal verwendbar.
+
+
+ Hier in Ihrer Sprache dargestellt. Jede Person erhält die Nachricht in der in ihrem Profil eingestellten Sprache.
+
+
+ Erfordert die Resgrid Responder App, angemeldet und mit erlaubten Benachrichtigungen. Beim Öffnen der Benachrichtigung erscheint in der App eine Bestätigungsschaltfläche. Wer die App nicht installiert hat, erscheint im Push-Kanal als ohne Antwort.
+
+
+ Sie antworten mit dem Testcode (pro Lauf wird ein neuer wie {0} erzeugt) an dieselbe Nummer, von der die Nachricht kam. Der übrige Inhalt der Antwort spielt keine Rolle.
+
+
+ Der Anruf spricht diesen Text zweimal und legt dann auf. Das Drücken der 1 bestätigt den Empfang. Angerufen wird die in ihrem Profil hinterlegte Nummer.
+
+
+ Push aktiviert
+
+
+ Push-Benachrichtigung
+
+
+ Push-Verifizierung
+
+
+ Ein Test auf Anfrage kann nur alle 48 Stunden ausgeführt werden. Bitte versuchen Sie es später erneut.
+
+
+ Rollen
+
+
+ Dieser Testlauf wird noch versendet. Die Nachrichten werden im Hintergrund verschickt — aktualisieren Sie die Seite in Kürze, um die Ergebnisse zu sehen.
+
+
+ Diesen Kommunikationstest jetzt starten?
+
+Dabei wird eine echte Nachricht an jeden Benutzer im Testumfang gesendet, über jeden aktivierten Kanal.
+
+- SMS: Antwort mit dem im Bericht angezeigten Testcode
+- E-Mail: Klick auf einen Bestätigungslink
+- Sprachanruf: ein automatischer Anruf bittet, die 1 zu drücken
+- Push: erfordert die angemeldete Resgrid Responder App
+
+Der Versand erfolgt im Hintergrund, der Bericht füllt sich nach und nach.
+
+
+ Sa
+
+
+ Zeitplan
+
+
+ Umfang
+
+
+ Alle
+
+
+ 1 Gruppe
+
+
+ {0} Gruppen
+
+
+ {0} Personen
+
+
+ 1 Person
+
+
+ 1 Rolle
+
+
+ {0} Rollen
+
+
+ Senden fehlgeschlagen
+
+
+ SMS-Anbieter
+
+
+ SMS-Kontakt
+
+
+ SMS aktiviert
+
+
+ SMS-Verifizierung
+
+
+ So
+
+
+ Kommunikationstests richten sich an Benutzer, nicht an Einheiten. Für die Bestätigung einer Push-Benachrichtigung benötigen Mitglieder die installierte und angemeldete Resgrid Responder App; SMS, E-Mail und Sprachanrufe werden direkt am Telefon oder im Posteingang bestätigt und benötigen keine App. Öffnen Sie einen Test, um zu sehen, was Ihre Mitglieder genau erhalten.
+
+
+ Testname
+
+
+ Do
+
+
+ Di
+
+
+ Der Testlauf konnte nicht gestartet werden. Möglicherweise gilt eine Zeitbegrenzung.
+
+
+ Nicht verifiziert
+
+
+ Nicht bestätigt. Resgrid sendet nicht an diese Kanäle.
+
+
+ Verifiziert
+
+
+ Vom Benutzer bestätigt. An diese sendet Resgrid.
+
+
+ Sprachanruf
+
+
+ Sprachkontakt
+
+
+ Sprachanruf aktiviert
+
+
+ Sprachverifizierung
+
+
+ Mi
+
+
+ Was Ihre Mitglieder sehen
+
+
+ Wen testen
+
+
+ Lassen Sie alle drei Listen leer, um die gesamte Abteilung zu testen. Die Auswahl ist additiv: Wer einer ausgewählten Gruppe, Rolle oder einem Namen entspricht, wird einmal getestet.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
index 1ae3a59e5..63ec6d097 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -86,10 +86,10 @@
Περιγραφή
- Τύπος Προγραμματισμού
+ Τύπος Προγράμματος
- Κατά Παραγγελία
+ Κατ’ Απαίτηση
Εβδομαδιαία
@@ -98,10 +98,10 @@
Μηνιαία
- Ημέρες της Εβδομάδας
+ Ημέρα Εβδομάδας
- Ημέρα του Μήνα
+ Ημέρα Μήνα (1-28)
Ώρα
@@ -143,7 +143,7 @@
Ακύρωση
- Πρόσφατες Εκτελέσεις Δοκιμών
+ Πρόσφατες Εκτελέσεις
Εκτέλεση Τώρα
@@ -152,7 +152,7 @@
Κωδικός Εκτέλεσης
- Ξεκίνησε
+ Έναρξη
Κατάσταση
@@ -179,13 +179,13 @@
Χρήστης
- Επαφή
+ Επικοινωνία
Πάροχος
- Κατάσταση Επαλήθευσης
+ Επαλήθευση
Απάντησε
@@ -205,4 +205,268 @@
Το Resgrid έλαβε την απάντησή σας στη δοκιμή επικοινωνίας. Ευχαριστούμε.
-
+
+ Γεια σας {0},
+
+
+
+Η υπηρεσία {1} εκτελεί δοκιμή επικοινωνίας ({2}) για να επιβεβαιώσει ότι μπορεί να επικοινωνήσει μαζί σας.
+
+
+
+Πρόκειται για ΔΟΚΙΜΗ. Δεν υπάρχει έκτακτη ανάγκη και δεν απαιτείται καμία ενέργεια πέραν της επιβεβαίωσης λήψης αυτού του email.
+
+
+
+Κάντε κλικ στον παρακάτω σύνδεσμο για να επιβεβαιώσετε τη λήψη αυτού του μηνύματος:
+
+{3}
+
+
+
+Η ομάδα του Resgrid
+
+
+ Δοκιμή επικοινωνίας Resgrid: {0}
+
+
+ {0}: πρόκειται μόνο για δοκιμή. Ανοίξτε την εφαρμογή Resgrid Responder και επιβεβαιώστε τη λήψη.
+
+
+ Δοκιμή επικοινωνίας
+
+
+ Δοκιμή επικοινωνίας Resgrid ({0}). Πρόκειται μόνο για δοκιμή. Απαντήστε {1} για να επιβεβαιώσετε τη λήψη αυτού του μηνύματος.
+
+
+ Δοκιμή επικοινωνίας Resgrid. Πρόκειται μόνο για δοκιμή. Απαντήστε {0} για να επιβεβαιώσετε τη λήψη αυτού του μηνύματος.
+
+
+ Αυτή είναι μια δοκιμή επικοινωνίας Resgrid. Πρόκειται μόνο για δοκιμή. Δεν υπάρχει έκτακτη ανάγκη.
+
+
+ Δεν ελήφθη επιβεβαίωση. Αντίο.
+
+
+ Πατήστε 1 για να επιβεβαιώσετε τη λήψη αυτής της κλήσης.
+
+
+ Ευχαριστούμε. Η απάντησή σας καταγράφηκε.
+
+
+ Κανάλια
+
+
+ Επαλήθευση Επικοινωνίας
+
+
+ Διαγραφή
+
+
+ Επεξεργασία
+
+
+ Επαφή Email
+
+
+ Email Ενεργό
+
+
+ Επαλήθευση Email
+
+
+ Παρ
+
+
+ Παλαιό Καθεστώς
+
+
+ Προηγείται της επαλήθευσης. Εξακολουθεί να χρησιμοποιείται, αλλά χωρίς επιβεβαίωση — ζητήστε από αυτά τα μέλη να επαληθεύσουν.
+
+
+ Ομάδες
+
+
+ Αρχική
+
+
+ Άτομα
+
+
+ λεπτά
+
+
+ Δευ
+
+
+ Το όνομα είναι υποχρεωτικό.
+
+
+ Δ/Υ
+
+
+ Το push δεν έχει επαληθεύσιμο τρόπο επικοινωνίας — η παράδοση εξαρτάται από καταχωρημένη συσκευή.
+
+
+ Δεν Στάλθηκε (Χωρίς Στοιχεία)
+
+
+ Δεν Στάλθηκε (Απενεργοποιημένο)
+
+
+ Δεν Στάλθηκε (Μη Επαληθευμένο)
+
+
+ Μόνο οι δοκιμές κατ’ απαίτηση μπορούν να ξεκινήσουν χειροκίνητα. Οι προγραμματισμένες εκτελούνται αυτόματα.
+
+
+ Επιτρέπεται μόνο μία μηνιαία δοκιμή ανά υπηρεσία. Επεξεργαστείτε την υπάρχουσα.
+
+
+ Επιτρέπεται μόνο μία εβδομαδιαία δοκιμή ανά υπηρεσία. Επεξεργαστείτε την υπάρχουσα.
+
+
+ Το κλικ στον σύνδεσμο αποτελεί την επιβεβαίωση. Ο σύνδεσμος είναι μοναδικός ανά άτομο και μίας χρήσης.
+
+
+ Εμφανίζεται εδώ στη γλώσσα σας. Κάθε άτομο λαμβάνει το μήνυμα στη γλώσσα που έχει οριστεί στο δικό του προφίλ.
+
+
+ Απαιτεί την εφαρμογή Resgrid Responder, συνδεδεμένη και με ενεργές ειδοποιήσεις. Ανοίγοντας την ειδοποίηση εμφανίζεται κουμπί επιβεβαίωσης στην εφαρμογή. Όσοι δεν έχουν την εφαρμογή εμφανίζονται ως χωρίς απάντηση στο κανάλι push.
+
+
+ Απαντούν με τον κωδικό εκτέλεσης (για κάθε εκτέλεση δημιουργείται νέος, όπως {0}) στον ίδιο αριθμό από τον οποίο ήρθε το μήνυμα. Τα υπόλοιπα περιεχόμενα της απάντησης δεν έχουν σημασία.
+
+
+ Η κλήση εκφωνεί αυτό δύο φορές και μετά τερματίζει. Πατώντας 1 επιβεβαιώνεται η λήψη. Καλεί τον αριθμό που έχει οριστεί στο προφίλ τους.
+
+
+ Push Ενεργό
+
+
+ Ειδοποίηση Push
+
+
+ Επαλήθευση Push
+
+
+ Μια δοκιμή κατ’ απαίτηση μπορεί να εκτελεστεί μόνο μία φορά κάθε 48 ώρες. Δοκιμάστε αργότερα.
+
+
+ Ρόλοι
+
+
+ Αυτή η εκτέλεση αποστέλλεται ακόμη. Τα μηνύματα φεύγουν στο παρασκήνιο — ανανεώστε τη σελίδα σε λίγο για να δείτε τα αποτελέσματα.
+
+
+ Έναρξη αυτής της δοκιμής επικοινωνίας τώρα;
+
+Θα σταλεί πραγματικό μήνυμα σε κάθε χρήστη εντός του εύρους της δοκιμής, σε κάθε ενεργοποιημένο κανάλι.
+
+- SMS: απαντούν με τον κωδικό εκτέλεσης που εμφανίζεται στην αναφορά
+- Email: κάνουν κλικ σε σύνδεσμο επιβεβαίωσης
+- Φωνή: αυτόματη κλήση τούς ζητά να πατήσουν 1
+- Push: απαιτεί την εφαρμογή Resgrid Responder, συνδεδεμένη
+
+Τα μηνύματα φεύγουν στο παρασκήνιο, οπότε η αναφορά συμπληρώνεται σταδιακά.
+
+
+ Σαβ
+
+
+ Πρόγραμμα
+
+
+ Εύρος
+
+
+ Όλοι
+
+
+ 1 ομάδα
+
+
+ {0} ομάδες
+
+
+ {0} άτομα
+
+
+ 1 άτομο
+
+
+ 1 ρόλος
+
+
+ {0} ρόλοι
+
+
+ Αποτυχία Αποστολής
+
+
+ Πάροχος SMS
+
+
+ Επαφή SMS
+
+
+ SMS Ενεργό
+
+
+ Επαλήθευση SMS
+
+
+ Κυρ
+
+
+ Οι δοκιμές επικοινωνίας απευθύνονται σε χρήστες, όχι σε μονάδες. Το προσωπικό χρειάζεται την εφαρμογή Resgrid Responder εγκατεστημένη και συνδεδεμένη για να επιβεβαιώσει μια ειδοποίηση push· SMS, email και φωνητικές κλήσεις επιβεβαιώνονται από το τηλέφωνο ή το γραμματοκιβώτιο και δεν απαιτούν εφαρμογή. Ανοίξτε μια δοκιμή για να δείτε ακριβώς τι θα λάβει το προσωπικό σας.
+
+
+ Όνομα Δοκιμής
+
+
+ Πεμ
+
+
+ Τρι
+
+
+ Δεν ήταν δυνατή η έναρξη της εκτέλεσης. Ενδέχεται να ισχύει όριο συχνότητας.
+
+
+ Μη Επαληθευμένο
+
+
+ Δεν έχει επιβεβαιωθεί. Το Resgrid δεν θα στείλει σε αυτά τα κανάλια.
+
+
+ Επαληθευμένο
+
+
+ Επιβεβαιωμένο από τον χρήστη. Το Resgrid στέλνει σε αυτά.
+
+
+ Φωνητική Κλήση
+
+
+ Επαφή Φωνής
+
+
+ Φωνή Ενεργή
+
+
+ Επαλήθευση Φωνής
+
+
+ Τετ
+
+
+ Τι θα Δει το Προσωπικό σας
+
+
+ Ποιους να Δοκιμάσετε
+
+
+ Αφήστε και τις τρεις λίστες κενές για να δοκιμάσετε ολόκληρη την υπηρεσία. Οι επιλογές προστίθενται: όποιος ταιριάζει σε επιλεγμένη ομάδα, ρόλο ή όνομα δοκιμάζεται μία φορά.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
index b93767e12..76b40bd50 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -98,10 +98,10 @@
Monthly
- Days of Week
+ Day of Week
- Day of Month
+ Day of Month (1-28)
Time
@@ -185,7 +185,7 @@
Carrier
- Verification Status
+ Verification
Responded
@@ -205,4 +205,268 @@
Resgrid received your communication test response. Thank you.
-
+
+ Hi {0},
+
+
+
+{1} is running a communication test ({2}) to confirm it can reach you.
+
+
+
+This is a TEST. There is no emergency and no response is required beyond confirming you got this email.
+
+
+
+Please click the link below to confirm you received this message:
+
+{3}
+
+
+
+The Resgrid Team
+
+
+ Resgrid Communication Test: {0}
+
+
+ {0}: this is only a test. Open the Resgrid Responder app and confirm you received this.
+
+
+ Communication Test
+
+
+ Resgrid communication test ({0}). This is only a test. Reply {1} to confirm you received this message.
+
+
+ Resgrid communication test. This is only a test. Reply {0} to confirm you received this message.
+
+
+ This is a Resgrid communication test. This is only a test. There is no emergency.
+
+
+ No confirmation was received. Goodbye.
+
+
+ Press 1 to confirm you received this call.
+
+
+ Thank you. Your response has been recorded.
+
+
+ Channels
+
+
+ Contact Verification
+
+
+ Delete
+
+
+ Edit
+
+
+ Email Contact
+
+
+ Email Enabled
+
+
+ Email Verification
+
+
+ Fri
+
+
+ Grandfathered
+
+
+ Predates verification. Still sent to, but unconfirmed — ask these members to verify.
+
+
+ Groups
+
+
+ Home
+
+
+ Individuals
+
+
+ min
+
+
+ Mon
+
+
+ Name is required.
+
+
+ N/A
+
+
+ Push has no verified contact method — delivery depends on a registered device.
+
+
+ Not Sent (No Contact)
+
+
+ Not Sent (Turned Off)
+
+
+ Not Sent (Unverified)
+
+
+ Only on-demand tests can be started manually. Scheduled tests run automatically.
+
+
+ Only one monthly test is allowed per department. Please edit the existing one instead.
+
+
+ Only one weekly test is allowed per department. Please edit the existing one instead.
+
+
+ Clicking the link is the confirmation. The link is unique per person and single use.
+
+
+ Shown here in your language. Each person receives the message in the language set on their own profile.
+
+
+ Requires the Resgrid Responder app, signed in, with notifications allowed. Opening the notification shows a confirm button in the app. Anyone without the app installed shows as no response on the push channel.
+
+
+ They reply with the run code (a fresh one like {0} is generated per run) from the same number the message came from. Nothing else in the reply matters.
+
+
+ The call speaks this twice, then hangs up. Pressing 1 confirms receipt. It rings the number their profile is set to be called on.
+
+
+ Push Enabled
+
+
+ Push Notification
+
+
+ Push Verification
+
+
+ An on-demand test can only be run once every 48 hours. Please try again later.
+
+
+ Roles
+
+
+ This run is still being sent. Messages go out in the background, so refresh this page in a few moments to see the results fill in.
+
+
+ Start this communication test now?
+
+This sends a real message to every user in the test scope, on each channel it has enabled.
+
+- SMS: they reply with the run code shown in the report
+- Email: they click a confirmation link
+- Voice: an automated call asks them to press 1
+- Push: requires the Resgrid Responder app, signed in, to confirm
+
+Messages go out in the background, so the report fills in as they send.
+
+
+ Sat
+
+
+ Schedule
+
+
+ Scope
+
+
+ Everyone
+
+
+ 1 group
+
+
+ {0} groups
+
+
+ {0} people
+
+
+ 1 person
+
+
+ 1 role
+
+
+ {0} roles
+
+
+ Send Failed
+
+
+ SMS Carrier
+
+
+ SMS Contact
+
+
+ SMS Enabled
+
+
+ SMS Verification
+
+
+ Sun
+
+
+ Communication tests target users, not units. Personnel need the Resgrid Responder app installed and signed in to confirm a push notification; SMS, email and voice are confirmed from the phone or inbox and need no app. Open a test to see exactly what your people will receive.
+
+
+ Test Name
+
+
+ Thu
+
+
+ Tue
+
+
+ Unable to start the test run. Rate limit may apply.
+
+
+ Unverified
+
+
+ Not confirmed. Resgrid will not send to these channels.
+
+
+ Verified
+
+
+ Confirmed by the user. Resgrid sends to these.
+
+
+ Voice Call
+
+
+ Voice Contact
+
+
+ Voice Enabled
+
+
+ Voice Verification
+
+
+ Wed
+
+
+ What Your People Will See
+
+
+ Who To Test
+
+
+ Leave all three lists empty to test everyone in the department. Selections are additive: anyone matching a selected group, role or name is tested once.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
index b93767e12..a09cacf0b 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Pruebas de comunicación
- Communication Tests
+ Pruebas de comunicación
- New Test
+ Nueva prueba
- Edit Test
+ Editar prueba
- Delete Test
+ Eliminar prueba
- Are you sure you want to delete this test?
+ ¿Seguro que desea eliminar esta prueba?
- Test Definitions
+ Definiciones de prueba
- Name
+ Nombre
- Description
+ Descripción
- Schedule Type
+ Tipo de programación
- On Demand
+ Bajo demanda
- Weekly
+ Semanal
- Monthly
+ Mensual
- Days of Week
+ Día de la semana
- Day of Month
+ Día del mes (1-28)
- Time
+ Hora
- Channels to Test
+ Canales a probar
SMS
- Email
+ Correo electrónico
- Voice
+ Voz
Push
- Active
+ Activa
- Inactive
+ Inactiva
- Response Window
+ Ventana de respuesta
- Response Window (minutes)
+ Ventana de respuesta (minutos)
- Create Test
+ Crear prueba
- Save Changes
+ Guardar cambios
- Cancel
+ Cancelar
- Recent Test Runs
+ Ejecuciones recientes
- Run Now
+ Ejecutar ahora
- Run Code
+ Código de ejecución
- Started
+ Iniciada
- Status
+ Estado
- Users Tested
+ Usuarios probados
- Responses
+ Respuestas
- View Report
+ Ver informe
- Communication Test Report
+ Informe de prueba de comunicación
- Run Details
+ Detalles de la ejecución
- Per-User Results
+ Resultados por usuario
- User
+ Usuario
- Contact
+ Contacto
- Carrier
+ Operador
- Verification Status
+ Verificación
- Responded
+ Respondió
- No Response
+ Sin respuesta
- Not Sent
+ No enviado
- Response Rate
+ Tasa de respuesta
- Your communication test response has been recorded. Thank you.
+ Su respuesta a la prueba de comunicación ha sido registrada. Gracias.
- Resgrid received your communication test response. Thank you.
+ Resgrid recibió su respuesta a la prueba de comunicación. Gracias.
-
+
+ Hola {0}:
+
+
+
+{1} está realizando una prueba de comunicación ({2}) para confirmar que puede contactarle.
+
+
+
+Esto es una PRUEBA. No hay ninguna emergencia y no se requiere ninguna acción salvo confirmar que recibió este correo.
+
+
+
+Haga clic en el siguiente enlace para confirmar que recibió este mensaje:
+
+{3}
+
+
+
+El equipo de Resgrid
+
+
+ Prueba de comunicación de Resgrid: {0}
+
+
+ {0}: esto es solo una prueba. Abra la aplicación Resgrid Responder y confirme que la recibió.
+
+
+ Prueba de comunicación
+
+
+ Prueba de comunicación de Resgrid ({0}). Esto es solo una prueba. Responda {1} para confirmar que recibió este mensaje.
+
+
+ Prueba de comunicación de Resgrid. Esto es solo una prueba. Responda {0} para confirmar que recibió este mensaje.
+
+
+ Esta es una prueba de comunicación de Resgrid. Esto es solo una prueba. No hay ninguna emergencia.
+
+
+ No se recibió ninguna confirmación. Adiós.
+
+
+ Pulse 1 para confirmar que recibió esta llamada.
+
+
+ Gracias. Su respuesta ha sido registrada.
+
+
+ Canales
+
+
+ Verificación de contacto
+
+
+ Eliminar
+
+
+ Editar
+
+
+ Contacto de correo
+
+
+ Correo activado
+
+
+ Verificación de correo
+
+
+ Vie
+
+
+ Heredado
+
+
+ Anterior a la verificación. Se sigue enviando, pero sin confirmar: pida a estos miembros que verifiquen.
+
+
+ Grupos
+
+
+ Inicio
+
+
+ Personas
+
+
+ min
+
+
+ Lun
+
+
+ El nombre es obligatorio.
+
+
+ N/D
+
+
+ Push no tiene un método de contacto verificable: la entrega depende de un dispositivo registrado.
+
+
+ No enviado (sin contacto)
+
+
+ No enviado (desactivado)
+
+
+ No enviado (sin verificar)
+
+
+ Solo las pruebas bajo demanda pueden iniciarse manualmente. Las programadas se ejecutan automáticamente.
+
+
+ Solo se permite una prueba mensual por departamento. Edite la existente en su lugar.
+
+
+ Solo se permite una prueba semanal por departamento. Edite la existente en su lugar.
+
+
+ Hacer clic en el enlace es la confirmación. El enlace es único por persona y de un solo uso.
+
+
+ Se muestra aquí en su idioma. Cada persona recibe el mensaje en el idioma configurado en su propio perfil.
+
+
+ Requiere la aplicación Resgrid Responder, con sesión iniciada y notificaciones permitidas. Al abrir la notificación aparece un botón de confirmación en la aplicación. Quien no tenga la aplicación instalada figura como sin respuesta en el canal push.
+
+
+ Responden con el código de ejecución (se genera uno nuevo como {0} en cada ejecución) al mismo número desde el que llegó el mensaje. El resto de la respuesta no importa.
+
+
+ La llamada dice esto dos veces y luego cuelga. Pulsar 1 confirma la recepción. Llama al número configurado en su perfil.
+
+
+ Push activado
+
+
+ Notificación push
+
+
+ Verificación push
+
+
+ Una prueba bajo demanda solo puede ejecutarse una vez cada 48 horas. Inténtelo más tarde.
+
+
+ Roles
+
+
+ Esta ejecución aún se está enviando. Los mensajes salen en segundo plano; actualice esta página en unos momentos para ver los resultados.
+
+
+ ¿Iniciar esta prueba de comunicación ahora?
+
+Se enviará un mensaje real a cada usuario del alcance de la prueba, por cada canal habilitado.
+
+- SMS: responden con el código de ejecución del informe
+- Correo: hacen clic en un enlace de confirmación
+- Voz: una llamada automática les pide pulsar 1
+- Push: requiere la aplicación Resgrid Responder con sesión iniciada
+
+Los mensajes salen en segundo plano, así que el informe se irá completando.
+
+
+ Sáb
+
+
+ Programación
+
+
+ Alcance
+
+
+ Todos
+
+
+ 1 grupo
+
+
+ {0} grupos
+
+
+ {0} personas
+
+
+ 1 persona
+
+
+ 1 rol
+
+
+ {0} roles
+
+
+ Error de envío
+
+
+ Operador SMS
+
+
+ Contacto SMS
+
+
+ SMS activado
+
+
+ Verificación SMS
+
+
+ Dom
+
+
+ Las pruebas de comunicación se dirigen a usuarios, no a unidades. El personal necesita la aplicación Resgrid Responder instalada y con sesión iniciada para confirmar una notificación push; SMS, correo y voz se confirman desde el teléfono o el buzón y no requieren aplicación. Abra una prueba para ver exactamente qué recibirá su personal.
+
+
+ Nombre de la prueba
+
+
+ Jue
+
+
+ Mar
+
+
+ No se pudo iniciar la ejecución. Puede aplicarse un límite de frecuencia.
+
+
+ Sin verificar
+
+
+ Sin confirmar. Resgrid no enviará a estos canales.
+
+
+ Verificado
+
+
+ Confirmado por el usuario. Resgrid envía a estos.
+
+
+ Llamada de voz
+
+
+ Contacto de voz
+
+
+ Voz activada
+
+
+ Verificación de voz
+
+
+ Mié
+
+
+ Lo que verá su personal
+
+
+ A quién probar
+
+
+ Deje las tres listas vacías para probar a todo el departamento. Las selecciones son acumulativas: quien coincida con un grupo, rol o nombre seleccionado se prueba una vez.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
index b93767e12..dd737e15a 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Tests de communication
- Communication Tests
+ Tests de communication
- New Test
+ Nouveau test
- Edit Test
+ Modifier le test
- Delete Test
+ Supprimer le test
- Are you sure you want to delete this test?
+ Voulez-vous vraiment supprimer ce test ?
- Test Definitions
+ Définitions des tests
- Name
+ Nom
Description
- Schedule Type
+ Type de planification
- On Demand
+ À la demande
- Weekly
+ Hebdomadaire
- Monthly
+ Mensuel
- Days of Week
+ Jour de la semaine
- Day of Month
+ Jour du mois (1-28)
- Time
+ Heure
- Channels to Test
+ Canaux à tester
SMS
- Email
+ E-mail
- Voice
+ Appel vocal
Push
- Active
+ Actif
- Inactive
+ Inactif
- Response Window
+ Délai de réponse
- Response Window (minutes)
+ Délai de réponse (minutes)
- Create Test
+ Créer le test
- Save Changes
+ Enregistrer
- Cancel
+ Annuler
- Recent Test Runs
+ Exécutions récentes
- Run Now
+ Lancer maintenant
- Run Code
+ Code d’exécution
- Started
+ Démarré
- Status
+ Statut
- Users Tested
+ Utilisateurs testés
- Responses
+ Réponses
- View Report
+ Voir le rapport
- Communication Test Report
+ Rapport de test de communication
- Run Details
+ Détails de l’exécution
- Per-User Results
+ Résultats par utilisateur
- User
+ Utilisateur
Contact
- Carrier
+ Opérateur
- Verification Status
+ Vérification
- Responded
+ A répondu
- No Response
+ Aucune réponse
- Not Sent
+ Non envoyé
- Response Rate
+ Taux de réponse
- Your communication test response has been recorded. Thank you.
+ Votre réponse au test de communication a été enregistrée. Merci.
- Resgrid received your communication test response. Thank you.
+ Resgrid a bien reçu votre réponse au test de communication. Merci.
-
+
+ Bonjour {0},
+
+
+
+{1} effectue un test de communication ({2}) afin de vérifier qu'il peut vous joindre.
+
+
+
+Ceci est un TEST. Il n'y a aucune urgence et aucune action n'est requise, hormis confirmer la réception de cet e-mail.
+
+
+
+Veuillez cliquer sur le lien ci-dessous pour confirmer la réception de ce message :
+
+{3}
+
+
+
+L'équipe Resgrid
+
+
+ Test de communication Resgrid : {0}
+
+
+ {0} : ceci n'est qu'un test. Ouvrez l'application Resgrid Responder et confirmez la réception.
+
+
+ Test de communication
+
+
+ Test de communication Resgrid ({0}). Ceci n'est qu'un test. Répondez {1} pour confirmer la réception de ce message.
+
+
+ Test de communication Resgrid. Ceci n'est qu'un test. Répondez {0} pour confirmer la réception de ce message.
+
+
+ Ceci est un test de communication Resgrid. Ceci n'est qu'un test. Il n'y a aucune urgence.
+
+
+ Aucune confirmation n'a été reçue. Au revoir.
+
+
+ Appuyez sur 1 pour confirmer la réception de cet appel.
+
+
+ Merci. Votre réponse a été enregistrée.
+
+
+ Canaux
+
+
+ Vérification des contacts
+
+
+ Supprimer
+
+
+ Modifier
+
+
+ Contact e-mail
+
+
+ E-mail activé
+
+
+ Vérification e-mail
+
+
+ Ven
+
+
+ Antérieur
+
+
+ Antérieur à la vérification. Toujours utilisé, mais non confirmé — demandez à ces membres de vérifier.
+
+
+ Groupes
+
+
+ Accueil
+
+
+ Personnes
+
+
+ min
+
+
+ Lun
+
+
+ Le nom est obligatoire.
+
+
+ S.O.
+
+
+ Le push n’a pas de moyen de contact vérifiable — la remise dépend d’un appareil enregistré.
+
+
+ Non envoyé (aucun contact)
+
+
+ Non envoyé (désactivé)
+
+
+ Non envoyé (non vérifié)
+
+
+ Seuls les tests à la demande peuvent être lancés manuellement. Les tests planifiés s’exécutent automatiquement.
+
+
+ Un seul test mensuel est autorisé par service. Modifiez plutôt celui qui existe.
+
+
+ Un seul test hebdomadaire est autorisé par service. Modifiez plutôt celui qui existe.
+
+
+ Le clic sur le lien constitue la confirmation. Le lien est unique par personne et à usage unique.
+
+
+ Affiché ici dans votre langue. Chaque personne reçoit le message dans la langue définie sur son propre profil.
+
+
+ Nécessite l’application Resgrid Responder, connectée et avec les notifications autorisées. L’ouverture de la notification affiche un bouton de confirmation dans l’application. Les personnes sans l’application apparaissent comme sans réponse sur le canal push.
+
+
+ Ils répondent avec le code d’exécution (un nouveau code comme {0} est généré à chaque exécution) au même numéro que celui de l’envoi. Le reste de la réponse est sans importance.
+
+
+ L’appel énonce ce message deux fois, puis raccroche. Appuyer sur 1 confirme la réception. Il compose le numéro défini dans leur profil.
+
+
+ Push activé
+
+
+ Notification push
+
+
+ Vérification push
+
+
+ Un test à la demande ne peut être lancé qu’une fois toutes les 48 heures. Réessayez plus tard.
+
+
+ Rôles
+
+
+ Cet envoi est encore en cours. Les messages partent en arrière-plan : actualisez cette page dans quelques instants pour voir les résultats.
+
+
+ Lancer ce test de communication maintenant ?
+
+Un message réel sera envoyé à chaque utilisateur concerné, sur chaque canal activé.
+
+- SMS : ils répondent avec le code d’exécution indiqué dans le rapport
+- E-mail : ils cliquent sur un lien de confirmation
+- Vocal : un appel automatique leur demande d’appuyer sur 1
+- Push : nécessite l’application Resgrid Responder, connectée
+
+Les messages partent en arrière-plan ; le rapport se remplit au fur et à mesure.
+
+
+ Sam
+
+
+ Planification
+
+
+ Portée
+
+
+ Tout le monde
+
+
+ 1 groupe
+
+
+ {0} groupes
+
+
+ {0} personnes
+
+
+ 1 personne
+
+
+ 1 rôle
+
+
+ {0} rôles
+
+
+ Échec de l’envoi
+
+
+ Opérateur SMS
+
+
+ Contact SMS
+
+
+ SMS activé
+
+
+ Vérification SMS
+
+
+ Dim
+
+
+ Les tests de communication ciblent les utilisateurs, pas les unités. Le personnel doit disposer de l’application Resgrid Responder installée et connectée pour confirmer une notification push ; les SMS, e-mails et appels vocaux se confirment depuis le téléphone ou la boîte mail, sans application. Ouvrez un test pour voir exactement ce que votre personnel recevra.
+
+
+ Nom du test
+
+
+ Jeu
+
+
+ Mar
+
+
+ Impossible de lancer l’exécution. Une limite de fréquence peut s’appliquer.
+
+
+ Non vérifié
+
+
+ Non confirmé. Resgrid n’enverra pas vers ces canaux.
+
+
+ Vérifié
+
+
+ Confirmé par l’utilisateur. Resgrid envoie vers ceux-ci.
+
+
+ Appel vocal
+
+
+ Contact vocal
+
+
+ Appel vocal activé
+
+
+ Vérification vocale
+
+
+ Mer
+
+
+ Ce que votre personnel verra
+
+
+ Qui tester
+
+
+ Laissez les trois listes vides pour tester tout le service. Les sélections s’additionnent : toute personne correspondant à un groupe, un rôle ou un nom sélectionné est testée une seule fois.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
index b93767e12..964f5988c 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,55 +59,55 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Test di comunicazione
- Communication Tests
+ Test di comunicazione
- New Test
+ Nuovo test
- Edit Test
+ Modifica test
- Delete Test
+ Elimina test
- Are you sure you want to delete this test?
+ Vuoi davvero eliminare questo test?
- Test Definitions
+ Definizioni dei test
- Name
+ Nome
- Description
+ Descrizione
- Schedule Type
+ Tipo di pianificazione
- On Demand
+ Su richiesta
- Weekly
+ Settimanale
- Monthly
+ Mensile
- Days of Week
+ Giorno della settimana
- Day of Month
+ Giorno del mese (1-28)
- Time
+ Ora
- Channels to Test
+ Canali da testare
SMS
@@ -116,93 +116,357 @@
Email
- Voice
+ Voce
Push
- Active
+ Attivo
- Inactive
+ Inattivo
- Response Window
+ Finestra di risposta
- Response Window (minutes)
+ Finestra di risposta (minuti)
- Create Test
+ Crea test
- Save Changes
+ Salva modifiche
- Cancel
+ Annulla
- Recent Test Runs
+ Esecuzioni recenti
- Run Now
+ Esegui ora
- Run Code
+ Codice esecuzione
- Started
+ Avviato
- Status
+ Stato
- Users Tested
+ Utenti testati
- Responses
+ Risposte
- View Report
+ Visualizza report
- Communication Test Report
+ Report del test di comunicazione
- Run Details
+ Dettagli esecuzione
- Per-User Results
+ Risultati per utente
- User
+ Utente
- Contact
+ Contatto
- Carrier
+ Operatore
- Verification Status
+ Verifica
- Responded
+ Ha risposto
- No Response
+ Nessuna risposta
- Not Sent
+ Non inviato
- Response Rate
+ Tasso di risposta
- Your communication test response has been recorded. Thank you.
+ La tua risposta al test di comunicazione è stata registrata. Grazie.
- Resgrid received your communication test response. Thank you.
+ Resgrid ha ricevuto la tua risposta al test di comunicazione. Grazie.
-
+
+ Ciao {0},
+
+
+
+{1} sta effettuando un test di comunicazione ({2}) per verificare di poterti raggiungere.
+
+
+
+Questo è un TEST. Non c'è alcuna emergenza e non è richiesta alcuna azione oltre a confermare di aver ricevuto questa email.
+
+
+
+Fai clic sul link qui sotto per confermare di aver ricevuto questo messaggio:
+
+{3}
+
+
+
+Il team Resgrid
+
+
+ Test di comunicazione Resgrid: {0}
+
+
+ {0}: questo è solo un test. Apri l'app Resgrid Responder e conferma di averlo ricevuto.
+
+
+ Test di comunicazione
+
+
+ Test di comunicazione Resgrid ({0}). Questo è solo un test. Rispondi {1} per confermare di aver ricevuto questo messaggio.
+
+
+ Test di comunicazione Resgrid. Questo è solo un test. Rispondi {0} per confermare di aver ricevuto questo messaggio.
+
+
+ Questo è un test di comunicazione Resgrid. Questo è solo un test. Non c'è alcuna emergenza.
+
+
+ Nessuna conferma ricevuta. Arrivederci.
+
+
+ Premi 1 per confermare di aver ricevuto questa chiamata.
+
+
+ Grazie. La tua risposta è stata registrata.
+
+
+ Canali
+
+
+ Verifica dei contatti
+
+
+ Elimina
+
+
+ Modifica
+
+
+ Contatto email
+
+
+ Email attiva
+
+
+ Verifica email
+
+
+ Ven
+
+
+ Preesistente
+
+
+ Precedente alla verifica. Viene comunque usato, ma non confermato: chiedi a questi membri di verificare.
+
+
+ Gruppi
+
+
+ Home
+
+
+ Persone
+
+
+ min
+
+
+ Lun
+
+
+ Il nome è obbligatorio.
+
+
+ N/D
+
+
+ Il push non ha un contatto verificabile: la consegna dipende da un dispositivo registrato.
+
+
+ Non inviato (nessun contatto)
+
+
+ Non inviato (disattivato)
+
+
+ Non inviato (non verificato)
+
+
+ Solo i test su richiesta possono essere avviati manualmente. Quelli pianificati partono automaticamente.
+
+
+ È consentito un solo test mensile per dipartimento. Modifica invece quello esistente.
+
+
+ È consentito un solo test settimanale per dipartimento. Modifica invece quello esistente.
+
+
+ Il clic sul link è la conferma. Il link è unico per persona e monouso.
+
+
+ Mostrato qui nella tua lingua. Ogni persona riceve il messaggio nella lingua impostata nel proprio profilo.
+
+
+ Richiede l’app Resgrid Responder, con accesso effettuato e notifiche consentite. Aprendo la notifica compare un pulsante di conferma nell’app. Chi non ha l’app installata risulta senza risposta sul canale push.
+
+
+ Rispondono con il codice esecuzione (per ogni esecuzione ne viene generato uno nuovo come {0}) allo stesso numero da cui è arrivato il messaggio. Il resto della risposta non conta.
+
+
+ La chiamata pronuncia questo testo due volte, poi riaggancia. Premere 1 conferma la ricezione. Chiama il numero impostato nel loro profilo.
+
+
+ Push attivo
+
+
+ Notifica push
+
+
+ Verifica push
+
+
+ Un test su richiesta può essere eseguito solo una volta ogni 48 ore. Riprova più tardi.
+
+
+ Ruoli
+
+
+ Questa esecuzione è ancora in invio. I messaggi partono in background: aggiorna la pagina tra poco per vedere i risultati.
+
+
+ Avviare ora questo test di comunicazione?
+
+Verrà inviato un messaggio reale a ogni utente incluso nel test, su ciascun canale abilitato.
+
+- SMS: rispondono con il codice esecuzione indicato nel report
+- Email: fanno clic su un link di conferma
+- Voce: una chiamata automatica chiede di premere 1
+- Push: richiede l’app Resgrid Responder con accesso effettuato
+
+I messaggi partono in background, quindi il report si popola man mano.
+
+
+ Sab
+
+
+ Pianificazione
+
+
+ Ambito
+
+
+ Tutti
+
+
+ 1 gruppo
+
+
+ {0} gruppi
+
+
+ {0} persone
+
+
+ 1 persona
+
+
+ 1 ruolo
+
+
+ {0} ruoli
+
+
+ Invio non riuscito
+
+
+ Operatore SMS
+
+
+ Contatto SMS
+
+
+ SMS attivo
+
+
+ Verifica SMS
+
+
+ Dom
+
+
+ I test di comunicazione riguardano gli utenti, non le unità. Il personale deve avere l’app Resgrid Responder installata e con accesso effettuato per confermare una notifica push; SMS, email e chiamate si confermano dal telefono o dalla casella di posta e non richiedono app. Apri un test per vedere esattamente cosa riceverà il tuo personale.
+
+
+ Nome del test
+
+
+ Gio
+
+
+ Mar
+
+
+ Impossibile avviare l’esecuzione. Potrebbe essere attivo un limite di frequenza.
+
+
+ Non verificato
+
+
+ Non confermato. Resgrid non invierà a questi canali.
+
+
+ Verificato
+
+
+ Confermato dall’utente. Resgrid invia a questi.
+
+
+ Chiamata vocale
+
+
+ Contatto vocale
+
+
+ Voce attiva
+
+
+ Verifica vocale
+
+
+ Mer
+
+
+ Cosa vedrà il tuo personale
+
+
+ Chi testare
+
+
+ Lascia vuote tutte e tre le liste per testare l’intero dipartimento. Le selezioni si sommano: chi corrisponde a un gruppo, ruolo o nome selezionato viene testato una volta.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
index b93767e12..8fc237823 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Testy łączności
- Communication Tests
+ Testy łączności
- New Test
+ Nowy test
- Edit Test
+ Edytuj test
- Delete Test
+ Usuń test
- Are you sure you want to delete this test?
+ Czy na pewno chcesz usunąć ten test?
- Test Definitions
+ Definicje testów
- Name
+ Nazwa
- Description
+ Opis
- Schedule Type
+ Typ harmonogramu
- On Demand
+ Na żądanie
- Weekly
+ Cotygodniowo
- Monthly
+ Comiesięcznie
- Days of Week
+ Dzień tygodnia
- Day of Month
+ Dzień miesiąca (1-28)
- Time
+ Godzina
- Channels to Test
+ Kanały do przetestowania
SMS
- Email
+ E-mail
- Voice
+ Połączenie głosowe
Push
- Active
+ Aktywny
- Inactive
+ Nieaktywny
- Response Window
+ Okno odpowiedzi
- Response Window (minutes)
+ Okno odpowiedzi (minuty)
- Create Test
+ Utwórz test
- Save Changes
+ Zapisz zmiany
- Cancel
+ Anuluj
- Recent Test Runs
+ Ostatnie uruchomienia
- Run Now
+ Uruchom teraz
- Run Code
+ Kod uruchomienia
- Started
+ Rozpoczęto
Status
- Users Tested
+ Przetestowani użytkownicy
- Responses
+ Odpowiedzi
- View Report
+ Zobacz raport
- Communication Test Report
+ Raport testu łączności
- Run Details
+ Szczegóły uruchomienia
- Per-User Results
+ Wyniki dla użytkowników
- User
+ Użytkownik
- Contact
+ Kontakt
- Carrier
+ Operator
- Verification Status
+ Weryfikacja
- Responded
+ Odpowiedział
- No Response
+ Brak odpowiedzi
- Not Sent
+ Nie wysłano
- Response Rate
+ Wskaźnik odpowiedzi
- Your communication test response has been recorded. Thank you.
+ Twoja odpowiedź na test łączności została zapisana. Dziękujemy.
- Resgrid received your communication test response. Thank you.
+ Resgrid otrzymał Twoją odpowiedź na test łączności. Dziękujemy.
-
+
+ Cześć {0},
+
+
+
+{1} przeprowadza test łączności ({2}), aby potwierdzić możliwość skontaktowania się z Tobą.
+
+
+
+To jest TEST. Nie ma żadnego zagrożenia i nie jest wymagane żadne działanie poza potwierdzeniem odbioru tej wiadomości.
+
+
+
+Kliknij poniższy link, aby potwierdzić odbiór tej wiadomości:
+
+{3}
+
+
+
+Zespół Resgrid
+
+
+ Test łączności Resgrid: {0}
+
+
+ {0}: to tylko test. Otwórz aplikację Resgrid Responder i potwierdź odbiór.
+
+
+ Test łączności
+
+
+ Test łączności Resgrid ({0}). To tylko test. Odpowiedz {1}, aby potwierdzić odbiór tej wiadomości.
+
+
+ Test łączności Resgrid. To tylko test. Odpowiedz {0}, aby potwierdzić odbiór tej wiadomości.
+
+
+ To jest test łączności Resgrid. To tylko test. Nie ma żadnego zagrożenia.
+
+
+ Nie otrzymano potwierdzenia. Do usłyszenia.
+
+
+ Naciśnij 1, aby potwierdzić odbiór tego połączenia.
+
+
+ Dziękujemy. Twoja odpowiedź została zapisana.
+
+
+ Kanały
+
+
+ Weryfikacja kontaktu
+
+
+ Usuń
+
+
+ Edytuj
+
+
+ Kontakt e-mail
+
+
+ E-mail włączony
+
+
+ Weryfikacja e-mail
+
+
+ Pt
+
+
+ Zachowany
+
+
+ Sprzed wprowadzenia weryfikacji. Nadal używane, ale niepotwierdzone — poproś te osoby o weryfikację.
+
+
+ Grupy
+
+
+ Strona główna
+
+
+ Osoby
+
+
+ min
+
+
+ Pn
+
+
+ Nazwa jest wymagana.
+
+
+ Nd.
+
+
+ Push nie ma weryfikowalnej metody kontaktu — dostarczenie zależy od zarejestrowanego urządzenia.
+
+
+ Nie wysłano (brak kontaktu)
+
+
+ Nie wysłano (wyłączone)
+
+
+ Nie wysłano (niezweryfikowany)
+
+
+ Ręcznie można uruchomić tylko testy na żądanie. Testy zaplanowane uruchamiają się automatycznie.
+
+
+ Dozwolony jest tylko jeden test miesięczny na oddział. Zamiast tego edytuj istniejący.
+
+
+ Dozwolony jest tylko jeden test tygodniowy na oddział. Zamiast tego edytuj istniejący.
+
+
+ Kliknięcie linku jest potwierdzeniem. Link jest unikalny dla każdej osoby i jednorazowy.
+
+
+ Wyświetlane tutaj w Twoim języku. Każda osoba otrzyma wiadomość w języku ustawionym w swoim profilu.
+
+
+ Wymaga aplikacji Resgrid Responder z zalogowanym kontem i włączonymi powiadomieniami. Otwarcie powiadomienia wyświetla w aplikacji przycisk potwierdzenia. Osoby bez zainstalowanej aplikacji są wykazywane jako brak odpowiedzi w kanale push.
+
+
+ Odpowiadają kodem uruchomienia (dla każdego uruchomienia generowany jest nowy, np. {0}) na ten sam numer, z którego przyszła wiadomość. Reszta treści odpowiedzi nie ma znaczenia.
+
+
+ Połączenie odtwarza tę treść dwa razy, po czym się rozłącza. Naciśnięcie 1 potwierdza odbiór. Dzwoni na numer ustawiony w profilu danej osoby.
+
+
+ Push włączone
+
+
+ Powiadomienie push
+
+
+ Weryfikacja push
+
+
+ Test na żądanie można uruchomić tylko raz na 48 godzin. Spróbuj ponownie później.
+
+
+ Role
+
+
+ To uruchomienie jest wciąż wysyłane. Wiadomości są wysyłane w tle — odśwież tę stronę za chwilę, aby zobaczyć wyniki.
+
+
+ Uruchomić ten test łączności teraz?
+
+Zostanie wysłana prawdziwa wiadomość do każdego użytkownika objętego testem, na każdym włączonym kanale.
+
+- SMS: odpowiadają kodem uruchomienia widocznym w raporcie
+- E-mail: klikają link potwierdzający
+- Głos: automatyczne połączenie prosi o naciśnięcie 1
+- Push: wymaga aplikacji Resgrid Responder z zalogowanym kontem
+
+Wiadomości są wysyłane w tle, więc raport będzie się stopniowo wypełniał.
+
+
+ Sb
+
+
+ Harmonogram
+
+
+ Zakres
+
+
+ Wszyscy
+
+
+ 1 grupa
+
+
+ Grupy: {0}
+
+
+ Osoby: {0}
+
+
+ 1 osoba
+
+
+ 1 rola
+
+
+ Role: {0}
+
+
+ Wysyłka nieudana
+
+
+ Operator SMS
+
+
+ Kontakt SMS
+
+
+ SMS włączone
+
+
+ Weryfikacja SMS
+
+
+ Nd
+
+
+ Testy łączności są kierowane do użytkowników, a nie do jednostek. Do potwierdzenia powiadomienia push personel potrzebuje zainstalowanej aplikacji Resgrid Responder z zalogowanym kontem; SMS, e-mail i połączenie głosowe potwierdza się z telefonu lub skrzynki i nie wymagają aplikacji. Otwórz test, aby zobaczyć dokładnie, co otrzyma Twój personel.
+
+
+ Nazwa testu
+
+
+ Cz
+
+
+ Wt
+
+
+ Nie udało się uruchomić testu. Może obowiązywać limit częstotliwości.
+
+
+ Niezweryfikowany
+
+
+ Niepotwierdzone. Resgrid nie wyśle na te kanały.
+
+
+ Zweryfikowany
+
+
+ Potwierdzone przez użytkownika. Resgrid wysyła na te kanały.
+
+
+ Połączenie głosowe
+
+
+ Kontakt głosowy
+
+
+ Połączenia głosowe włączone
+
+
+ Weryfikacja głosowa
+
+
+ Śr
+
+
+ Co zobaczy Twój personel
+
+
+ Kogo testować
+
+
+ Pozostaw wszystkie trzy listy puste, aby przetestować cały oddział. Wybory sumują się: każda osoba pasująca do wybranej grupy, roli lub nazwiska jest testowana raz.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
index b93767e12..0ff61d98d 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Communication Tests
+ Kommunikationstester
- Communication Tests
+ Kommunikationstester
- New Test
+ Nytt test
- Edit Test
+ Redigera test
- Delete Test
+ Ta bort test
- Are you sure you want to delete this test?
+ Är du säker på att du vill ta bort detta test?
- Test Definitions
+ Testdefinitioner
- Name
+ Namn
- Description
+ Beskrivning
- Schedule Type
+ Schematyp
- On Demand
+ Vid behov
- Weekly
+ Veckovis
- Monthly
+ Månadsvis
- Days of Week
+ Veckodag
- Day of Month
+ Dag i månaden (1-28)
- Time
+ Tid
- Channels to Test
+ Kanaler att testa
SMS
- Email
+ E-post
- Voice
+ Röstsamtal
Push
- Active
+ Aktiv
- Inactive
+ Inaktiv
- Response Window
+ Svarsfönster
- Response Window (minutes)
+ Svarsfönster (minuter)
- Create Test
+ Skapa test
- Save Changes
+ Spara ändringar
- Cancel
+ Avbryt
- Recent Test Runs
+ Senaste testkörningar
- Run Now
+ Kör nu
- Run Code
+ Körkod
- Started
+ Startad
Status
- Users Tested
+ Testade användare
- Responses
+ Svar
- View Report
+ Visa rapport
- Communication Test Report
+ Rapport för kommunikationstest
- Run Details
+ Körningsdetaljer
- Per-User Results
+ Resultat per användare
- User
+ Användare
- Contact
+ Kontakt
- Carrier
+ Operatör
- Verification Status
+ Verifiering
- Responded
+ Svarade
- No Response
+ Inget svar
- Not Sent
+ Ej skickat
- Response Rate
+ Svarsfrekvens
- Your communication test response has been recorded. Thank you.
+ Ditt svar på kommunikationstestet har registrerats. Tack.
- Resgrid received your communication test response. Thank you.
+ Resgrid har tagit emot ditt svar på kommunikationstestet. Tack.
-
+
+ Hej {0},
+
+
+
+{1} genomför ett kommunikationstest ({2}) för att bekräfta att de kan nå dig.
+
+
+
+Detta är ett TEST. Det föreligger ingen nödsituation och ingen åtgärd krävs utöver att bekräfta att du fått detta e-postmeddelande.
+
+
+
+Klicka på länken nedan för att bekräfta att du fått detta meddelande:
+
+{3}
+
+
+
+Resgrid-teamet
+
+
+ Resgrid kommunikationstest: {0}
+
+
+ {0}: detta är bara ett test. Öppna appen Resgrid Responder och bekräfta att du fått det.
+
+
+ Kommunikationstest
+
+
+ Resgrid kommunikationstest ({0}). Detta är bara ett test. Svara {1} för att bekräfta att du fått detta meddelande.
+
+
+ Resgrid kommunikationstest. Detta är bara ett test. Svara {0} för att bekräfta att du fått detta meddelande.
+
+
+ Detta är ett Resgrid kommunikationstest. Detta är bara ett test. Det föreligger ingen nödsituation.
+
+
+ Ingen bekräftelse mottogs. Hej då.
+
+
+ Tryck 1 för att bekräfta att du tagit emot detta samtal.
+
+
+ Tack. Ditt svar har registrerats.
+
+
+ Kanaler
+
+
+ Kontaktverifiering
+
+
+ Ta bort
+
+
+ Redigera
+
+
+ E-postkontakt
+
+
+ E-post aktiverat
+
+
+ E-postverifiering
+
+
+ Fre
+
+
+ Undantagen
+
+
+ Föregår verifieringen. Skickas fortfarande till, men obekräftad — be dessa medlemmar att verifiera.
+
+
+ Grupper
+
+
+ Hem
+
+
+ Enskilda personer
+
+
+ min
+
+
+ Mån
+
+
+ Namn krävs.
+
+
+ Ej tillämpligt
+
+
+ Push har ingen verifierbar kontaktmetod — leveransen beror på en registrerad enhet.
+
+
+ Ej skickat (ingen kontakt)
+
+
+ Ej skickat (avstängt)
+
+
+ Ej skickat (overifierad)
+
+
+ Endast test vid behov kan startas manuellt. Schemalagda test körs automatiskt.
+
+
+ Endast ett månadstest tillåts per avdelning. Redigera det befintliga i stället.
+
+
+ Endast ett veckotest tillåts per avdelning. Redigera det befintliga i stället.
+
+
+ Att klicka på länken är bekräftelsen. Länken är unik per person och kan bara användas en gång.
+
+
+ Visas här på ditt språk. Varje person får meddelandet på det språk som är inställt i deras egen profil.
+
+
+ Kräver appen Resgrid Responder, inloggad och med aviseringar tillåtna. När aviseringen öppnas visas en bekräftelseknapp i appen. Den som inte har appen installerad visas som inget svar i push-kanalen.
+
+
+ De svarar med körkoden (en ny som {0} genereras per körning) till samma nummer som meddelandet kom från. Resten av svaret spelar ingen roll.
+
+
+ Samtalet läser upp detta två gånger och lägger sedan på. Att trycka 1 bekräftar mottagandet. Det ringer det nummer som är inställt i deras profil.
+
+
+ Push aktiverat
+
+
+ Push-avisering
+
+
+ Push-verifiering
+
+
+ Ett test vid behov kan bara köras en gång var 48:e timme. Försök igen senare.
+
+
+ Roller
+
+
+ Denna körning skickas fortfarande. Meddelandena går ut i bakgrunden — uppdatera sidan om en stund för att se resultaten.
+
+
+ Starta detta kommunikationstest nu?
+
+Ett riktigt meddelande skickas till varje användare inom testets omfattning, på varje aktiverad kanal.
+
+- SMS: de svarar med körkoden som visas i rapporten
+- E-post: de klickar på en bekräftelselänk
+- Röst: ett automatiskt samtal ber dem trycka 1
+- Push: kräver appen Resgrid Responder, inloggad
+
+Meddelandena går ut i bakgrunden, så rapporten fylls i efterhand.
+
+
+ Lör
+
+
+ Schema
+
+
+ Omfattning
+
+
+ Alla
+
+
+ 1 grupp
+
+
+ {0} grupper
+
+
+ {0} personer
+
+
+ 1 person
+
+
+ 1 roll
+
+
+ {0} roller
+
+
+ Sändning misslyckades
+
+
+ SMS-operatör
+
+
+ SMS-kontakt
+
+
+ SMS aktiverat
+
+
+ SMS-verifiering
+
+
+ Sön
+
+
+ Kommunikationstester riktar sig till användare, inte enheter. Personalen behöver appen Resgrid Responder installerad och inloggad för att bekräfta en push-avisering; SMS, e-post och röstsamtal bekräftas från telefonen eller inkorgen och kräver ingen app. Öppna ett test för att se exakt vad din personal kommer att få.
+
+
+ Testnamn
+
+
+ Tors
+
+
+ Tis
+
+
+ Kunde inte starta testkörningen. En frekvensgräns kan gälla.
+
+
+ Overifierad
+
+
+ Ej bekräftad. Resgrid skickar inte till dessa kanaler.
+
+
+ Verifierad
+
+
+ Bekräftad av användaren. Resgrid skickar till dessa.
+
+
+ Röstsamtal
+
+
+ Röstkontakt
+
+
+ Röstsamtal aktiverat
+
+
+ Röstverifiering
+
+
+ Ons
+
+
+ Vad din personal kommer att se
+
+
+ Vem som testas
+
+
+ Lämna alla tre listor tomma för att testa hela avdelningen. Valen adderas: den som matchar en vald grupp, roll eller ett namn testas en gång.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
index b93767e12..dc2f79f41 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
@@ -1,51 +1,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
text/microsoft-resx
@@ -59,150 +59,414 @@
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
+ День місяця (1-28)
- 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.
+ Resgrid отримав вашу відповідь на тест зв’язку. Дякуємо.
-
+
+ Вітаємо, {0}!
+
+
+
+{1} проводить тест зв’язку ({2}), щоб підтвердити можливість зв’язатися з вами.
+
+
+
+Це ТЕСТ. Жодної надзвичайної ситуації немає, і не потрібно нічого робити, окрім підтвердження отримання цього листа.
+
+
+
+Натисніть посилання нижче, щоб підтвердити отримання цього повідомлення:
+
+{3}
+
+
+
+Команда Resgrid
+
+
+ Тест зв’язку Resgrid: {0}
+
+
+ {0}: це лише тест. Відкрийте застосунок Resgrid Responder і підтвердьте отримання.
+
+
+ Тест зв’язку
+
+
+ Тест зв’язку Resgrid ({0}). Це лише тест. Надішліть у відповідь {1}, щоб підтвердити отримання цього повідомлення.
+
+
+ Тест зв’язку Resgrid. Це лише тест. Надішліть у відповідь {0}, щоб підтвердити отримання цього повідомлення.
+
+
+ Це тест зв’язку Resgrid. Це лише тест. Жодної надзвичайної ситуації немає.
+
+
+ Підтвердження не отримано. До побачення.
+
+
+ Натисніть 1, щоб підтвердити отримання цього дзвінка.
+
+
+ Дякуємо. Вашу відповідь зафіксовано.
+
+
+ Канали
+
+
+ Перевірка контактів
+
+
+ Видалити
+
+
+ Редагувати
+
+
+ Контакт пошти
+
+
+ Пошту увімкнено
+
+
+ Перевірка пошти
+
+
+ Пт
+
+
+ Успадковано
+
+
+ Створено до впровадження перевірки. Надсилання триває, але без підтвердження — попросіть цих людей підтвердити.
+
+
+ Групи
+
+
+ Головна
+
+
+ Окремі особи
+
+
+ хв
+
+
+ Пн
+
+
+ Назва обов’язкова.
+
+
+ Н/З
+
+
+ Push не має контактного методу для перевірки — доставка залежить від зареєстрованого пристрою.
+
+
+ Не надіслано (немає контакту)
+
+
+ Не надіслано (вимкнено)
+
+
+ Не надіслано (не підтверджено)
+
+
+ Вручну можна запустити лише тести за запитом. Заплановані виконуються автоматично.
+
+
+ Дозволено лише один щомісячний тест на підрозділ. Відредагуйте наявний.
+
+
+ Дозволено лише один щотижневий тест на підрозділ. Відредагуйте наявний.
+
+
+ Натискання посилання і є підтвердженням. Посилання унікальне для кожної особи та одноразове.
+
+
+ Показано вашою мовою. Кожна особа отримає повідомлення мовою, вказаною в її профілі.
+
+
+ Потрібен застосунок Resgrid Responder із виконаним входом і дозволеними сповіщеннями. Після відкриття сповіщення в застосунку з’являється кнопка підтвердження. Ті, хто не має застосунку, відображаються як без відповіді в каналі push.
+
+
+ Вони відповідають кодом запуску (для кожного запуску створюється новий, як-от {0}) на той самий номер, з якого надійшло повідомлення. Решта тексту відповіді не має значення.
+
+
+ Дзвінок промовляє це двічі, а потім завершується. Натискання 1 підтверджує отримання. Виклик надходить на номер, указаний у профілі.
+
+
+ Push увімкнено
+
+
+ Push-сповіщення
+
+
+ Перевірка push
+
+
+ Тест за запитом можна запускати лише раз на 48 годин. Спробуйте пізніше.
+
+
+ Ролі
+
+
+ Цей запуск ще надсилається. Повідомлення відправляються у фоні — оновіть сторінку за кілька хвилин, щоб побачити результати.
+
+
+ Запустити цей тест зв’язку зараз?
+
+Буде надіслано справжнє повідомлення кожному користувачу в межах тесту, на кожен увімкнений канал.
+
+- SMS: відповідають кодом запуску, показаним у звіті
+- Пошта: натискають посилання підтвердження
+- Дзвінок: автоматичний виклик просить натиснути 1
+- Push: потрібен застосунок Resgrid Responder із виконаним входом
+
+Повідомлення надсилаються у фоні, тож звіт заповнюватиметься поступово.
+
+
+ Сб
+
+
+ Розклад
+
+
+ Обсяг
+
+
+ Усі
+
+
+ 1 група
+
+
+ Груп: {0}
+
+
+ Осіб: {0}
+
+
+ 1 особа
+
+
+ 1 роль
+
+
+ Ролей: {0}
+
+
+ Помилка надсилання
+
+
+ Оператор SMS
+
+
+ Контакт SMS
+
+
+ SMS увімкнено
+
+
+ Перевірка SMS
+
+
+ Нд
+
+
+ Тести зв’язку спрямовані на користувачів, а не на підрозділи. Щоб підтвердити push-сповіщення, особовому складу потрібен встановлений застосунок Resgrid Responder із виконаним входом; SMS, електронну пошту та дзвінок підтверджують із телефона або поштової скриньки без застосунку. Відкрийте тест, щоб побачити, що саме отримають ваші люди.
+
+
+ Назва тесту
+
+
+ Чт
+
+
+ Вт
+
+
+ Не вдалося запустити тест. Можливо, діє обмеження частоти.
+
+
+ Не підтверджено
+
+
+ Не підтверджено. Resgrid не надсилатиме на ці канали.
+
+
+ Підтверджено
+
+
+ Підтверджено користувачем. Resgrid надсилає на них.
+
+
+ Голосовий виклик
+
+
+ Контакт дзвінка
+
+
+ Дзвінки увімкнено
+
+
+ Перевірка дзвінка
+
+
+ Ср
+
+
+ Що побачать ваші люди
+
+
+ Кого перевіряти
+
+
+ Залиште всі три списки порожніми, щоб перевірити весь підрозділ. Вибір сумується: кожен, хто відповідає обраній групі, ролі або імені, перевіряється один раз.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs
new file mode 100644
index 000000000..f219930a4
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Localization.Areas.User.CommunicationTest
+{
+ ///
+ /// The exact wording a communication test delivers on each channel, rendered in a caller-supplied
+ /// culture. Every message is composed for someone else — a worker builds it for a recipient whose
+ /// language has nothing to do with the thread culture — so each method takes the recipient's
+ /// culture explicitly rather than reading CultureInfo.CurrentUICulture.
+ ///
+ /// The "what your people will see" preview on the new/edit screens reads from here too, so an
+ /// administrator previewing a test sees the real message rather than a copy that can drift.
+ ///
+ public static class CommunicationTestMessageCatalog
+ {
+ /// Placeholder run code used by the preview; a real run generates its own.
+ public const string SampleRunCode = "CT-A7X3";
+
+ public static string BuildSmsBody(string testName, string runCode, string? culture)
+ {
+ // A test with no name would otherwise read "communication test ()." — drop the
+ // parenthetical entirely rather than send empty brackets.
+ return string.IsNullOrWhiteSpace(testName)
+ ? CommunicationTestResources.Get("MessageSmsBodyNoName", culture, runCode)
+ : CommunicationTestResources.Get("MessageSmsBody", culture, testName.Trim(), runCode);
+ }
+
+ public static string BuildEmailSubject(string testName, string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailSubject", culture, testName);
+ }
+
+ public static string BuildEmailBody(string firstName, string departmentName, string testName, string confirmUrl, string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailBody", culture, firstName, departmentName, testName, confirmUrl);
+ }
+
+ public static string BuildPushTitle(string? culture)
+ {
+ return CommunicationTestResources.Get("MessagePushTitle", culture);
+ }
+
+ public static string BuildPushBody(string testName, string? culture)
+ {
+ return CommunicationTestResources.Get("MessagePushBody", culture, testName);
+ }
+
+ ///
+ /// What the automated call speaks, in order. The call repeats the prompt twice before hanging
+ /// up, and pressing 1 records the response.
+ ///
+ public static IReadOnlyList GetVoicePrompts(string? culture)
+ {
+ return new[]
+ {
+ CommunicationTestResources.Get("MessageVoiceGreeting", culture),
+ CommunicationTestResources.Get("MessageVoicePressOne", culture)
+ };
+ }
+
+ public static string BuildVoiceRecorded(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageVoiceRecorded", culture);
+ }
+
+ public static string BuildVoiceNoResponse(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageVoiceNoResponse", culture);
+ }
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx
new file mode 100644
index 000000000..6cf84ce33
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ التقويم
+
+
+ مرحبًا {0}،
+
+تصدير بياناتك الشخصية جاهز للتنزيل.
+
+رابط التنزيل: {1}
+
+تنتهي صلاحية هذا الرابط في {2:f} بتوقيت UTC. بعد هذا التاريخ لن تكون البيانات متاحة.
+
+إذا لم تطلب ذلك، يرجى التواصل مع الدعم.
+
+فريق Resgrid
+
+
+ تصدير بياناتك من Resgrid جاهز
+
+
+ إشعار
+
+
+ مرحبًا {0}،
+
+رمز التحقق الخاص بك في Resgrid هو: {1}
+
+تنتهي صلاحية هذا الرمز خلال {2} دقيقة. إذا لم تطلب ذلك، فتجاهل هذا البريد.
+
+فريق Resgrid
+
+
+ رمز التحقق من Resgrid
+
+
+ رمز التحقق الخاص بك في Resgrid هو: {0}. تنتهي صلاحيته خلال {1} دقيقة.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.cs b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.cs
new file mode 100644
index 000000000..af8156c08
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+
+namespace Resgrid.Localization.Areas.User.SystemMessages
+{
+ /// Marker type used by ASP.NET Core localization for system message resources.
+ public class SystemMessages
+ {
+ }
+
+ ///
+ /// Culture-explicit access to the account-level messages Resgrid sends to a single person —
+ /// verification codes, data export notices, generic notification and calendar mail. Each is
+ /// composed on a background or request thread for a recipient whose language is their own, so
+ /// lookups take the recipient's culture rather than reading the ambient thread culture.
+ /// A missing culture for a key falls back to English; a missing key surfaces the key name.
+ ///
+ public static class SystemMessagesResources
+ {
+ private static readonly ResourceManager ResourceManager = new ResourceManager(
+ typeof(SystemMessages).FullName!, typeof(SystemMessages).Assembly);
+
+ public static string Get(string key, string? culture, params object[] arguments)
+ {
+ var cultureInfo = GetSupportedCulture(culture);
+ var value = ResourceManager.GetString(key, cultureInfo)
+ ?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en"))
+ ?? key;
+
+ return arguments == null || arguments.Length == 0
+ ? value
+ : string.Format(cultureInfo, value, arguments);
+ }
+
+ public static string GetCurrent(string key, params object[] arguments)
+ {
+ return Get(key, CultureInfo.CurrentUICulture.Name, arguments);
+ }
+
+ public static IReadOnlyDictionary GetAll(string culture)
+ {
+ var resourceSet = ResourceManager.GetResourceSet(GetSupportedCulture(culture), true, false);
+ if (resourceSet == null)
+ return new Dictionary();
+
+ return resourceSet.Cast()
+ .Where(x => x.Key is string && x.Value is string)
+ .ToDictionary(x => (string)x.Key, x => (string)x.Value!);
+ }
+
+ private static CultureInfo GetSupportedCulture(string? culture)
+ {
+ var candidate = string.IsNullOrWhiteSpace(culture) ? "en" : culture.Trim();
+ var separator = candidate.IndexOfAny(new[] { '-', '_' });
+ if (separator > 0)
+ candidate = candidate.Substring(0, separator);
+
+ candidate = candidate.ToLowerInvariant();
+ return SupportedLocales.SupportedLanguagesMap.ContainsKey(candidate)
+ ? CultureInfo.GetCultureInfo(candidate)
+ : CultureInfo.GetCultureInfo("en");
+ }
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx
new file mode 100644
index 000000000..4ec77a552
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Kalender
+
+
+ Hallo {0},
+
+Ihr Export personenbezogener Daten steht zum Download bereit.
+
+Download-Link: {1}
+
+Dieser Link läuft am {2:f} UTC ab. Danach sind die Daten nicht mehr verfügbar.
+
+Falls Sie dies nicht angefordert haben, wenden Sie sich bitte an den Support.
+
+Ihr Resgrid-Team
+
+
+ Ihr Resgrid-Datenexport ist bereit
+
+
+ Benachrichtigung
+
+
+ Hallo {0},
+
+Ihr Resgrid-Bestätigungscode lautet: {1}
+
+Dieser Code läuft in {2} Minuten ab. Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail bitte.
+
+Ihr Resgrid-Team
+
+
+ Resgrid-Bestätigungscode
+
+
+ Ihr Resgrid-Bestätigungscode lautet: {0}. Er läuft in {1} Minuten ab.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx
new file mode 100644
index 000000000..ab749728b
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Ημερολόγιο
+
+
+ Γεια σας {0},
+
+Η εξαγωγή των προσωπικών σας δεδομένων είναι έτοιμη για λήψη.
+
+Σύνδεσμος λήψης: {1}
+
+Ο σύνδεσμος λήγει στις {2:f} UTC. Μετά την ημερομηνία αυτή τα δεδομένα δεν θα είναι πλέον διαθέσιμα.
+
+Αν δεν κάνατε εσείς αυτό το αίτημα, επικοινωνήστε με την υποστήριξη.
+
+Η ομάδα του Resgrid
+
+
+ Η εξαγωγή δεδομένων σας από το Resgrid είναι έτοιμη
+
+
+ Ειδοποίηση
+
+
+ Γεια σας {0},
+
+Ο κωδικός επαλήθευσης Resgrid είναι: {1}
+
+Ο κωδικός λήγει σε {2} λεπτά. Αν δεν κάνατε εσείς αυτό το αίτημα, αγνοήστε αυτό το email.
+
+Η ομάδα του Resgrid
+
+
+ Κωδικός επαλήθευσης Resgrid
+
+
+ Ο κωδικός επαλήθευσης Resgrid είναι: {0}. Λήγει σε {1} λεπτά.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx
new file mode 100644
index 000000000..0ad526318
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Calendar
+
+
+ Hi {0},
+
+Your personal data export is ready for download.
+
+Download link: {1}
+
+This link expires on {2:f} UTC. After this date the data will no longer be available.
+
+If you did not request this, please contact support.
+
+The Resgrid Team
+
+
+ Your Resgrid Data Export is Ready
+
+
+ Notification
+
+
+ Hi {0},
+
+Your Resgrid verification code is: {1}
+
+This code expires in {2} minutes. If you did not request this, please ignore this email.
+
+The Resgrid Team
+
+
+ Resgrid Verification Code
+
+
+ Your Resgrid verification code is: {0}. It expires in {1} minutes.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx
new file mode 100644
index 000000000..02d7e9fd2
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Calendario
+
+
+ Hola {0}:
+
+Su exportación de datos personales está lista para descargar.
+
+Enlace de descarga: {1}
+
+Este enlace caduca el {2:f} UTC. Después de esa fecha los datos dejarán de estar disponibles.
+
+Si no solicitó esto, contacte con el soporte.
+
+El equipo de Resgrid
+
+
+ Su exportación de datos de Resgrid está lista
+
+
+ Notificación
+
+
+ Hola {0}:
+
+Su código de verificación de Resgrid es: {1}
+
+Este código caduca en {2} minutos. Si no lo solicitó, ignore este correo.
+
+El equipo de Resgrid
+
+
+ Código de verificación de Resgrid
+
+
+ Su código de verificación de Resgrid es: {0}. Caduca en {1} minutos.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx
new file mode 100644
index 000000000..8052b0bd7
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Calendrier
+
+
+ Bonjour {0},
+
+Votre export de données personnelles est prêt à être téléchargé.
+
+Lien de téléchargement : {1}
+
+Ce lien expire le {2:f} UTC. Passé cette date, les données ne seront plus disponibles.
+
+Si vous n'êtes pas à l'origine de cette demande, contactez le support.
+
+L'équipe Resgrid
+
+
+ Votre export de données Resgrid est prêt
+
+
+ Notification
+
+
+ Bonjour {0},
+
+Votre code de vérification Resgrid est : {1}
+
+Ce code expire dans {2} minutes. Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.
+
+L'équipe Resgrid
+
+
+ Code de vérification Resgrid
+
+
+ Votre code de vérification Resgrid est : {0}. Il expire dans {1} minutes.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx
new file mode 100644
index 000000000..5d5d30ebf
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Calendario
+
+
+ Ciao {0},
+
+L'esportazione dei tuoi dati personali è pronta per il download.
+
+Link per il download: {1}
+
+Questo link scade il {2:f} UTC. Dopo tale data i dati non saranno più disponibili.
+
+Se non hai richiesto tu questa operazione, contatta l'assistenza.
+
+Il team Resgrid
+
+
+ La tua esportazione dati Resgrid è pronta
+
+
+ Notifica
+
+
+ Ciao {0},
+
+Il tuo codice di verifica Resgrid è: {1}
+
+Questo codice scade tra {2} minuti. Se non hai richiesto tu questa operazione, ignora questa email.
+
+Il team Resgrid
+
+
+ Codice di verifica Resgrid
+
+
+ Il tuo codice di verifica Resgrid è: {0}. Scade tra {1} minuti.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx
new file mode 100644
index 000000000..586d1b59a
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Kalendarz
+
+
+ Cześć {0},
+
+Eksport Twoich danych osobowych jest gotowy do pobrania.
+
+Link do pobrania: {1}
+
+Link wygasa {2:f} UTC. Po tej dacie dane nie będą już dostępne.
+
+Jeśli to nie Ty prosiłeś o eksport, skontaktuj się z pomocą techniczną.
+
+Zespół Resgrid
+
+
+ Twój eksport danych Resgrid jest gotowy
+
+
+ Powiadomienie
+
+
+ Cześć {0},
+
+Twój kod weryfikacyjny Resgrid to: {1}
+
+Kod wygaśnie za {2} minut. Jeśli to nie Ty prosiłeś o kod, zignoruj tę wiadomość.
+
+Zespół Resgrid
+
+
+ Kod weryfikacyjny Resgrid
+
+
+ Twój kod weryfikacyjny Resgrid to: {0}. Wygaśnie za {1} minut.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx
new file mode 100644
index 000000000..749912629
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Kalender
+
+
+ Hej {0},
+
+Din export av personuppgifter är klar för nedladdning.
+
+Nedladdningslänk: {1}
+
+Länken upphör att gälla {2:f} UTC. Efter detta datum är uppgifterna inte längre tillgängliga.
+
+Om du inte har begärt detta, kontakta supporten.
+
+Resgrid-teamet
+
+
+ Din Resgrid-dataexport är klar
+
+
+ Avisering
+
+
+ Hej {0},
+
+Din Resgrid-verifieringskod är: {1}
+
+Koden upphör att gälla om {2} minuter. Om du inte har begärt detta kan du ignorera detta e-postmeddelande.
+
+Resgrid-teamet
+
+
+ Resgrid verifieringskod
+
+
+ Din Resgrid-verifieringskod är: {0}. Den upphör att gälla om {1} minuter.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx
new file mode 100644
index 000000000..77ed080eb
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+ Календар
+
+
+ Вітаємо, {0}!
+
+Експорт ваших персональних даних готовий до завантаження.
+
+Посилання для завантаження: {1}
+
+Посилання діє до {2:f} UTC. Після цієї дати дані будуть недоступні.
+
+Якщо ви не надсилали запит, зверніться до служби підтримки.
+
+Команда Resgrid
+
+
+ Ваш експорт даних Resgrid готовий
+
+
+ Сповіщення
+
+
+ Вітаємо, {0}!
+
+Ваш код підтвердження Resgrid: {1}
+
+Код діє {2} хв. Якщо ви не надсилали запит, просто проігноруйте цей лист.
+
+Команда Resgrid
+
+
+ Код підтвердження Resgrid
+
+
+ Ваш код підтвердження Resgrid: {0}. Діє {1} хв.
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Model/CommunicationTestResultExtensions.cs b/Core/Resgrid.Model/CommunicationTestResultExtensions.cs
new file mode 100644
index 000000000..fe11b71b1
--- /dev/null
+++ b/Core/Resgrid.Model/CommunicationTestResultExtensions.cs
@@ -0,0 +1,36 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Presentation helpers for communication test results. These live here rather than in a view so
+ /// the web report, the v4 API and the apps all describe a channel's verification state with the
+ /// same words — a report that says "Pending" in one place and "Unverified" in another is the sort
+ /// of drift that makes an administrator distrust the whole report.
+ ///
+ public static class CommunicationTestResultExtensions
+ {
+ ///
+ /// Label shown for a result's contact verification state. Push has no verifiable contact
+ /// method — delivery depends on a registered device, not on a confirmed address or number —
+ /// so it reports "N/A" instead of borrowing a verification word that would be meaningless.
+ ///
+ public static string GetVerificationDisplayText(this CommunicationTestResult result)
+ {
+ if (result == null)
+ return "-";
+
+ if (result.Channel == (int)CommunicationTestChannel.Push)
+ return "N/A";
+
+ return ((ContactVerificationStatus)result.VerificationStatus).ToDisplayText();
+ }
+
+ ///
+ /// true when this result's channel carries a real verification state, i.e. anything
+ /// except push. Use this before counting a result into a verification summary.
+ ///
+ public static bool HasVerifiableContactMethod(this CommunicationTestResult result)
+ {
+ return result != null && result.Channel != (int)CommunicationTestChannel.Push;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/CommunicationTestRun.cs b/Core/Resgrid.Model/CommunicationTestRun.cs
index b098e9f05..a804fbb4a 100644
--- a/Core/Resgrid.Model/CommunicationTestRun.cs
+++ b/Core/Resgrid.Model/CommunicationTestRun.cs
@@ -40,6 +40,15 @@ public class CommunicationTestRun : IEntity
public int TotalResponses { get; set; }
+ ///
+ /// Snapshot of the audience this run was started for, taken from the test's targeting at
+ /// start time. A JSON array of user ids when the test was targeted, the JSON literal
+ /// null when it was untargeted (whole department), and a NULL column only for runs
+ /// created before snapshots existed. Frozen so that editing a test's targets while its run
+ /// is queued cannot change who that run tests.
+ ///
+ public string TargetedUserIds { get; set; }
+
[NotMapped]
[JsonIgnore]
public object IdValue
diff --git a/Core/Resgrid.Model/CommunicationTestTarget.cs b/Core/Resgrid.Model/CommunicationTestTarget.cs
new file mode 100644
index 000000000..43af6d09c
--- /dev/null
+++ b/Core/Resgrid.Model/CommunicationTestTarget.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Scopes a communication test to a subset of the department. Rows are additive: the tested
+ /// audience is the union of every target, intersected with current department membership.
+ /// A test with no rows tests the whole department.
+ ///
+ [Table("CommunicationTestTargets")]
+ public class CommunicationTestTarget : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public Guid CommunicationTestTargetId { get; set; }
+
+ [Required]
+ public Guid CommunicationTestId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ ///
+ /// A value.
+ ///
+ public int TargetType { get; set; }
+
+ ///
+ /// The group id, role id or user id this row targets. Stored as a string because group and
+ /// role ids are ints while user ids are 128 character identity keys.
+ ///
+ [Required]
+ [MaxLength(128)]
+ public string TargetId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return CommunicationTestTargetId == Guid.Empty ? null : (object)CommunicationTestTargetId.ToString(); }
+ set { CommunicationTestTargetId = value == null ? Guid.Empty : Guid.Parse(value.ToString()); }
+ }
+
+ [NotMapped]
+ public string TableName => "CommunicationTestTargets";
+
+ [NotMapped]
+ public string IdName => "CommunicationTestTargetId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/CommunicationTestTargetType.cs b/Core/Resgrid.Model/CommunicationTestTargetType.cs
new file mode 100644
index 000000000..4dd6413f1
--- /dev/null
+++ b/Core/Resgrid.Model/CommunicationTestTargetType.cs
@@ -0,0 +1,13 @@
+namespace Resgrid.Model
+{
+ ///
+ /// What a row points at. A test with no target rows
+ /// covers every member of the department (the original behavior).
+ ///
+ public enum CommunicationTestTargetType
+ {
+ Group = 0,
+ Role = 1,
+ User = 2
+ }
+}
diff --git a/Core/Resgrid.Model/ContactVerificationExtensions.cs b/Core/Resgrid.Model/ContactVerificationExtensions.cs
index 71c4b92c9..b678f1d8e 100644
--- a/Core/Resgrid.Model/ContactVerificationExtensions.cs
+++ b/Core/Resgrid.Model/ContactVerificationExtensions.cs
@@ -25,6 +25,27 @@ public static ContactVerificationStatus ToVerificationStatus(this bool? value)
///
public static bool IsContactMethodAllowedForSending(this bool? verified)
=> verified != false;
+
+ ///
+ /// The label shown to users for a verification state.
+ /// reads as "Unverified" rather than "Pending": from an administrator's point of view the fact
+ /// that matters is that the channel is not verified and will not be sent to, not that a code
+ /// happens to be outstanding.
+ ///
+ public static string ToDisplayText(this ContactVerificationStatus status)
+ {
+ switch (status)
+ {
+ case ContactVerificationStatus.Verified:
+ return "Verified";
+ case ContactVerificationStatus.Pending:
+ return "Unverified";
+ case ContactVerificationStatus.Grandfathered:
+ return "Grandfathered";
+ default:
+ return "Unknown";
+ }
+ }
}
}
diff --git a/Core/Resgrid.Model/Providers/IOutboundQueueProvider.cs b/Core/Resgrid.Model/Providers/IOutboundQueueProvider.cs
index 7f3282699..226455a5c 100644
--- a/Core/Resgrid.Model/Providers/IOutboundQueueProvider.cs
+++ b/Core/Resgrid.Model/Providers/IOutboundQueueProvider.cs
@@ -15,5 +15,7 @@ public interface IOutboundQueueProvider
Task EnqueueSecurityRefreshEvent(SecurityRefreshEvent securityRefreshEvent);
Task EnqueueWorkflow(Resgrid.Model.Queue.WorkflowQueueItem item);
Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMessageQueue);
+
+ Task EnqueueCommunicationTest(CommunicationTestQueueItem communicationTestQueue);
}
}
diff --git a/Core/Resgrid.Model/Providers/IOutboundVoiceProvider.cs b/Core/Resgrid.Model/Providers/IOutboundVoiceProvider.cs
index cf04dd5ba..4bc3f817e 100644
--- a/Core/Resgrid.Model/Providers/IOutboundVoiceProvider.cs
+++ b/Core/Resgrid.Model/Providers/IOutboundVoiceProvider.cs
@@ -10,5 +10,11 @@ public interface IOutboundVoiceProvider
/// Places a Twilio voice call that speaks the verification code digits to the user.
///
Task SendVoiceVerificationCallAsync(string phoneNumber, string userId, int contactType);
+
+ ///
+ /// Places a Twilio voice call for a communication test. The call plays the test prompt and
+ /// gathers a keypress, which is recorded against .
+ ///
+ Task SendCommunicationTestCallAsync(string phoneNumber, string responseToken);
}
}
diff --git a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
index 088b9b754..5a689d855 100644
--- a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
+++ b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
@@ -22,6 +22,8 @@ Task EnqueueUnitLocationEvents(
Task EnqueuePersonnelLocationEvent(PersonnelLocationEvent personnelLocationEvent);
Task EnqueueWorkflowEvent(WorkflowQueueItem item);
Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMessageQueue);
+
+ Task EnqueueCommunicationTest(CommunicationTestQueueItem communicationTestQueue);
Task VerifyAndCreateClients();
}
}
diff --git a/Core/Resgrid.Model/Queue/CommunicationTestQueueItem.cs b/Core/Resgrid.Model/Queue/CommunicationTestQueueItem.cs
new file mode 100644
index 000000000..9637860eb
--- /dev/null
+++ b/Core/Resgrid.Model/Queue/CommunicationTestQueueItem.cs
@@ -0,0 +1,31 @@
+using System;
+using ProtoBuf;
+
+namespace Resgrid.Model.Queue
+{
+ ///
+ /// Carries a started communication test run onto the bus so the whole run — building the
+ /// per-recipient result rows and then sending on every enabled channel — happens in the worker
+ /// process instead of on the request thread that started it. A large department is hundreds of
+ /// sequential provider round-trips, which no web request can absorb.
+ /// The run row already exists when this is published; the worker owns everything after it.
+ ///
+ [ProtoContract]
+ public class CommunicationTestQueueItem
+ {
+ [ProtoMember(1)]
+ public int DepartmentId { get; set; }
+
+ /// The run to build and deliver. Stored as a string because ProtoBuf has no Guid.
+ [ProtoMember(2)]
+ public string CommunicationTestRunId { get; set; }
+
+ [ProtoMember(3)]
+ public string CommunicationTestId { get; set; }
+
+ public Guid GetRunId()
+ {
+ return Guid.TryParse(CommunicationTestRunId, out var runId) ? runId : Guid.Empty;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/ICommunicationTestTargetRepository.cs b/Core/Resgrid.Model/Repositories/ICommunicationTestTargetRepository.cs
new file mode 100644
index 000000000..480ef682d
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/ICommunicationTestTargetRepository.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface ICommunicationTestTargetRepository : IRepository
+ {
+ Task> GetTargetsByTestIdAsync(Guid communicationTestId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/ICommunicationTestService.cs b/Core/Resgrid.Model/Services/ICommunicationTestService.cs
index ce45ff1e3..f3ea5fa66 100644
--- a/Core/Resgrid.Model/Services/ICommunicationTestService.cs
+++ b/Core/Resgrid.Model/Services/ICommunicationTestService.cs
@@ -13,20 +13,86 @@ public interface ICommunicationTestService
Task SaveTestAsync(CommunicationTest test, CancellationToken cancellationToken = default);
Task DeleteTestAsync(Guid communicationTestId, CancellationToken cancellationToken = default);
+ ///
+ /// Gets the group/role/user targets scoping a test. An empty result means the test covers
+ /// the whole department.
+ ///
+ Task> GetTargetsByTestIdAsync(Guid communicationTestId);
+
+ ///
+ /// Replaces the whole target set for a test. An empty collection clears targeting.
+ ///
+ Task SaveTargetsAsync(Guid communicationTestId, int departmentId, IEnumerable targets, CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves a test and replaces its target set in one transaction. Callers editing a test should
+ /// use this rather than the two calls separately: replacing targets clears them before writing
+ /// the new ones, so a failure between the halves would leave a targeted test covering the whole
+ /// department. The targets' own CommunicationTestId is ignored — the saved test's id is stamped
+ /// on, so a new test can have its targets built before it has an id.
+ ///
+ Task SaveTestWithTargetsAsync(CommunicationTest test, int departmentId, IEnumerable targets, CancellationToken cancellationToken = default);
+
+ ///
+ /// Resolves the user ids a test covers, or null when the test is untargeted and
+ /// therefore covers every member of the department.
+ ///
+ Task> ResolveTargetedUserIdsAsync(Guid communicationTestId, int departmentId);
+
Task CanStartOnDemandRunAsync(Guid communicationTestId);
+
+ ///
+ /// Creates the run row in Pending, snapshots the audience it covers onto the run, and hands it
+ /// to the worker over the bus. Returns as soon as the run is claimed — no result rows are
+ /// written and nothing is sent on this thread.
+ ///
Task StartTestRunAsync(Guid communicationTestId, int departmentId, string initiatedByUserId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Worker-side entry point for a queued run: builds the result rows, then delivers them.
+ /// Idempotent, so a redelivered queue message is harmless.
+ ///
+ Task ProcessRunAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Writes one result row per recipient per enabled channel for the audience snapshotted onto
+ /// the run when it was started, moving the run to Running. No-ops when the run already has
+ /// results.
+ ///
+ Task BuildRunResultsAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default);
+
Task> GetRunsByTestIdAsync(Guid communicationTestId);
Task GetRunByIdAsync(Guid communicationTestRunId);
Task> GetRunsByDepartmentIdAsync(int departmentId);
Task> GetResultsByRunIdAsync(Guid communicationTestRunId);
+ ///
+ /// Sends the messages for every not-yet-sent result on a run and moves the run to
+ /// AwaitingResponses. Idempotent per result, so an interrupted run can be finished later.
+ /// Honours the DoNotBroadcast kill switch: a blocked department's results are recorded as
+ /// failed sends and no provider is called.
+ ///
+ Task DeliverRunAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Finishes delivery for runs left mid-send by an interrupted process.
+ ///
+ Task DeliverPendingRunsAsync(CancellationToken cancellationToken = default);
+
Task RecordSmsResponseAsync(string runCode, string fromPhoneNumber);
Task RecordEmailResponseAsync(string responseToken);
Task RecordVoiceResponseAsync(string responseToken);
Task RecordPushResponseAsync(string responseToken);
Task GetDepartmentIdByResponseTokenAsync(string responseToken);
+ ///
+ /// The preferred language (UserProfile.Language) of the person a response token belongs to.
+ /// The voice webhook has only the token, so this is how a call knows what language to speak.
+ /// Returns null when the token is unknown or the recipient has no language set.
+ ///
+ Task GetRecipientLanguageByResponseTokenAsync(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 c4d60960b..8215f39bd 100644
--- a/Core/Resgrid.Model/Services/IEmailService.cs
+++ b/Core/Resgrid.Model/Services/IEmailService.cs
@@ -216,8 +216,20 @@ Task SendUserCancellationNotificationToTeamAsync(Department department, Pa
/// The recipient email address.
/// User's first name for personalisation.
/// The numeric verification code to include in the email.
- Task SendEmailVerificationCodeAsync(string toEmailAddress, string firstName, string verificationCode);
+ Task SendEmailVerificationCodeAsync(string toEmailAddress, string firstName, string verificationCode, string culture = null);
- Task SendGdprDataExportReadyAsync(string toEmailAddress, string firstName, string downloadUrl, DateTime expiresAt);
+ Task SendGdprDataExportReadyAsync(string toEmailAddress, string firstName, string downloadUrl, DateTime expiresAt, string culture = null);
+
+ ///
+ /// Sends a communication test email carrying the single-use confirmation link the recipient
+ /// clicks to record that the message arrived.
+ ///
+ /// The recipient email address.
+ /// User's first name for personalisation.
+ /// The department running the test.
+ /// The name of the communication test.
+ /// The tokenised confirmation URL.
+ /// The recipient's preferred culture (UserProfile.Language); null falls back to English.
+ Task SendCommunicationTestEmailAsync(string toEmailAddress, string firstName, string departmentName, string testName, string confirmUrl, string culture = null);
}
}
diff --git a/Core/Resgrid.Model/Services/IQueueService.cs b/Core/Resgrid.Model/Services/IQueueService.cs
index 6f1c6fd34..33730e8e8 100644
--- a/Core/Resgrid.Model/Services/IQueueService.cs
+++ b/Core/Resgrid.Model/Services/IQueueService.cs
@@ -70,6 +70,16 @@ public interface IQueueService
/// Task<System.Boolean>.
Task EnqueueChatbotMessageAsync(ChatbotMessageQueueItem item, CancellationToken cancellationToken = default(CancellationToken));
+ ///
+ /// Hands a started communication test run to the worker, which builds the per-recipient
+ /// result rows and does the sending. Returns false if the publish failed; the run
+ /// stays Pending and the worker's recovery sweep will pick it up.
+ ///
+ /// The communication test queue item.
+ /// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ /// Task<System.Boolean>.
+ Task EnqueueCommunicationTestAsync(CommunicationTestQueueItem item, CancellationToken cancellationToken = default(CancellationToken));
+
///
/// Sets the queue item completed asynchronous.
///
diff --git a/Core/Resgrid.Model/Services/ISmsService.cs b/Core/Resgrid.Model/Services/ISmsService.cs
index c976a962f..d76fa45ce 100644
--- a/Core/Resgrid.Model/Services/ISmsService.cs
+++ b/Core/Resgrid.Model/Services/ISmsService.cs
@@ -81,6 +81,18 @@ Task SendNotificationAsync(string userId, int departmentId, string message
/// The E.164-formatted destination phone number.
/// The numeric verification code.
/// The outbound department phone number.
- Task SendSmsVerificationCodeAsync(string toPhoneNumber, string verificationCode, string departmentNumber);
+ Task SendSmsVerificationCodeAsync(string toPhoneNumber, string verificationCode, string departmentNumber, string culture = null);
+
+ ///
+ /// Sends a communication test SMS. Always goes direct through the SMS provider (never the
+ /// email-to-SMS gateway) because the recipient replies with the run code on the same
+ /// number, and gateway-delivered messages cannot be replied to.
+ ///
+ /// The E.164-formatted destination phone number.
+ /// The message body, including the run code to reply with.
+ /// The outbound department phone number.
+ /// The recipient's mobile carrier, when known.
+ /// The department identifier.
+ Task SendCommunicationTestAsync(string toPhoneNumber, string message, string departmentNumber, MobileCarriers carrier, int departmentId);
}
}
diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
index 7a5d858dc..5c3837d97 100644
--- a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
+++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
@@ -32,6 +32,9 @@ public static class TwilioVoicePromptCatalog
public const string InvalidStaffingSelection = "Invalid staffing selection. Returning to the main menu.";
public const string NoStaffingSelection = "No staffing selection made. Returning to the main menu.";
public const string CommunicationTestRecorded = "Thank you. Your response has been recorded.";
+ public const string CommunicationTestGreeting = "This is a Resgrid communication test. This is only a test. There is no emergency.";
+ public const string CommunicationTestPressOne = "Press 1 to confirm you received this call.";
+ public const string CommunicationTestNoResponse = "No confirmation was received. Goodbye.";
public const string PleaseWaitForDispatch = "Please wait while we prepare your dispatch information.";
@@ -87,6 +90,9 @@ public static IReadOnlyCollection GetStaticPrompts()
InvalidStaffingSelection,
NoStaffingSelection,
CommunicationTestRecorded,
+ CommunicationTestGreeting,
+ CommunicationTestPressOne,
+ CommunicationTestNoResponse,
PleaseWaitForDispatch
};
}
diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs
index a527619c1..7583d0d70 100644
--- a/Core/Resgrid.Services/CommunicationTestService.cs
+++ b/Core/Resgrid.Services/CommunicationTestService.cs
@@ -1,35 +1,81 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using Newtonsoft.Json;
using Resgrid.Framework;
+using CommunicationTestMessages = Resgrid.Localization.Areas.User.CommunicationTest.CommunicationTestMessageCatalog;
using Resgrid.Model;
+using Resgrid.Model.Messages;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Queue;
using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
using Resgrid.Model.Services;
namespace Resgrid.Services
{
public class CommunicationTestService : ICommunicationTestService
{
+ ///
+ /// How long a run is left alone before the recovery sweep will re-process it, so the sweep
+ /// cannot collide with a queue consumer that is still working through the same run.
+ ///
+ private static readonly TimeSpan RecoveryGracePeriod = TimeSpan.FromMinutes(30);
+
private readonly ICommunicationTestRepository _communicationTestRepository;
private readonly ICommunicationTestRunRepository _communicationTestRunRepository;
private readonly ICommunicationTestResultRepository _communicationTestResultRepository;
+ private readonly ICommunicationTestTargetRepository _communicationTestTargetRepository;
private readonly IDepartmentsService _departmentsService;
private readonly IUserProfileService _userProfileService;
+ private readonly IDepartmentGroupsService _departmentGroupsService;
+ private readonly IPersonnelRolesService _personnelRolesService;
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+ private readonly ISmsService _smsService;
+ private readonly IEmailService _emailService;
+ private readonly IPushService _pushService;
+ private readonly IOutboundVoiceProvider _outboundVoiceProvider;
+ private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser;
+ private readonly IQueueService _queueService;
+ private readonly IUnitOfWork _unitOfWork;
public CommunicationTestService(
ICommunicationTestRepository communicationTestRepository,
ICommunicationTestRunRepository communicationTestRunRepository,
ICommunicationTestResultRepository communicationTestResultRepository,
+ ICommunicationTestTargetRepository communicationTestTargetRepository,
IDepartmentsService departmentsService,
- IUserProfileService userProfileService)
+ IUserProfileService userProfileService,
+ IDepartmentGroupsService departmentGroupsService,
+ IPersonnelRolesService personnelRolesService,
+ IDepartmentSettingsService departmentSettingsService,
+ ISmsService smsService,
+ IEmailService emailService,
+ IPushService pushService,
+ IOutboundVoiceProvider outboundVoiceProvider,
+ IPhoneNumberProcesserProvider phoneNumberProcesser,
+ IQueueService queueService,
+ IUnitOfWork unitOfWork)
{
_communicationTestRepository = communicationTestRepository;
_communicationTestRunRepository = communicationTestRunRepository;
_communicationTestResultRepository = communicationTestResultRepository;
+ _communicationTestTargetRepository = communicationTestTargetRepository;
_departmentsService = departmentsService;
_userProfileService = userProfileService;
+ _departmentGroupsService = departmentGroupsService;
+ _personnelRolesService = personnelRolesService;
+ _departmentSettingsService = departmentSettingsService;
+ _smsService = smsService;
+ _emailService = emailService;
+ _pushService = pushService;
+ _outboundVoiceProvider = outboundVoiceProvider;
+ _phoneNumberProcesser = phoneNumberProcesser;
+ _queueService = queueService;
+ _unitOfWork = unitOfWork;
}
public async Task> GetTestsByDepartmentIdAsync(int departmentId)
@@ -70,6 +116,182 @@ public async Task DeleteTestAsync(Guid communicationTestId, CancellationTo
return await _communicationTestRepository.DeleteAsync(test, cancellationToken);
}
+ #region Targeting
+
+ public async Task> GetTargetsByTestIdAsync(Guid communicationTestId)
+ {
+ return await _communicationTestTargetRepository.GetTargetsByTestIdAsync(communicationTestId);
+ }
+
+ ///
+ /// Replaces the whole target set for a test. Passing an empty collection clears targeting,
+ /// which puts the test back to covering the entire department.
+ ///
+ public async Task SaveTargetsAsync(Guid communicationTestId, int departmentId, IEnumerable targets, CancellationToken cancellationToken = default)
+ {
+ var existing = await _communicationTestTargetRepository.GetTargetsByTestIdAsync(communicationTestId);
+ if (existing != null)
+ {
+ foreach (var target in existing)
+ await _communicationTestTargetRepository.DeleteAsync(target, cancellationToken);
+ }
+
+ if (targets == null)
+ return;
+
+ // De-duplicate so the same group/role/user selected twice doesn't create duplicate rows.
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var target in targets)
+ {
+ if (target == null || string.IsNullOrWhiteSpace(target.TargetId))
+ continue;
+
+ if (!seen.Add($"{target.TargetType}:{target.TargetId.Trim()}"))
+ continue;
+
+ await _communicationTestTargetRepository.SaveOrUpdateAsync(new CommunicationTestTarget
+ {
+ CommunicationTestId = communicationTestId,
+ DepartmentId = departmentId,
+ TargetType = target.TargetType,
+ TargetId = target.TargetId.Trim()
+ }, cancellationToken, true);
+ }
+ }
+
+ ///
+ /// Saves a test and replaces its target set as one unit. Targeting is replaced by clearing
+ /// every existing row before writing the new ones, so a failure between the two halves leaves
+ /// the test with no targets -- which the resolver reads as "the whole department". A narrowly
+ /// targeted test silently widening to everyone is the worst way for this to fail, so both
+ /// halves commit together or not at all.
+ ///
+ public async Task SaveTestWithTargetsAsync(CommunicationTest test, int departmentId, IEnumerable targets, CancellationToken cancellationToken = default)
+ {
+ if (test == null)
+ return null;
+
+ _unitOfWork.CreateOrGetConnection();
+ try
+ {
+ var saved = await SaveTestAsync(test, cancellationToken);
+
+ // SaveTargetsAsync stamps this id onto every row it writes, so a brand new test can
+ // have its targets built by the caller before the test itself has an id.
+ await SaveTargetsAsync(saved.CommunicationTestId, departmentId, targets, cancellationToken);
+
+ _unitOfWork.CommitChanges();
+
+ return saved;
+ }
+ catch
+ {
+ _unitOfWork.DiscardChanges();
+ throw;
+ }
+ }
+
+ ///
+ /// Resolves the user ids a test covers. Returns null when the test has no targeting,
+ /// meaning every member of the department is tested.
+ ///
+ public async Task> ResolveTargetedUserIdsAsync(Guid communicationTestId, int departmentId)
+ {
+ var targets = await _communicationTestTargetRepository.GetTargetsByTestIdAsync(communicationTestId);
+ var targetList = targets?.ToList();
+
+ if (targetList == null || targetList.Count == 0)
+ return null;
+
+ var userIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var target in targetList)
+ {
+ if (string.IsNullOrWhiteSpace(target.TargetId))
+ continue;
+
+ switch ((CommunicationTestTargetType)target.TargetType)
+ {
+ case CommunicationTestTargetType.Group:
+ if (int.TryParse(target.TargetId, out var groupId))
+ {
+ var groupMembers = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId);
+ if (groupMembers != null)
+ {
+ foreach (var member in groupMembers)
+ userIds.Add(member.UserId);
+ }
+ }
+ break;
+
+ case CommunicationTestTargetType.Role:
+ if (int.TryParse(target.TargetId, out var roleId))
+ {
+ var roleMembers = await _personnelRolesService.GetAllMembersOfRoleAsync(roleId);
+ if (roleMembers != null)
+ {
+ foreach (var member in roleMembers)
+ userIds.Add(member.UserId);
+ }
+ }
+ break;
+
+ case CommunicationTestTargetType.User:
+ userIds.Add(target.TargetId);
+ break;
+ }
+ }
+
+ return userIds;
+ }
+
+ ///
+ /// Serializes a resolved audience for storage on the run. An untargeted test is stored as the
+ /// JSON literal null rather than a NULL column, which is what lets the builder tell a
+ /// snapshot that says "whole department" apart from a run that has no snapshot at all.
+ ///
+ private static string SerializeTargetedUserIds(HashSet targetedUserIds)
+ {
+ return JsonConvert.SerializeObject(targetedUserIds);
+ }
+
+ ///
+ /// Reads back the audience snapshot stored on a run. Returns false when the run has no usable
+ /// snapshot -- it predates snapshots, or its stored value is unreadable -- which means the
+ /// caller has to resolve the test's targeting itself.
+ ///
+ private static bool TryReadTargetedUserIds(string snapshot, out HashSet targetedUserIds)
+ {
+ targetedUserIds = null;
+
+ if (string.IsNullOrWhiteSpace(snapshot))
+ return false;
+
+ try
+ {
+ var userIds = JsonConvert.DeserializeObject>(snapshot);
+
+ // A snapshot of "null" is a real answer: the test was untargeted when the run started,
+ // so the run covers the whole department.
+ if (userIds == null)
+ return true;
+
+ // Rebuilt with the resolver's case-insensitive comparer -- a set built straight from
+ // deserialized values carries the default ordinal comparer, which would drop members
+ // whose stored id casing differs from their department membership row.
+ targetedUserIds = new HashSet(userIds, StringComparer.OrdinalIgnoreCase);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+
+ #endregion Targeting
+
public async Task CanStartOnDemandRunAsync(Guid communicationTestId)
{
var existingRuns = await _communicationTestRunRepository.GetRunsByTestIdAsync(communicationTestId);
@@ -83,6 +305,12 @@ public async Task CanStartOnDemandRunAsync(Guid communicationTestId)
return mostRecent.StartedOn.AddHours(48) <= DateTime.UtcNow;
}
+ ///
+ /// Claims the run and hands it to the worker. Nothing is built or sent here: a department of
+ /// any size is one result row insert and one provider round-trip per recipient per channel,
+ /// which the request thread that started the run cannot absorb. The audience the run covers
+ /// is resolved and stored on the run, so the worker tests who the run was started for.
+ ///
public async Task StartTestRunAsync(Guid communicationTestId, int departmentId, string initiatedByUserId, CancellationToken cancellationToken = default)
{
var test = await _communicationTestRepository.GetByIdAsync(communicationTestId);
@@ -98,23 +326,96 @@ public async Task StartTestRunAsync(Guid communicationTest
var runCode = GenerateRunCode();
+ // Freeze the audience with the run. Targeting can be edited between the run being queued
+ // and the worker picking it up, and a run has to test the people it was started for --
+ // otherwise the report describes an audience that was never actually tested. Resolving is
+ // a handful of membership reads, not the per-recipient work the worker does.
+ var targetedUserIds = await ResolveTargetedUserIdsAsync(communicationTestId, departmentId);
+
var run = new CommunicationTestRun
{
CommunicationTestId = communicationTestId,
DepartmentId = departmentId,
InitiatedByUserId = initiatedByUserId,
StartedOn = DateTime.UtcNow,
- Status = (int)CommunicationTestRunStatus.Running,
+ Status = (int)CommunicationTestRunStatus.Pending,
RunCode = runCode,
TotalUsersTested = 0,
- TotalResponses = 0
+ TotalResponses = 0,
+ TargetedUserIds = SerializeTargetedUserIds(targetedUserIds)
};
run = await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true);
+ try
+ {
+ var enqueued = await _queueService.EnqueueCommunicationTestAsync(new CommunicationTestQueueItem
+ {
+ DepartmentId = departmentId,
+ CommunicationTestRunId = run.CommunicationTestRunId.ToString(),
+ CommunicationTestId = communicationTestId.ToString()
+ }, cancellationToken);
+
+ if (!enqueued)
+ Logging.LogInfo($"CommunicationTest: run {run.CommunicationTestRunId} could not be published to the bus; it stays Pending for the recovery sweep.");
+ }
+ catch (Exception ex)
+ {
+ // The run row is saved and Pending, so a broker outage delays the test rather than
+ // losing it -- DeliverPendingRunsAsync sweeps it up on the worker's next cycle.
+ Logging.LogException(ex);
+ }
+
+ return run;
+ }
+
+ ///
+ /// Worker-side entry point: builds the result rows for a Pending run, then delivers them.
+ /// Safe to call twice for the same run -- building is skipped once results exist and each
+ /// result is only sent once.
+ ///
+ public async Task ProcessRunAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default)
+ {
+ await BuildRunResultsAsync(communicationTestRunId, cancellationToken);
+ await DeliverRunAsync(communicationTestRunId, cancellationToken);
+ }
+
+ ///
+ /// Writes one result row per recipient per enabled channel for the audience the run was
+ /// started with. No-ops when the run already has results, so a redelivered queue message
+ /// cannot double up the audience.
+ ///
+ public async Task BuildRunResultsAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default)
+ {
+ var run = await _communicationTestRunRepository.GetByIdAsync(communicationTestRunId);
+ if (run == null)
+ return null;
+
+ var test = await _communicationTestRepository.GetByIdAsync(run.CommunicationTestId);
+ if (test == null)
+ return run;
+
+ var existingResults = await _communicationTestResultRepository.GetResultsByRunIdAsync(communicationTestRunId);
+ if (existingResults != null && existingResults.Any())
+ return run;
+
+ var communicationTestId = run.CommunicationTestId;
+ var departmentId = run.DepartmentId;
+
var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId);
var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId);
+ // A targeted test only covers the audience snapshotted when the run started, so editing
+ // the test's targets while the run sat on the queue cannot change who it tests. A run
+ // created before snapshots existed has none and falls back to current targeting.
+ // Intersecting against current department membership means a stale group/role/user
+ // target can never pull in someone who left.
+ if (!TryReadTargetedUserIds(run.TargetedUserIds, out var targetedUserIds))
+ targetedUserIds = await ResolveTargetedUserIdsAsync(communicationTestId, departmentId);
+
+ if (targetedUserIds != null)
+ members = members.Where(m => targetedUserIds.Contains(m.UserId)).ToList();
+
int totalUsersTested = 0;
foreach (var member in members)
@@ -133,18 +434,14 @@ public async Task StartTestRunAsync(Guid communicationTest
Channel = (int)CommunicationTestChannel.Email,
ContactValue = profile?.MembershipEmail,
VerificationStatus = (int)emailVerified.ToVerificationStatus(),
- SendAttempted = emailVerified.IsContactMethodAllowedForSending(),
+ SendAttempted = emailVerified.IsContactMethodAllowedForSending()
+ && !string.IsNullOrWhiteSpace(profile?.MembershipEmail)
+ && IsEmailEnabled(profile),
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;
}
@@ -165,45 +462,43 @@ public async Task StartTestRunAsync(Guid communicationTest
ContactValue = profile?.GetPhoneNumber(),
ContactCarrier = carrierName,
VerificationStatus = (int)mobileVerified.ToVerificationStatus(),
- SendAttempted = mobileVerified.IsContactMethodAllowedForSending(),
+ SendAttempted = mobileVerified.IsContactMethodAllowedForSending()
+ && !string.IsNullOrWhiteSpace(profile?.GetPhoneNumber())
+ && IsSmsEnabled(profile),
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;
+ // Honour the user's voice routing preference: the number we would really call for a
+ // dispatch is the number the test has to ring, otherwise the report proves nothing.
+ var useHome = UsesHomeRoute(profile);
+
+ var voiceNumber = useHome ? profile?.GetHomePhoneNumber() : profile?.GetPhoneNumber();
+ var voiceVerified = useHome ? profile?.HomeNumberVerified : 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(),
+ ContactValue = voiceNumber,
+ VerificationStatus = (int)voiceVerified.ToVerificationStatus(),
+ SendAttempted = voiceVerified.IsContactMethodAllowedForSending()
+ && !string.IsNullOrWhiteSpace(voiceNumber)
+ && profile != null && profile.VoiceForCall,
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;
}
@@ -217,9 +512,10 @@ public async Task StartTestRunAsync(Guid communicationTest
UserId = member.UserId,
Channel = (int)CommunicationTestChannel.Push,
VerificationStatus = (int)ContactVerificationStatus.Verified,
- SendAttempted = true,
- SendSucceeded = true,
- SentOn = DateTime.UtcNow,
+ // The push service silently drops a notification when this opt-in is off, so
+ // gate here rather than reporting an attempt that never leaves the process.
+ SendAttempted = profile != null && profile.SendNotificationPush,
+ SendSucceeded = false,
Responded = false,
ResponseToken = Guid.NewGuid().ToString("N")
};
@@ -233,12 +529,257 @@ public async Task StartTestRunAsync(Guid communicationTest
}
run.TotalUsersTested = totalUsersTested;
- run.Status = (int)CommunicationTestRunStatus.AwaitingResponses;
+ run.Status = (int)CommunicationTestRunStatus.Running;
run = await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true);
return run;
}
+ #region Delivery
+
+ ///
+ /// Sends the messages for every not-yet-sent result on a run and moves the run to
+ /// AwaitingResponses. Safe to call more than once: results that already have a SentOn are
+ /// skipped, so a run whose delivery was interrupted can be finished by the worker. Sends
+ /// nothing when DoNotBroadcast is on and the run's department is not on the bypass list.
+ ///
+ public async Task DeliverRunAsync(Guid communicationTestRunId, CancellationToken cancellationToken = default)
+ {
+ var run = await _communicationTestRunRepository.GetByIdAsync(communicationTestRunId);
+ if (run == null)
+ return 0;
+
+ var test = await _communicationTestRepository.GetByIdAsync(run.CommunicationTestId);
+ if (test == null)
+ return 0;
+
+ var results = await _communicationTestResultRepository.GetResultsByRunIdAsync(communicationTestRunId);
+ if (results == null)
+ return 0;
+
+ var department = await _departmentsService.GetDepartmentByIdAsync(run.DepartmentId);
+ var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(run.DepartmentId);
+ var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(run.DepartmentId);
+
+ // Every other outbound path honours the global broadcast kill switch, and a communication
+ // test is the one feature whose whole job is to message every member of a department -- so
+ // an ungated test run is the loudest thing a non-production environment can do. Result rows
+ // are still written and the run still finishes, the provider calls are all that is dropped.
+ var canTransmit = ConfigHelper.CanTransmit(run.DepartmentId);
+ if (!canTransmit)
+ Logging.LogInfo($"CommunicationTest: run {communicationTestRunId} sent nothing, DoNotBroadcast is on and department {run.DepartmentId} is not bypassed.");
+
+ int sent = 0;
+
+ foreach (var result in results)
+ {
+ if (!result.SendAttempted || result.SentOn.HasValue)
+ continue;
+
+ profiles.TryGetValue(result.UserId, out var profile);
+
+ bool succeeded = false;
+
+ // Blocked runs fall straight through to the stamp below with succeeded false. Leaving
+ // SentOn null instead would look like an interrupted delivery, and the recovery sweep
+ // would keep re-processing a run that can never send.
+ if (canTransmit)
+ {
+ // One channel failing for one person must never abort the rest of the run -- the
+ // report is the deliverable, and a thrown provider error would leave it half written.
+ try
+ {
+ // Every message is composed for the recipient, so it renders in THEIR language, not
+ // the language of whoever started the run or of the worker thread.
+ var culture = profile?.Language;
+
+ switch ((CommunicationTestChannel)result.Channel)
+ {
+ case CommunicationTestChannel.Email:
+ succeeded = await _emailService.SendCommunicationTestEmailAsync(
+ result.ContactValue,
+ profile?.FirstName ?? string.Empty,
+ department?.Name ?? "Your department",
+ test.Name,
+ BuildEmailConfirmUrl(result.ResponseToken),
+ culture);
+ break;
+
+ case CommunicationTestChannel.Sms:
+ succeeded = await SendTestSmsAsync(result, profile, run.RunCode, test.Name, departmentNumber, run.DepartmentId);
+ break;
+
+ case CommunicationTestChannel.Voice:
+ succeeded = await SendTestVoiceAsync(result, profile);
+ break;
+
+ case CommunicationTestChannel.Push:
+ succeeded = await SendTestPushAsync(result, profile, department, test.Name);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ succeeded = false;
+ }
+ }
+
+ result.SendSucceeded = succeeded;
+ result.SentOn = DateTime.UtcNow;
+
+ await _communicationTestResultRepository.SaveOrUpdateAsync(result, cancellationToken, true);
+
+ if (succeeded)
+ sent++;
+ }
+
+ if (run.Status == (int)CommunicationTestRunStatus.Pending || run.Status == (int)CommunicationTestRunStatus.Running)
+ {
+ run.Status = (int)CommunicationTestRunStatus.AwaitingResponses;
+ await _communicationTestRunRepository.SaveOrUpdateAsync(run, cancellationToken, true);
+ }
+
+ return sent;
+ }
+
+ ///
+ /// Recovery sweep for runs still sitting in Pending/Running. That is what a run looks like
+ /// when its queue message never reached the broker, or when the worker that was delivering it
+ /// died part-way through. Building and sending are both idempotent, so re-processing is safe.
+ ///
+ public async Task DeliverPendingRunsAsync(CancellationToken cancellationToken = default)
+ {
+ var openRuns = await _communicationTestRunRepository.GetOpenRunsAsync();
+ if (openRuns == null)
+ return;
+
+ foreach (var run in openRuns)
+ {
+ if (run.Status != (int)CommunicationTestRunStatus.Pending && run.Status != (int)CommunicationTestRunStatus.Running)
+ continue;
+
+ // Leave a run the worker may still be actively processing alone. Without this the sweep
+ // races the queue consumer on a freshly published run, and two builders that both see
+ // zero results would each write a full set of rows.
+ if (run.StartedOn.Add(RecoveryGracePeriod) > DateTime.UtcNow)
+ continue;
+
+ try
+ {
+ await ProcessRunAsync(run.CommunicationTestRunId, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ }
+ }
+ }
+
+ private async Task SendTestSmsAsync(CommunicationTestResult result, UserProfile profile, string runCode, string testName, string departmentNumber, int departmentId)
+ {
+ // The stored ContactValue is the display form (digits only). Providers need E.164, so
+ // normalise off the raw profile number the same way contact verification does -- an
+ // unnormalised number is rejected by Twilio/SignalWire with "Invalid 'To'".
+ var rawNumber = profile?.MobileNumber;
+ if (string.IsNullOrWhiteSpace(rawNumber))
+ return false;
+
+ var processed = _phoneNumberProcesser.Process(rawNumber);
+ if (processed == null || !processed.IsValid || string.IsNullOrWhiteSpace(processed.InternationalNumber))
+ {
+ Logging.LogInfo($"Communication test SMS skipped for user {result.UserId}: mobile number is not a valid sendable number (needs international format, e.g. +).");
+ return false;
+ }
+
+ var body = CommunicationTestMessages.BuildSmsBody(testName, runCode, profile?.Language);
+
+ var carrier = profile != null && profile.MobileCarrier > 0 ? (MobileCarriers)profile.MobileCarrier : MobileCarriers.None;
+
+ return await _smsService.SendCommunicationTestAsync(processed.InternationalNumber, body, departmentNumber, carrier, departmentId);
+ }
+
+ ///
+ /// Whether a test voice call routes to the home number rather than the mobile. Both the result
+ /// row and the call itself pick their number through here, so the number the report shows is
+ /// the number that was actually dialled.
+ ///
+ private static bool UsesHomeRoute(UserProfile profile)
+ {
+ return profile != null && !profile.VoiceCallMobile && profile.VoiceCallHome
+ && !string.IsNullOrWhiteSpace(profile.GetHomePhoneNumber());
+ }
+
+ private async Task SendTestVoiceAsync(CommunicationTestResult result, UserProfile profile)
+ {
+ if (string.IsNullOrWhiteSpace(result.ResponseToken))
+ return false;
+
+ // The stored ContactValue is the display form: GetPhoneNumber/GetHomePhoneNumber strip the
+ // leading "+", which leaves an international number unparseable and fails every non-US voice
+ // test as "not a valid sendable number". Normalise off the raw profile number instead, the
+ // same way the SMS path does.
+ var rawNumber = UsesHomeRoute(profile) ? profile?.HomeNumber : profile?.MobileNumber;
+ if (string.IsNullOrWhiteSpace(rawNumber))
+ return false;
+
+ var processed = _phoneNumberProcesser.Process(rawNumber);
+ if (processed == null || !processed.IsValid || string.IsNullOrWhiteSpace(processed.InternationalNumber))
+ {
+ Logging.LogInfo($"Communication test voice call skipped for user {result.UserId}: phone number is not a valid sendable number.");
+ return false;
+ }
+
+ return await _outboundVoiceProvider.SendCommunicationTestCallAsync(processed.InternationalNumber, result.ResponseToken);
+ }
+
+ private async Task SendTestPushAsync(CommunicationTestResult result, UserProfile profile, Department department, string testName)
+ {
+ if (profile == null || !profile.SendNotificationPush)
+ return false;
+
+ var message = new StandardPushMessage
+ {
+ Title = CommunicationTestMessages.BuildPushTitle(profile.Language),
+ SubTitle = CommunicationTestMessages.BuildPushBody(testName, profile.Language),
+ // The event code carries the response token to the device. The Responder app parses
+ // the "CT:" prefix, shows a confirm button, and posts the token back to
+ // CommunicationTests/RecordPushResponse — without it push has no way to be answered.
+ Id = CommunicationTestPushEventCode(result.ResponseToken),
+ DepartmentCode = department?.Code,
+ DepartmentId = result.DepartmentId
+ };
+
+ return await _pushService.PushNotification(message, result.UserId, profile);
+ }
+
+ ///
+ /// Push event code for a communication test. The "CT:" prefix is what the Responder app
+ /// matches on to show the confirm-receipt prompt, and the token after it is what the app
+ /// posts back. Changing either half breaks push confirmation, so both sides reference this
+ /// format rather than building the string inline.
+ ///
+ private static string CommunicationTestPushEventCode(string responseToken)
+ {
+ return $"CT:{responseToken}";
+ }
+
+ private static string BuildEmailConfirmUrl(string responseToken)
+ {
+ return $"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/v4/CommunicationTestResponse/EmailConfirm?token={Uri.EscapeDataString(responseToken ?? string.Empty)}";
+ }
+
+ // A channel counts as reachable when ANY of the per-event opt-ins for it is on. The test
+ // answers "can we get a message to this person at all", not "would this specific event type
+ // go out", so gating on a single flag would report false negatives.
+ private static bool IsSmsEnabled(UserProfile profile)
+ => profile != null && (profile.SendSms || profile.SendMessageSms || profile.SendNotificationSms);
+
+ private static bool IsEmailEnabled(UserProfile profile)
+ => profile != null && (profile.SendEmail || profile.SendMessageEmail || profile.SendNotificationEmail);
+
+ #endregion Delivery
+
public async Task> GetRunsByTestIdAsync(Guid communicationTestId)
{
return await _communicationTestRunRepository.GetRunsByTestIdAsync(communicationTestId);
@@ -266,13 +807,15 @@ public async Task RecordSmsResponseAsync(string runCode, string fromPhoneN
return false;
var results = await _communicationTestResultRepository.GetResultsByRunIdAsync(run.CommunicationTestRunId);
- var cleanPhone = fromPhoneNumber.Replace("+", "").Replace("-", "").Replace(" ", "").Replace("(", "").Replace(")", "");
+ var inboundDigits = DigitsOnly(fromPhoneNumber);
+
+ if (string.IsNullOrWhiteSpace(inboundDigits))
+ return false;
var matchingResult = results.FirstOrDefault(r =>
r.Channel == (int)CommunicationTestChannel.Sms &&
!r.Responded &&
- r.ContactValue != null &&
- r.ContactValue.Replace("+", "").Replace("-", "").Replace(" ", "").Replace("(", "").Replace(")", "") == cleanPhone);
+ IsSamePhoneNumber(r.ContactValue, inboundDigits));
if (matchingResult == null)
return false;
@@ -309,6 +852,21 @@ public async Task RecordPushResponseAsync(string responseToken)
return result?.DepartmentId;
}
+ public async Task GetRecipientLanguageByResponseTokenAsync(string responseToken)
+ {
+ if (string.IsNullOrWhiteSpace(responseToken))
+ return null;
+
+ // The voice webhook only carries the token, so the person being called is identified by
+ // the result row it belongs to. Their profile language decides what the call says.
+ var result = await _communicationTestResultRepository.GetResultByResponseTokenAsync(responseToken);
+ if (result == null || string.IsNullOrWhiteSpace(result.UserId))
+ return null;
+
+ var profile = await _userProfileService.GetProfileByUserIdAsync(result.UserId);
+ return profile?.Language;
+ }
+
public async Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
@@ -387,6 +945,47 @@ private async Task UpdateRunResponseCountAsync(CommunicationTestRun run)
await _communicationTestRunRepository.SaveOrUpdateAsync(run, CancellationToken.None, true);
}
+ private static string DigitsOnly(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var builder = new StringBuilder(value.Length);
+ foreach (var character in value)
+ {
+ if (char.IsDigit(character))
+ builder.Append(character);
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Compares two phone numbers by their digits, tolerating a country code on one side only.
+ /// The stored contact value is the profile's display form while the inbound webhook reports
+ /// an E.164 number, so an exact compare would miss every international sender.
+ ///
+ private static bool IsSamePhoneNumber(string storedNumber, string inboundDigits)
+ {
+ var storedDigits = DigitsOnly(storedNumber);
+
+ if (string.IsNullOrWhiteSpace(storedDigits) || string.IsNullOrWhiteSpace(inboundDigits))
+ return false;
+
+ if (storedDigits == inboundDigits)
+ return true;
+
+ // Require a meaningful overlap before accepting a suffix match, otherwise short or
+ // partially entered numbers could match the wrong member of the department.
+ const int minimumSignificantDigits = 7;
+ var shortest = Math.Min(storedDigits.Length, inboundDigits.Length);
+ if (shortest < minimumSignificantDigits)
+ return false;
+
+ return storedDigits.EndsWith(inboundDigits, StringComparison.Ordinal)
+ || inboundDigits.EndsWith(storedDigits, StringComparison.Ordinal);
+ }
+
private static string GenerateRunCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
diff --git a/Core/Resgrid.Services/ContactVerificationService.cs b/Core/Resgrid.Services/ContactVerificationService.cs
index 482b9a5a5..0dd54aa52 100644
--- a/Core/Resgrid.Services/ContactVerificationService.cs
+++ b/Core/Resgrid.Services/ContactVerificationService.cs
@@ -73,7 +73,7 @@ public async Task SendEmailVerificationCodeAsync(
await _userProfileService.SaveProfileAsync(departmentId, profile, cancellationToken);
- bool sent = await _emailService.SendEmailVerificationCodeAsync(emailAddress, profile.FirstName ?? string.Empty, code);
+ bool sent = await _emailService.SendEmailVerificationCodeAsync(emailAddress, profile.FirstName ?? string.Empty, code, profile.Language);
await WriteAuditAsync(userId, departmentId, ContactVerificationType.Email, sent, "Send", null, cancellationToken);
@@ -109,7 +109,7 @@ public async Task SendMobileVerificationCodeAsync
await _userProfileService.SaveProfileAsync(departmentId, profile, cancellationToken);
- bool sent = await _smsService.SendSmsVerificationCodeAsync(mobileResult.InternationalNumber, code, departmentNumber);
+ bool sent = await _smsService.SendSmsVerificationCodeAsync(mobileResult.InternationalNumber, code, departmentNumber, profile.Language);
await WriteAuditAsync(userId, departmentId, ContactVerificationType.MobileNumber, sent, "Send", null, cancellationToken);
diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs
index 612c463f8..64b1fd41d 100644
--- a/Core/Resgrid.Services/EmailService.cs
+++ b/Core/Resgrid.Services/EmailService.cs
@@ -2,6 +2,8 @@
using System.IO;
using System.Net.Mail;
using Resgrid.Framework;
+using CommunicationTestMessages = Resgrid.Localization.Areas.User.CommunicationTest.CommunicationTestMessageCatalog;
+using SystemMessagesResources = Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources;
using Resgrid.Model;
using Resgrid.Model.Providers;
using Resgrid.Model.Services;
@@ -640,7 +642,7 @@ public async Task SendNotificationAsync(string userId, string message, int
using (var mail = new MailMessage())
{
mail.To.Add(email);
- mail.Subject = "Notification";
+ mail.Subject = SystemMessagesResources.Get("NotificationEmailSubject", profile?.Language);
mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
mail.Body = message;
@@ -678,7 +680,7 @@ public async Task SendCalendarAsync(string userId, string message, int dep
using (var mail = new MailMessage())
{
mail.To.Add(email);
- mail.Subject = "Calendar";
+ mail.Subject = SystemMessagesResources.Get("CalendarEmailSubject", profile?.Language);
mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
mail.Body = message;
@@ -720,7 +722,7 @@ public async Task SendDeleteDepartmentEmail(string sendingToEmail, string
return false;
}
- public async Task SendEmailVerificationCodeAsync(string toEmailAddress, string firstName, string verificationCode)
+ public async Task SendEmailVerificationCodeAsync(string toEmailAddress, string firstName, string verificationCode, string culture = null)
{
if (string.IsNullOrWhiteSpace(toEmailAddress))
return false;
@@ -729,9 +731,9 @@ public async Task SendEmailVerificationCodeAsync(string toEmailAddress, st
{
using var mail = new MailMessage();
mail.To.Add(toEmailAddress);
- mail.Subject = "Resgrid Verification Code";
+ mail.Subject = SystemMessagesResources.Get("VerificationCodeEmailSubject", culture);
mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
- mail.Body = $"Hi {firstName},\r\n\r\nYour Resgrid verification code is: {verificationCode}\r\n\r\nThis code expires in {Config.VerificationConfig.VerificationCodeExpiryMinutes} minutes. If you did not request this, please ignore this email.\r\n\r\nThe Resgrid Team";
+ mail.Body = SystemMessagesResources.Get("VerificationCodeEmailBody", culture, firstName, verificationCode, Config.VerificationConfig.VerificationCodeExpiryMinutes);
mail.IsBodyHtml = false;
await _emailSender.SendEmail(mail);
@@ -745,7 +747,7 @@ public async Task SendEmailVerificationCodeAsync(string toEmailAddress, st
return false;
}
- public async Task SendGdprDataExportReadyAsync(string toEmailAddress, string firstName, string downloadUrl, DateTime expiresAt)
+ public async Task SendGdprDataExportReadyAsync(string toEmailAddress, string firstName, string downloadUrl, DateTime expiresAt, string culture = null)
{
if (string.IsNullOrWhiteSpace(toEmailAddress))
return false;
@@ -754,9 +756,34 @@ public async Task SendGdprDataExportReadyAsync(string toEmailAddress, stri
{
using var mail = new MailMessage();
mail.To.Add(toEmailAddress);
- mail.Subject = "Your Resgrid Data Export is Ready";
+ mail.Subject = SystemMessagesResources.Get("GdprExportReadySubject", culture);
mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
- mail.Body = $"Hi {firstName},\r\n\r\nYour personal data export is ready for download.\r\n\r\nDownload link: {downloadUrl}\r\n\r\nThis link expires on {expiresAt:f} UTC. After this date the data will no longer be available.\r\n\r\nIf you did not request this, please contact support.\r\n\r\nThe Resgrid Team";
+ mail.Body = SystemMessagesResources.Get("GdprExportReadyBody", culture, firstName, downloadUrl, expiresAt);
+ mail.IsBodyHtml = false;
+
+ await _emailSender.SendEmail(mail);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ }
+
+ return false;
+ }
+
+ public async Task SendCommunicationTestEmailAsync(string toEmailAddress, string firstName, string departmentName, string testName, string confirmUrl, string culture = null)
+ {
+ if (string.IsNullOrWhiteSpace(toEmailAddress))
+ return false;
+
+ try
+ {
+ using var mail = new MailMessage();
+ mail.To.Add(toEmailAddress);
+ mail.Subject = CommunicationTestMessages.BuildEmailSubject(testName, culture);
+ mail.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
+ mail.Body = CommunicationTestMessages.BuildEmailBody(firstName, departmentName, testName, confirmUrl, culture);
mail.IsBodyHtml = false;
await _emailSender.SendEmail(mail);
diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs
index 07517d4d4..a866ced7c 100644
--- a/Core/Resgrid.Services/GdprDataExportService.cs
+++ b/Core/Resgrid.Services/GdprDataExportService.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
@@ -120,7 +120,8 @@ await _emailService.SendGdprDataExportReadyAsync(
user.Email,
profile.FirstName,
downloadUrl,
- request.TokenExpiresAt.Value);
+ request.TokenExpiresAt.Value,
+ profile.Language);
}
}
catch (Exception ex)
diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs
index fb3cfef4f..d01df6bdc 100644
--- a/Core/Resgrid.Services/PushService.cs
+++ b/Core/Resgrid.Services/PushService.cs
@@ -154,29 +154,49 @@ public async Task PushNotification(StandardPushMessage message, string use
if (profile == null)
profile = await _userProfileService.GetProfileByUserIdAsync(userId);
- if (profile != null && profile.SendNotificationPush)
+ // Nothing is sent when the user has push off, so say so rather than reporting success —
+ // a caller that reports delivery (the communication test) would otherwise claim a send
+ // that never left this method.
+ if (profile == null || !profile.SendNotificationPush)
+ return false;
+
+ string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification);
+
+ // An event code supplied on the message wins, so a caller can round-trip its own
+ // identifier to the device (the communication test sends "CT:{responseToken}" and the
+ // Responder app posts that token back to confirm receipt). MessageId stays the default
+ // for ordinary notifications, preserving the existing "N{id}" codes.
+ var eventCode = !string.IsNullOrWhiteSpace(message.Id)
+ ? message.Id
+ : string.Format("N{0}", message.MessageId);
+
+ bool delivered = false;
+
+ try
{
- string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification);
+ await _notificationProvider.SendAllNotifications(message.Title, message.SubTitle, userId, eventCode, soundType, true, 1, "#000000");
+ delivered = true;
+ }
+ catch (Exception ex)
+ {
+ Framework.Logging.LogException(ex);
+ }
- try
- {
- await _notificationProvider.SendAllNotifications(message.Title, message.SubTitle, userId, string.Format("N{0}", message.MessageId), soundType, true, 1, "#000000");
- }
- catch (Exception ex)
- {
- Framework.Logging.LogException(ex);
- }
- try
- {
- if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
- await _novuProvider.SendUserNotification(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("N{0}", message.MessageId), soundType);
- }
- catch (Exception ex)
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
{
- Framework.Logging.LogException(ex);
+ // Novu is the only transport that reports back whether it accepted the message;
+ // the legacy provider returns void, so "didn't throw" is the best it can offer.
+ delivered |= await _novuProvider.SendUserNotification(message.Title, message.SubTitle, userId, message.DepartmentCode, eventCode, soundType);
}
}
- return true;
+ catch (Exception ex)
+ {
+ Framework.Logging.LogException(ex);
+ }
+
+ return delivered;
}
///
diff --git a/Core/Resgrid.Services/QueueService.cs b/Core/Resgrid.Services/QueueService.cs
index f57a349db..2fbb552ce 100644
--- a/Core/Resgrid.Services/QueueService.cs
+++ b/Core/Resgrid.Services/QueueService.cs
@@ -51,6 +51,14 @@ public async Task GetQueueItemByIdAsync(int queueItemId)
return true;
}
+ public async Task EnqueueCommunicationTestAsync(CommunicationTestQueueItem item, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // Returns false rather than throwing: the run row is already saved as Pending, and the
+ // worker's recovery sweep picks up a run whose message never made it onto the bus. The
+ // caller decides whether a failed publish is worth surfacing.
+ return await _outboundQueueProvider.EnqueueCommunicationTest(item);
+ }
+
public async Task GetPendingDeleteDepartmentQueueItemAsync(int departmentId)
{
var allItems = await _queueItemsRepository.GetAllAsync();
diff --git a/Core/Resgrid.Services/SmsService.cs b/Core/Resgrid.Services/SmsService.cs
index 7d388fcce..1ed165633 100644
--- a/Core/Resgrid.Services/SmsService.cs
+++ b/Core/Resgrid.Services/SmsService.cs
@@ -6,6 +6,7 @@
using Resgrid.Model.Providers;
using Resgrid.Model.Services;
using Resgrid.Framework;
+using SystemMessagesResources = Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources;
namespace Resgrid.Services
{
@@ -441,14 +442,14 @@ await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatNotif
return true;
}
- public async Task SendSmsVerificationCodeAsync(string toPhoneNumber, string verificationCode, string departmentNumber)
+ public async Task SendSmsVerificationCodeAsync(string toPhoneNumber, string verificationCode, string departmentNumber, string culture = null)
{
if (string.IsNullOrWhiteSpace(toPhoneNumber))
return false;
try
{
- string messageBody = $"Your Resgrid verification code is: {verificationCode}. It expires in {Config.VerificationConfig.VerificationCodeExpiryMinutes} minutes.";
+ string messageBody = SystemMessagesResources.Get("VerificationCodeSmsBody", culture, verificationCode, Config.VerificationConfig.VerificationCodeExpiryMinutes);
// Always use direct provider send for verification — never email-to-SMS gateway,
// since we need deterministic delivery for security-critical codes.
@@ -470,6 +471,32 @@ await _textMessageProvider.SendTextMessage(
}
}
+ public async Task SendCommunicationTestAsync(string toPhoneNumber, string message, string departmentNumber, MobileCarriers carrier, int departmentId)
+ {
+ if (string.IsNullOrWhiteSpace(toPhoneNumber) || string.IsNullOrWhiteSpace(message))
+ return false;
+
+ try
+ {
+ // Direct provider send only. The recipient confirms the test by replying with the run
+ // code, and a reply is only routed back to us when the message came from our number --
+ // the email-to-SMS gateway path would deliver but leave the recipient unable to answer.
+ return await _textMessageProvider.SendTextMessage(
+ toPhoneNumber,
+ message,
+ departmentNumber,
+ carrier,
+ departmentId,
+ false,
+ false);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+
private string FormatCallSubject(Call call)
{
if (call.IsCritical)
diff --git a/Documentation/translation-audit.md b/Documentation/translation-audit.md
new file mode 100644
index 000000000..648045fe6
--- /dev/null
+++ b/Documentation/translation-audit.md
@@ -0,0 +1,92 @@
+# Localization Audit — Resgrid.Localization
+
+**Generated:** 2026-08-16, against `develop`.
+**Scope:** every `*.en.resx` under `Core/Resgrid.Localization` compared against its nine
+translated siblings (`de`, `es`, `fr`, `it`, `pl`, `sv`, `uk`, `el`, `ar`).
+
+## Headline
+
+| Measure | Count |
+|---|---|
+| Area/language pairs | 414 |
+| Fully translated | 116 |
+| Partially English | 143 |
+| **Never populated** (Visual Studio starter template) | **134** |
+| Resource file missing entirely | 21 |
+| Untranslated strings, approximate | **~17,500** |
+
+Only ~28% of area/language pairs are complete. The debt predates the communication test work and
+is not visible from the code, because every one of these files exists, is embedded, and resolves —
+it just returns English. A member who sets their profile to Polish sees a mix of Polish and English
+with no error anywhere.
+
+## The three failure modes
+
+### 1. Never populated (134 pairs, 22 areas)
+
+The resx still contains the Visual Studio starter entries (`Name1`, `Bitmap1`, `Icon1`) and no real
+keys. Lookups fall through to English for every string in the area.
+
+Affected areas: Calendar, Call, Common, Contacts, CustomStatuses, Dashboard, DeleteAccount,
+Department, DepartmentTypes, Documents, EditProfile, HomeDashboard, Login, Logs, Messages, Note,
+Person, Profile, Shifts, Subscription, Templates, Units — each in `de`, `fr`, `it`, `pl`, `sv`, `uk`
+(and `Common`, `Department` also in `es`).
+
+`Common` and `Login` are the most damaging: they back shared UI and the sign-in screen.
+
+### 2. Missing file (21 pairs, 3 areas)
+
+No resx at all for the language. CustomMaps, IndoorMaps and Mapping have only `en`, `el` and `ar`.
+
+### 3. Partially English (143 pairs)
+
+The file is populated but many values are still the English text. Worst offenders:
+
+| Area | Language | English values | Total keys |
+|---|---|---|---|
+| Security | fr | 263 | 328 |
+| Security | sv | 260 | 328 |
+| Security | de / it / pl / uk | 259 | 328 |
+| Workflows | it | 141 | 177 |
+| Workflows | fr | 140 | 177 |
+| TwoFactor | de / fr / it / pl / sv / uk | 50 | 51 |
+| Logs | es | 30 | 79 |
+
+`Security` and `TwoFactor` matter disproportionately — they cover account access and recovery.
+
+## What is complete
+
+- **CommunicationTest** — 128 keys × 10 locales, at parity. Covers both the screens and the
+ messages a test sends.
+- **SystemMessages** — 7 keys × 10 locales. Verification codes (email + SMS), GDPR export notice,
+ and the generic notification/calendar email subjects.
+- **Moderation** — was already complete before this audit.
+
+These three are pinned by `Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs`, which
+fails if a new English placeholder, missing key, missing file or starter-template stub appears in
+them. Add an area to its `GuardedAreas` list once that area is brought up to full translation.
+
+The guard deliberately does **not** assert the backlog above. A test that fails from day one gets
+muted, and a muted test guards nothing.
+
+## Recommended order of work
+
+1. `Common` and `Login` — shared UI and sign-in, visible to everyone in every locale.
+2. `Security` and `TwoFactor` — account access and recovery.
+3. `Call`, `Dispatch`, `Messages` — the operational path.
+4. Everything else.
+
+## Re-running the audit
+
+The comparison is a straightforward resx diff: parse each `*.en.resx`, then for each language check
+for a missing file, the `Name1`/`Bitmap1` starter markers, keys absent from the translation, and
+values byte-identical to English. Short brand/protocol strings ("SMS", "Push", "min") and words that
+are genuinely the same in the target language are false positives — the guard test keeps an explicit
+allow-list of those pairs rather than guessing with a heuristic.
+
+## Caveat on the translations added in this pass
+
+The CommunicationTest and SystemMessages values were authored by an AI, not a native speaker. The
+structure is correct and placeholder integrity is test-verified across all ten locales, but the
+wording should get a native review before it reaches members — particularly the SMS and voice text,
+which people act on under pressure.
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
index 8cd523887..028d74d5f 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
@@ -180,6 +180,12 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Chatb
autoDelete: false,
arguments: null);
+ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.CommunicationTestQueueName),
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: null);
+
await DeclareUnitLocationV2QueuesAsync(channel);
return true;
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
index 6d62ad435..8f01da380 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
@@ -32,6 +32,7 @@ public class RabbitInboundQueueProvider
public Func SecurityRefreshEventQueueReceived;
public Func WorkflowQueueReceived;
public Func ChatbotMessageQueueReceived;
+ public Func CommunicationTestQueueReceived;
public RabbitInboundQueueProvider()
{
@@ -669,6 +670,72 @@ await _channel.BasicConsumeAsync(
autoAck: false,
consumer: chatbotMessageQueueReceivedConsumer);
}
+
+ if (CommunicationTestQueueReceived != null)
+ {
+ var communicationTestQueueReceivedConsumer = new AsyncEventingBasicConsumer(_channel);
+ communicationTestQueueReceivedConsumer.ReceivedAsync += async (model, ea) =>
+ {
+ if (ea == null)
+ return;
+
+ // An unprocessable delivery has to be settled, not ignored. Leaving it neither
+ // acked nor nacked holds a prefetch slot until the connection drops, and enough
+ // of them stall the consumer outright.
+ if (ea.Body.Length <= 0)
+ {
+ await _channel.BasicNackAsync(ea.DeliveryTag, false, false);
+ Logging.LogInfo("CommunicationTest: dropping a queue delivery with an empty body.");
+ return;
+ }
+
+ CommunicationTestQueueItem ctqi = null;
+ try
+ {
+ var body = ea.Body;
+ var message = Encoding.UTF8.GetString(body.ToArray());
+ ctqi = ObjectSerialization.Deserialize(message);
+ }
+ catch (Exception ex)
+ {
+ await _channel.BasicNackAsync(ea.DeliveryTag, false, false);
+ Logging.LogException(ex, Encoding.UTF8.GetString(ea.Body.ToArray()));
+ return;
+ }
+
+ // Deserialization can hand back null without throwing -- a body of the literal
+ // "null" does exactly that. Same outcome as a parse failure: never processable,
+ // so drop it rather than leak it.
+ if (ctqi == null)
+ {
+ await _channel.BasicNackAsync(ea.DeliveryTag, false, false);
+ Logging.LogInfo($"CommunicationTest: dropping a queue delivery that deserialized to null: {Encoding.UTF8.GetString(ea.Body.ToArray())}");
+ return;
+ }
+
+ try
+ {
+ if (CommunicationTestQueueReceived != null)
+ {
+ await CommunicationTestQueueReceived.Invoke(ctqi);
+ await _channel.BasicAckAsync(ea.DeliveryTag, false);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ if (await RetryQueueItem(ea, ex))
+ await _channel.BasicNackAsync(ea.DeliveryTag, false, false);
+ else
+ await _channel.BasicNackAsync(ea.DeliveryTag, false, true);
+ }
+ };
+
+ String communicationTestQueueReceivedConsumerTag = await _channel.BasicConsumeAsync(
+ queue: RabbitConnection.SetQueueNameForEnv(ServiceBusConfig.CommunicationTestQueueName),
+ autoAck: false,
+ consumer: communicationTestQueueReceivedConsumer);
+ }
}
}
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
index 07ba6f3c6..1039d9d6d 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
@@ -33,6 +33,16 @@ public async Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMes
return await SendMessage(ServiceBusConfig.ChatbotProcessingQueueName, serializedObject);
}
+ public async Task EnqueueCommunicationTest(CommunicationTestQueueItem communicationTestQueue)
+ {
+ string serializedObject = ObjectSerialization.Serialize(communicationTestQueue);
+
+ // Publisher confirmation: losing this message silently strands the run with no results
+ // and no messages sent. The caller surfaces a failure so the run can be retried.
+ return await SendMessage(ServiceBusConfig.CommunicationTestQueueName, serializedObject,
+ requirePublisherConfirmation: true);
+ }
+
public async Task EnqueueMessage(MessageQueueItem messageQueue)
{
string serializedObject = ObjectSerialization.Serialize(messageQueue);
diff --git a/Providers/Resgrid.Providers.Bus/OutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundQueueProvider.cs
index 414552e27..dd861ba0d 100644
--- a/Providers/Resgrid.Providers.Bus/OutboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/OutboundQueueProvider.cs
@@ -60,5 +60,10 @@ public async Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMes
{
return await _rabbitOutboundQueueProvider.EnqueueChatbotMessage(chatbotMessageQueue);
}
+
+ public async Task EnqueueCommunicationTest(CommunicationTestQueueItem communicationTestQueue)
+ {
+ return await _rabbitOutboundQueueProvider.EnqueueCommunicationTest(communicationTestQueue);
+ }
}
}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0118_AddCommunicationTestTargets.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0118_AddCommunicationTestTargets.cs
new file mode 100644
index 000000000..6be6f84ea
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0118_AddCommunicationTestTargets.cs
@@ -0,0 +1,42 @@
+using System.Data;
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ ///
+ /// CommunicationTestTargets: scopes a communication test to specific groups, personnel roles
+ /// or individual users. A test with no rows keeps the original behavior and covers the whole
+ /// department. Cascades with the parent test so deleting a test removes its targeting.
+ ///
+ [Migration(118)]
+ public class M0118_AddCommunicationTestTargets : Migration
+ {
+ public override void Up()
+ {
+ Create.Table("CommunicationTestTargets")
+ .WithColumn("CommunicationTestTargetId").AsGuid().NotNullable().PrimaryKey().WithDefault(SystemMethods.NewGuid)
+ .WithColumn("CommunicationTestId").AsGuid().NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("TargetType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("TargetId").AsString(128).NotNullable();
+
+ Create.ForeignKey("FK_CommunicationTestTargets_CommunicationTests")
+ .FromTable("CommunicationTestTargets").ForeignColumn("CommunicationTestId")
+ .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId")
+ .OnDelete(Rule.Cascade);
+
+ Create.Index("IX_CommunicationTestTargets_CommunicationTestId")
+ .OnTable("CommunicationTestTargets")
+ .OnColumn("CommunicationTestId").Ascending();
+
+ Create.Index("IX_CommunicationTestTargets_DepartmentId")
+ .OnTable("CommunicationTestTargets")
+ .OnColumn("DepartmentId").Ascending();
+ }
+
+ public override void Down()
+ {
+ Delete.Table("CommunicationTestTargets");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0119_AddCommunicationTestRunAudience.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0119_AddCommunicationTestRunAudience.cs
new file mode 100644
index 000000000..073af088a
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0119_AddCommunicationTestRunAudience.cs
@@ -0,0 +1,31 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ ///
+ /// Snapshots the audience a communication test run covers onto the run itself. Targeting is
+ /// resolved when the run is started rather than when the worker builds it, so editing a test's
+ /// targets while its run sits on the queue can no longer change who that run tests. NULL means
+ /// the run predates the snapshot and the builder falls back to the test's current targeting.
+ ///
+ [Migration(119)]
+ public class M0119_AddCommunicationTestRunAudience : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("CommunicationTestRuns").Column("TargetedUserIds").Exists())
+ {
+ Alter.Table("CommunicationTestRuns")
+ .AddColumn("TargetedUserIds").AsString(int.MaxValue).Nullable();
+ }
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("CommunicationTestRuns").Column("TargetedUserIds").Exists())
+ {
+ Delete.Column("TargetedUserIds").FromTable("CommunicationTestRuns");
+ }
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0118_AddCommunicationTestTargetsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0118_AddCommunicationTestTargetsPg.cs
new file mode 100644
index 000000000..cd4557f44
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0118_AddCommunicationTestTargetsPg.cs
@@ -0,0 +1,45 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ ///
+ /// CommunicationTestTargets: scopes a communication test to specific groups, personnel roles
+ /// or individual users. A test with no rows keeps the original behavior and covers the whole
+ /// department. Cascades with the parent test so deleting a test removes its targeting.
+ /// Guid columns use citext to match the existing communication test tables (M0062).
+ ///
+ [Migration(118)]
+ public class M0118_AddCommunicationTestTargetsPg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("communicationtesttargets").Exists())
+ {
+ Create.Table("communicationtesttargets")
+ .WithColumn("communicationtesttargetid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("communicationtestid").AsCustom("citext").NotNullable()
+ .WithColumn("departmentid").AsInt32().NotNullable()
+ .WithColumn("targettype").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("targetid").AsCustom("citext").NotNullable();
+
+ Create.ForeignKey("fk_communicationtesttargets_communicationtests")
+ .FromTable("communicationtesttargets").ForeignColumn("communicationtestid")
+ .ToTable("communicationtests").PrimaryColumn("communicationtestid")
+ .OnDelete(System.Data.Rule.Cascade);
+
+ Create.Index("ix_communicationtesttargets_communicationtestid")
+ .OnTable("communicationtesttargets")
+ .OnColumn("communicationtestid");
+
+ Create.Index("ix_communicationtesttargets_departmentid")
+ .OnTable("communicationtesttargets")
+ .OnColumn("departmentid");
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Table("communicationtesttargets");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0119_AddCommunicationTestRunAudiencePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0119_AddCommunicationTestRunAudiencePg.cs
new file mode 100644
index 000000000..df8e0882e
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0119_AddCommunicationTestRunAudiencePg.cs
@@ -0,0 +1,33 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ ///
+ /// Snapshots the audience a communication test run covers onto the run itself. Targeting is
+ /// resolved when the run is started rather than when the worker builds it, so editing a test's
+ /// targets while its run sits on the queue can no longer change who that run tests. NULL means
+ /// the run predates the snapshot and the builder falls back to the test's current targeting.
+ /// citext to match the existing communication test tables (M0062), and unbounded so a large
+ /// targeted audience is not truncated.
+ ///
+ [Migration(119)]
+ public class M0119_AddCommunicationTestRunAudiencePg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("communicationtestruns").Column("targeteduserids").Exists())
+ {
+ Alter.Table("communicationtestruns")
+ .AddColumn("targeteduserids").AsCustom("citext").Nullable();
+ }
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("communicationtestruns").Column("targeteduserids").Exists())
+ {
+ Delete.Column("targeteduserids").FromTable("communicationtestruns");
+ }
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs b/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs
index b82e2f43c..e5b5b95dd 100644
--- a/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs
+++ b/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs
@@ -92,5 +92,32 @@ public async Task SendVoiceVerificationCallAsync(string phoneNumber, strin
return false;
}
}
+
+ public async Task SendCommunicationTestCallAsync(string phoneNumber, string responseToken)
+ {
+ if (string.IsNullOrWhiteSpace(phoneNumber) || string.IsNullOrWhiteSpace(responseToken))
+ return false;
+
+ try
+ {
+ TwilioClient.Init(Config.NumberProviderConfig.TwilioAccountSid, Config.NumberProviderConfig.TwilioAuthToken);
+
+ string fromNumber = Config.NumberProviderConfig.TwilioResgridNumber;
+ if (string.IsNullOrWhiteSpace(fromNumber))
+ return false;
+
+ var options = new CreateCallOptions(new PhoneNumber(phoneNumber), new PhoneNumber(fromNumber));
+ options.Url = new Uri(string.Format(Config.NumberProviderConfig.TwilioCommunicationTestApiUrl, responseToken));
+ options.Method = "GET";
+
+ var phoneCall = await CallResource.CreateAsync(options);
+ return phoneCall != null;
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ return false;
+ }
+ }
}
}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestTargetRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestTargetRepository.cs
new file mode 100644
index 000000000..90a43823d
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/CommunicationTestTargetRepository.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 CommunicationTestTargetRepository : RepositoryBase, ICommunicationTestTargetRepository
+ {
+ private readonly IConnectionProvider _connectionProvider;
+ private readonly SqlConfiguration _sqlConfiguration;
+ private readonly IQueryFactory _queryFactory;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public CommunicationTestTargetRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ _connectionProvider = connectionProvider;
+ _sqlConfiguration = sqlConfiguration;
+ _queryFactory = queryFactory;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> GetTargetsByTestIdAsync(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;
+ }
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
index 6322126e1..aa25e040e 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs
@@ -584,7 +584,9 @@ protected SqlConfiguration() { }
public string CommunicationTestsTable { get; set; }
public string CommunicationTestRunsTable { get; set; }
public string CommunicationTestResultsTable { get; set; }
+ public string CommunicationTestTargetsTable { get; set; }
public string SelectActiveCommTestsByScheduleTypeQuery { get; set; }
+ public string SelectCommTestTargetsByTestIdQuery { get; set; }
public string SelectCommTestRunsByTestIdQuery { get; set; }
public string SelectCommTestRunByRunCodeQuery { get; set; }
public string SelectOpenCommTestRunsQuery { get; set; }
diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
index 0271d3265..b2d5b2666 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
@@ -208,6 +208,7 @@ DELETE FROM [dbo].[UdfFields] WHERE UdfDefinitionId IN (SELECT UdfDefinitionId F
-- Communication tests
DELETE FROM [dbo].[CommunicationTestResults] WHERE DepartmentId = @DepartmentId
DELETE FROM [dbo].[CommunicationTestRuns] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[CommunicationTestTargets] WHERE DepartmentId = @DepartmentId
DELETE FROM [dbo].[CommunicationTests] WHERE DepartmentId = @DepartmentId
-- Chatbot
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
index ab8a34a24..148f458c7 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
@@ -170,6 +170,7 @@ 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();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index b454f2188..fc05cd518 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -182,6 +182,7 @@ 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();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
index fe6c681c6..43eda2744 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
@@ -157,6 +157,7 @@ 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();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
index 520192505..74329c673 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
@@ -168,6 +168,7 @@ 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();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestTargetsByTestIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestTargetsByTestIdQuery.cs
new file mode 100644
index 000000000..fdfc128e4
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/CommunicationTests/SelectCommTestTargetsByTestIdQuery.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 SelectCommTestTargetsByTestIdQuery : ISelectQuery
+ {
+ private readonly SqlConfiguration _sqlConfiguration;
+ public SelectCommTestTargetsByTestIdQuery(SqlConfiguration sqlConfiguration)
+ {
+ _sqlConfiguration = sqlConfiguration;
+ }
+
+ public string GetQuery()
+ {
+ var query = _sqlConfiguration.SelectCommTestTargetsByTestIdQuery
+ .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName,
+ _sqlConfiguration.CommunicationTestTargetsTable,
+ _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/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
index 4891d7621..a53a7c0f3 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs
@@ -1850,7 +1850,9 @@ SELECT r.*
CommunicationTestsTable = "CommunicationTests";
CommunicationTestRunsTable = "CommunicationTestRuns";
CommunicationTestResultsTable = "CommunicationTestResults";
+ CommunicationTestTargetsTable = "CommunicationTestTargets";
SelectActiveCommTestsByScheduleTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Active = true AND ScheduleType = %SCHEDULETYPE%";
+ SelectCommTestTargetsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestId = %COMMTESTID%";
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)";
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
index 451f75205..f627fb6ad 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs
@@ -1800,7 +1800,9 @@ SELECT TOP 1 *
CommunicationTestsTable = "CommunicationTests";
CommunicationTestRunsTable = "CommunicationTestRuns";
CommunicationTestResultsTable = "CommunicationTestResults";
+ CommunicationTestTargetsTable = "CommunicationTestTargets";
SelectActiveCommTestsByScheduleTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [Active] = 1 AND [ScheduleType] = %SCHEDULETYPE%";
+ SelectCommTestTargetsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommunicationTestId] = %COMMTESTID%";
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)";
diff --git a/Tests/Resgrid.Tests/Localization/CommunicationTestMessageLocalizationTests.cs b/Tests/Resgrid.Tests/Localization/CommunicationTestMessageLocalizationTests.cs
new file mode 100644
index 000000000..8861938d2
--- /dev/null
+++ b/Tests/Resgrid.Tests/Localization/CommunicationTestMessageLocalizationTests.cs
@@ -0,0 +1,115 @@
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Localization;
+using Resgrid.Localization.Areas.User.CommunicationTest;
+
+namespace Resgrid.Tests.Localization
+{
+ ///
+ /// A communication test proves a department can reach its people. A member whose profile is set
+ /// to German being sent an English message is only half a proof — they may receive it and still
+ /// not act on it. These pin that every channel renders in the recipient's culture, and that an
+ /// unknown or missing culture degrades to English rather than to a key name.
+ ///
+ [TestFixture]
+ public class CommunicationTestMessageLocalizationTests
+ {
+ [Test]
+ public void every_supported_culture_should_have_its_own_wording_for_each_channel()
+ {
+ var english = new
+ {
+ Sms = CommunicationTestMessageCatalog.BuildSmsBody("Monthly Check", "CT-A7X3", "en"),
+ Subject = CommunicationTestMessageCatalog.BuildEmailSubject("Monthly Check", "en"),
+ Body = CommunicationTestMessageCatalog.BuildEmailBody("Alex", "Station 1", "Monthly Check", "https://x/y", "en"),
+ PushTitle = CommunicationTestMessageCatalog.BuildPushTitle("en"),
+ PushBody = CommunicationTestMessageCatalog.BuildPushBody("Monthly Check", "en"),
+ Voice = CommunicationTestMessageCatalog.GetVoicePrompts("en")[0]
+ };
+
+ foreach (var culture in SupportedLocales.GetSupportedCultures())
+ {
+ if (culture == "en")
+ continue;
+
+ CommunicationTestMessageCatalog.BuildSmsBody("Monthly Check", "CT-A7X3", culture)
+ .Should().NotBe(english.Sms, $"SMS should be translated for {culture}");
+ CommunicationTestMessageCatalog.BuildEmailSubject("Monthly Check", culture)
+ .Should().NotBe(english.Subject, $"email subject should be translated for {culture}");
+ CommunicationTestMessageCatalog.BuildEmailBody("Alex", "Station 1", "Monthly Check", "https://x/y", culture)
+ .Should().NotBe(english.Body, $"email body should be translated for {culture}");
+ CommunicationTestMessageCatalog.BuildPushTitle(culture)
+ .Should().NotBe(english.PushTitle, $"push title should be translated for {culture}");
+ CommunicationTestMessageCatalog.BuildPushBody("Monthly Check", culture)
+ .Should().NotBe(english.PushBody, $"push body should be translated for {culture}");
+ CommunicationTestMessageCatalog.GetVoicePrompts(culture)[0]
+ .Should().NotBe(english.Voice, $"voice prompt should be translated for {culture}");
+ }
+ }
+
+ [Test]
+ public void every_supported_culture_should_keep_the_run_code_and_confirm_link_intact()
+ {
+ foreach (var culture in SupportedLocales.GetSupportedCultures())
+ {
+ // A translation that drops a placeholder leaves the recipient with no way to answer.
+ CommunicationTestMessageCatalog.BuildSmsBody("Monthly Check", "CT-A7X3", culture)
+ .Should().Contain("CT-A7X3", $"the run code must survive translation for {culture}");
+ CommunicationTestMessageCatalog.BuildSmsBody("Monthly Check", "CT-A7X3", culture)
+ .Should().Contain("Monthly Check", $"the test name must survive translation for {culture}");
+
+ CommunicationTestMessageCatalog.BuildEmailBody("Alex", "Station 1", "Monthly Check", "https://confirm/link", culture)
+ .Should().Contain("https://confirm/link", $"the confirm link must survive translation for {culture}");
+ CommunicationTestMessageCatalog.BuildEmailSubject("Monthly Check", culture)
+ .Should().Contain("Monthly Check", $"the test name must survive translation for {culture}");
+
+ CommunicationTestMessageCatalog.BuildPushBody("Monthly Check", culture)
+ .Should().Contain("Monthly Check", $"the test name must survive translation for {culture}");
+ }
+ }
+
+ [Test]
+ public void an_unset_or_unknown_culture_should_fall_back_to_english()
+ {
+ var english = CommunicationTestMessageCatalog.BuildPushTitle("en");
+
+ CommunicationTestMessageCatalog.BuildPushTitle(null).Should().Be(english);
+ CommunicationTestMessageCatalog.BuildPushTitle("").Should().Be(english);
+ CommunicationTestMessageCatalog.BuildPushTitle("zz").Should().Be(english);
+ CommunicationTestMessageCatalog.BuildPushTitle("klingon").Should().Be(english);
+ }
+
+ [Test]
+ public void a_regional_culture_should_resolve_to_its_base_language()
+ {
+ CommunicationTestMessageCatalog.BuildPushTitle("de-DE")
+ .Should().Be(CommunicationTestMessageCatalog.BuildPushTitle("de"));
+ CommunicationTestMessageCatalog.BuildPushTitle("es_MX")
+ .Should().Be(CommunicationTestMessageCatalog.BuildPushTitle("es"));
+ }
+
+ [Test]
+ public void an_unnamed_test_should_not_send_empty_brackets()
+ {
+ foreach (var culture in SupportedLocales.GetSupportedCultures())
+ {
+ var body = CommunicationTestMessageCatalog.BuildSmsBody(null, "CT-A7X3", culture);
+
+ body.Should().NotContain("()", $"an unnamed test must not read as empty brackets for {culture}");
+ body.Should().Contain("CT-A7X3");
+ }
+ }
+
+ [Test]
+ public void resource_lookups_should_never_surface_a_raw_key_name()
+ {
+ foreach (var culture in SupportedLocales.GetSupportedCultures())
+ {
+ CommunicationTestResources.Get("MessagePushTitle", culture).Should().NotBe("MessagePushTitle");
+ CommunicationTestResources.Get("MessageVoiceRecorded", culture).Should().NotBe("MessageVoiceRecorded");
+ CommunicationTestResources.Get("MessageVoiceNoResponse", culture).Should().NotBe("MessageVoiceNoResponse");
+ CommunicationTestResources.Get("MessageSmsBodyNoName", culture).Should().NotBe("MessageSmsBodyNoName");
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs
new file mode 100644
index 000000000..69bd27ccf
--- /dev/null
+++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Resgrid.Tests.Localization
+{
+ ///
+ /// Guards the resource areas that are fully translated so they stay that way.
+ ///
+ /// The wider project carries a large translation debt (see Documentation/translation-audit.md):
+ /// most areas still hold English text under translated key names, and 22 areas were never
+ /// populated at all. That backlog is deliberately NOT asserted here — a test that fails from
+ /// day one gets muted, and a muted test guards nothing. Instead this pins the areas that are
+ /// currently complete, so new work cannot quietly add English placeholders to them.
+ ///
+ ///
+ /// When an area is brought up to full translation, add it to .
+ ///
+ ///
+ [TestFixture]
+ public class TranslationCompletenessTests
+ {
+ private static readonly string[] Languages = { "de", "es", "fr", "it", "pl", "sv", "uk", "el", "ar" };
+
+ private static readonly string[] GuardedAreas =
+ {
+ "Areas/User/CommunicationTest/CommunicationTest",
+ "Areas/User/SystemMessages/SystemMessages",
+ };
+
+ private static string LocalizationRoot()
+ {
+ // Walk up from the test bin directory to the repository root.
+ var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
+ while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln")))
+ directory = directory.Parent;
+
+ directory.Should().NotBeNull("the repository root should be locatable from the test directory");
+ return Path.Combine(directory!.FullName, "Core", "Resgrid.Localization");
+ }
+
+ private static Dictionary Load(string path)
+ {
+ return XDocument.Load(path)
+ .Root!
+ .Elements("data")
+ .ToDictionary(
+ x => (string)x.Attribute("name")!,
+ x => (string)x.Element("value") ?? string.Empty);
+ }
+
+ ///
+ /// Key/language pairs whose translation is genuinely the same word as English — "Status" in
+ /// German, "Description" in French, "min" as the abbreviation for minutes. Listed explicitly
+ /// rather than guessed at by a length or casing heuristic: a heuristic loose enough to cover
+ /// "Description" would also wave through a real missed translation of the same length.
+ /// Every entry here was checked by reading the two values side by side.
+ ///
+ private static readonly HashSet KnownIdentical = new HashSet(StringComparer.Ordinal)
+ {
+ // Brand and protocol names carry across every language.
+ "CommunicationTest|de|Push", "CommunicationTest|de|SMS",
+ "CommunicationTest|es|Push", "CommunicationTest|es|SMS",
+ "CommunicationTest|fr|Push", "CommunicationTest|fr|SMS",
+ "CommunicationTest|it|Push", "CommunicationTest|it|SMS",
+ "CommunicationTest|pl|Push", "CommunicationTest|pl|SMS",
+ "CommunicationTest|sv|Push", "CommunicationTest|sv|SMS",
+ "CommunicationTest|uk|Push", "CommunicationTest|uk|SMS",
+ "CommunicationTest|el|Push", "CommunicationTest|el|SMS",
+ "CommunicationTest|ar|Push",
+
+ // Words that happen to be spelled the same in that language.
+ "CommunicationTest|de|Status", // Status
+ "CommunicationTest|de|TestName", // Name
+ "CommunicationTest|pl|Status", // Status
+ "CommunicationTest|sv|Status", // Status
+ "CommunicationTest|es|Roles", // Roles
+ "CommunicationTest|fr|Contact", // Contact
+ "CommunicationTest|fr|TestDescription", // Description
+ "CommunicationTest|it|Email", "CommunicationTest|el|Email",
+ "CommunicationTest|it|Home", // Home
+ "SystemMessages|fr|NotificationEmailSubject", // Notification
+
+ // "min" is the standard abbreviation for minutes in these languages.
+ "CommunicationTest|es|MinutesShort", "CommunicationTest|fr|MinutesShort",
+ "CommunicationTest|it|MinutesShort", "CommunicationTest|pl|MinutesShort",
+ "CommunicationTest|sv|MinutesShort",
+
+ // Counted phrases whose wording matches English.
+ "CommunicationTest|es|ScopeRoles", // {0} roles
+ "CommunicationTest|sv|ScopePerson", // 1 person
+ };
+
+ [Test]
+ public void guarded_areas_should_have_a_resource_file_for_every_supported_language()
+ {
+ var root = LocalizationRoot();
+
+ foreach (var area in GuardedAreas)
+ {
+ foreach (var language in Languages)
+ {
+ var path = Path.Combine(root, $"{area}.{language}.resx".Replace('/', Path.DirectorySeparatorChar));
+ File.Exists(path).Should().BeTrue($"{area} should ship a {language} resource file");
+ }
+ }
+ }
+
+ [Test]
+ public void guarded_areas_should_have_every_english_key_in_every_language()
+ {
+ var root = LocalizationRoot();
+
+ foreach (var area in GuardedAreas)
+ {
+ var english = Load(Path.Combine(root, $"{area}.en.resx".Replace('/', Path.DirectorySeparatorChar)));
+
+ foreach (var language in Languages)
+ {
+ var translated = Load(Path.Combine(root, $"{area}.{language}.resx".Replace('/', Path.DirectorySeparatorChar)));
+ var missing = english.Keys.Where(k => !translated.ContainsKey(k)).ToList();
+
+ missing.Should().BeEmpty($"{area}.{language} is missing keys present in English");
+ }
+ }
+ }
+
+ [Test]
+ public void guarded_areas_should_not_contain_english_placeholders()
+ {
+ var root = LocalizationRoot();
+
+ foreach (var area in GuardedAreas)
+ {
+ var english = Load(Path.Combine(root, $"{area}.en.resx".Replace('/', Path.DirectorySeparatorChar)));
+
+ var areaName = area.Substring(area.LastIndexOf('/') + 1);
+
+ foreach (var language in Languages)
+ {
+ var translated = Load(Path.Combine(root, $"{area}.{language}.resx".Replace('/', Path.DirectorySeparatorChar)));
+
+ var untranslated = english
+ .Where(pair => translated.TryGetValue(pair.Key, out var value)
+ && value == pair.Value
+ && !KnownIdentical.Contains($"{areaName}|{language}|{pair.Key}"))
+ .Select(pair => pair.Key)
+ .ToList();
+
+ untranslated.Should().BeEmpty(
+ $"{area}.{language} still holds the English text for these keys — a member reading " +
+ "this locale would be shown English");
+ }
+ }
+ }
+
+ [Test]
+ public void guarded_areas_should_never_be_left_as_the_visual_studio_starter_template()
+ {
+ var root = LocalizationRoot();
+
+ foreach (var area in GuardedAreas)
+ {
+ foreach (var language in Languages)
+ {
+ var translated = Load(Path.Combine(root, $"{area}.{language}.resx".Replace('/', Path.DirectorySeparatorChar)));
+
+ // An unpopulated resx keeps the template's sample entries. 22 areas in this repo
+ // are in exactly that state, which is how the debt went unnoticed for so long.
+ translated.ContainsKey("Name1").Should().BeFalse($"{area}.{language} was never populated");
+ translated.ContainsKey("Bitmap1").Should().BeFalse($"{area}.{language} was never populated");
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Models/CommunicationTestResultExtensionsTests.cs b/Tests/Resgrid.Tests/Models/CommunicationTestResultExtensionsTests.cs
new file mode 100644
index 000000000..7c40e9967
--- /dev/null
+++ b/Tests/Resgrid.Tests/Models/CommunicationTestResultExtensionsTests.cs
@@ -0,0 +1,82 @@
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Model;
+
+namespace Resgrid.Tests.Models
+{
+ ///
+ /// The verification wording is what an administrator acts on when reading a communication test
+ /// report ("who can we actually reach?"), and the same labels are served to the apps through the
+ /// v4 API. Pin them so a rename cannot quietly change what the report claims.
+ ///
+ [TestFixture]
+ public class CommunicationTestResultExtensionsTests
+ {
+ private static CommunicationTestResult Result(CommunicationTestChannel channel, ContactVerificationStatus status)
+ {
+ return new CommunicationTestResult
+ {
+ Channel = (int)channel,
+ VerificationStatus = (int)status
+ };
+ }
+
+ [Test]
+ public void verified_channel_should_read_as_verified()
+ {
+ Result(CommunicationTestChannel.Sms, ContactVerificationStatus.Verified)
+ .GetVerificationDisplayText().Should().Be("Verified");
+ }
+
+ [Test]
+ public void pending_channel_should_read_as_unverified_not_pending()
+ {
+ // "Pending" describes the code lifecycle; the administrator needs to know the channel is
+ // unverified and will not be sent to.
+ Result(CommunicationTestChannel.Email, ContactVerificationStatus.Pending)
+ .GetVerificationDisplayText().Should().Be("Unverified");
+ }
+
+ [Test]
+ public void grandfathered_channel_should_read_as_grandfathered()
+ {
+ Result(CommunicationTestChannel.Voice, ContactVerificationStatus.Grandfathered)
+ .GetVerificationDisplayText().Should().Be("Grandfathered");
+ }
+
+ [Test]
+ public void push_should_report_not_applicable_regardless_of_stored_status()
+ {
+ // Push rows are stored as Verified because there is nothing to verify; surfacing that
+ // word would tell the reader a device is confirmed when no such check exists.
+ Result(CommunicationTestChannel.Push, ContactVerificationStatus.Verified)
+ .GetVerificationDisplayText().Should().Be("N/A");
+
+ Result(CommunicationTestChannel.Push, ContactVerificationStatus.Verified)
+ .HasVerifiableContactMethod().Should().BeFalse();
+ }
+
+ [Test]
+ public void contact_channels_should_count_as_verifiable()
+ {
+ Result(CommunicationTestChannel.Sms, ContactVerificationStatus.Verified).HasVerifiableContactMethod().Should().BeTrue();
+ Result(CommunicationTestChannel.Email, ContactVerificationStatus.Verified).HasVerifiableContactMethod().Should().BeTrue();
+ Result(CommunicationTestChannel.Voice, ContactVerificationStatus.Verified).HasVerifiableContactMethod().Should().BeTrue();
+ }
+
+ [Test]
+ public void missing_result_should_render_a_dash()
+ {
+ CommunicationTestResult missing = null;
+ missing.GetVerificationDisplayText().Should().Be("-");
+ }
+
+ [Test]
+ public void verification_status_labels_should_match_the_report_legend()
+ {
+ ContactVerificationStatus.Verified.ToDisplayText().Should().Be("Verified");
+ ContactVerificationStatus.Pending.ToDisplayText().Should().Be("Unverified");
+ ContactVerificationStatus.Grandfathered.ToDisplayText().Should().Be("Grandfathered");
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs
index d7fe69ad5..68f2a5b28 100644
--- a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -6,9 +6,14 @@
using FluentAssertions;
using Moq;
using NUnit.Framework;
+using Resgrid.Config;
using Resgrid.Framework.Testing;
using Resgrid.Model;
+using Resgrid.Model.Messages;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Queue;
using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
using Resgrid.Model.Services;
using Resgrid.Services;
@@ -21,11 +26,77 @@ public class with_the_communication_test_service : TestBase
protected Mock _communicationTestRepoMock;
protected Mock _communicationTestRunRepoMock;
protected Mock _communicationTestResultRepoMock;
+ protected Mock _communicationTestTargetRepoMock;
protected Mock _departmentsServiceMock;
protected Mock _userProfileServiceMock;
+ protected Mock _departmentGroupsServiceMock;
+ protected Mock _personnelRolesServiceMock;
+ protected Mock _departmentSettingsServiceMock;
+ protected Mock _smsServiceMock;
+ protected Mock _emailServiceMock;
+ protected Mock _pushServiceMock;
+ protected Mock _outboundVoiceProviderMock;
+ protected Mock _phoneNumberProcesserMock;
+ protected Mock _queueServiceMock;
+ protected Mock _unitOfWorkMock;
+
+ protected Dictionary _savedRuns;
+ protected List _savedResults;
protected ICommunicationTestService _communicationTestService;
+ ///
+ /// Makes the run and result repositories behave like a store instead of a stub, which the
+ /// two-phase (start → queue → build → deliver) flow needs: BuildRunResultsAsync re-reads the
+ /// run the start call saved, and its "already built" guard reads back saved results.
+ ///
+ protected void SetupRunAndResultPersistence()
+ {
+ _savedRuns = new Dictionary();
+ _savedResults = new List();
+
+ _communicationTestRunRepoMock
+ .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true))
+ .ReturnsAsync((CommunicationTestRun r, CancellationToken c, bool f) =>
+ {
+ if (r.CommunicationTestRunId == Guid.Empty)
+ r.CommunicationTestRunId = Guid.NewGuid();
+
+ _savedRuns[r.CommunicationTestRunId] = r;
+ return r;
+ });
+
+ _communicationTestRunRepoMock
+ .Setup(x => x.GetByIdAsync(It.IsAny()))
+ .ReturnsAsync((Guid id) => _savedRuns.TryGetValue(id, out var run) ? run : null);
+
+ _communicationTestResultRepoMock
+ .Setup(x => x.GetResultsByRunIdAsync(It.IsAny()))
+ .ReturnsAsync((Guid id) => _savedResults.Where(r => r.CommunicationTestRunId == id).ToList());
+
+ _communicationTestResultRepoMock
+ .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true))
+ .Callback((r, c, f) =>
+ {
+ if (!_savedResults.Contains(r))
+ _savedResults.Add(r);
+ })
+ .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r);
+ }
+
+ ///
+ /// Starts a run the way a caller does, then runs the worker-side build step, and returns the
+ /// built run. Delivery is deliberately left out so audience tests stay off the send path.
+ ///
+ protected async Task StartAndBuildAsync(Guid testId, int departmentId, string userId)
+ {
+ var run = await _communicationTestService.StartTestRunAsync(testId, departmentId, userId);
+ if (run == null)
+ return null;
+
+ return await _communicationTestService.BuildRunResultsAsync(run.CommunicationTestRunId);
+ }
+
protected override void Before_all_tests()
{
base.Before_all_tests();
@@ -33,15 +104,46 @@ protected override void Before_all_tests()
_communicationTestRepoMock = new Mock();
_communicationTestRunRepoMock = new Mock();
_communicationTestResultRepoMock = new Mock();
+ _communicationTestTargetRepoMock = new Mock();
_departmentsServiceMock = new Mock();
_userProfileServiceMock = new Mock();
+ _departmentGroupsServiceMock = new Mock();
+ _personnelRolesServiceMock = new Mock();
+ _departmentSettingsServiceMock = new Mock();
+ _smsServiceMock = new Mock();
+ _emailServiceMock = new Mock();
+ _pushServiceMock = new Mock();
+ _outboundVoiceProviderMock = new Mock();
+ _phoneNumberProcesserMock = new Mock();
+ _queueServiceMock = new Mock();
+ _unitOfWorkMock = new Mock();
+
+ _savedRuns = new Dictionary();
+ _savedResults = new List();
+
+ _communicationTestTargetRepoMock.Setup(x => x.GetTargetsByTestIdAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+
+ _queueServiceMock.Setup(x => x.EnqueueCommunicationTestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
_communicationTestService = new CommunicationTestService(
_communicationTestRepoMock.Object,
_communicationTestRunRepoMock.Object,
_communicationTestResultRepoMock.Object,
+ _communicationTestTargetRepoMock.Object,
_departmentsServiceMock.Object,
- _userProfileServiceMock.Object
+ _userProfileServiceMock.Object,
+ _departmentGroupsServiceMock.Object,
+ _personnelRolesServiceMock.Object,
+ _departmentSettingsServiceMock.Object,
+ _smsServiceMock.Object,
+ _emailServiceMock.Object,
+ _pushServiceMock.Object,
+ _outboundVoiceProviderMock.Object,
+ _phoneNumberProcesserMock.Object,
+ _queueServiceMock.Object,
+ _unitOfWorkMock.Object
);
}
}
@@ -81,19 +183,9 @@ public async Task should_create_results_per_user_per_channel()
};
_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);
+ SetupRunAndResultPersistence();
- var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id);
+ var run = await StartAndBuildAsync(testId, 1, TestData.Users.TestUser1Id);
run.Should().NotBeNull();
run.TotalUsersTested.Should().Be(2);
@@ -105,6 +197,101 @@ public async Task should_create_results_per_user_per_channel()
Times.Exactly(6));
}
+ [Test]
+ public async Task should_hand_the_run_to_the_worker_instead_of_building_or_sending_inline()
+ {
+ var testId = Guid.NewGuid();
+ _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest
+ {
+ CommunicationTestId = testId,
+ DepartmentId = 1,
+ TestEmail = true,
+ TestSms = true,
+ ResponseWindowMinutes = 60,
+ Active = true
+ });
+
+ SetupRunAndResultPersistence();
+
+ CommunicationTestQueueItem queued = null;
+ _queueServiceMock
+ .Setup(x => x.EnqueueCommunicationTestAsync(It.IsAny(), It.IsAny()))
+ .Callback((i, c) => queued = i)
+ .ReturnsAsync(true);
+
+ var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id);
+
+ run.Should().NotBeNull();
+ run.Status.Should().Be((int)CommunicationTestRunStatus.Pending);
+
+ queued.Should().NotBeNull();
+ queued.CommunicationTestRunId.Should().Be(run.CommunicationTestRunId.ToString());
+ queued.CommunicationTestId.Should().Be(testId.ToString());
+ queued.DepartmentId.Should().Be(1);
+
+ // Nothing touched the department or the providers on the caller's thread.
+ _departmentsServiceMock.Verify(x => x.GetAllMembersForDepartmentAsync(It.IsAny()), Times.Never);
+ _communicationTestResultRepoMock.Verify(
+ x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), true),
+ Times.Never);
+ }
+
+ [Test]
+ public async Task should_leave_the_run_pending_when_the_broker_publish_throws()
+ {
+ var testId = Guid.NewGuid();
+ _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest
+ {
+ CommunicationTestId = testId,
+ DepartmentId = 1,
+ TestEmail = true,
+ ResponseWindowMinutes = 60,
+ Active = true
+ });
+
+ SetupRunAndResultPersistence();
+
+ _queueServiceMock.Setup(x => x.EnqueueCommunicationTestAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("broker down"));
+
+ // The run must survive a broker outage so the recovery sweep can pick it up.
+ var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id);
+
+ run.Should().NotBeNull();
+ run.Status.Should().Be((int)CommunicationTestRunStatus.Pending);
+ }
+
+ [Test]
+ public async Task should_not_rebuild_results_for_a_run_that_already_has_them()
+ {
+ var testId = Guid.NewGuid();
+ _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest
+ {
+ CommunicationTestId = testId,
+ DepartmentId = 1,
+ TestEmail = true,
+ ResponseWindowMinutes = 60,
+ Active = true
+ });
+
+ _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(new List
+ {
+ new DepartmentMember { UserId = TestData.Users.TestUser1Id, DepartmentId = 1 }
+ });
+ _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(new Dictionary());
+
+ SetupRunAndResultPersistence();
+
+ var run = await _communicationTestService.StartTestRunAsync(testId, 1, TestData.Users.TestUser1Id);
+
+ // At-least-once delivery: the same queue item arriving twice must not double the audience.
+ await _communicationTestService.BuildRunResultsAsync(run.CommunicationTestRunId);
+ await _communicationTestService.BuildRunResultsAsync(run.CommunicationTestRunId);
+
+ _savedResults.Count.Should().Be(1);
+ }
+
+
[Test]
public async Task should_block_send_for_pending_verification()
{
@@ -135,22 +322,11 @@ public async Task should_block_send_for_pending_verification()
};
_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