diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs
index 0f712dafe..35483d288 100644
--- a/Core/Resgrid.Config/ServiceBusConfig.cs
+++ b/Core/Resgrid.Config/ServiceBusConfig.cs
@@ -58,6 +58,14 @@ public static class ServiceBusConfig
public static string RabbitUsername = "";
public static string RabbbitPassword = "";
public static string RabbbitExchange = "";
+
+ ///
+ /// Ceiling for a single serialized message body, in bytes. Sits under the broker's
+ /// 16MB (16777216) max frame size with headroom for AMQP framing overhead. A publish
+ /// over the broker limit isn't rejected cleanly, it closes the channel with a
+ /// PRECONDITION_FAILED and takes the connection's in-flight work with it.
+ ///
+ public static int MaxMessageSizeInBytes = 15 * 1024 * 1024;
#endregion RabbitMQ 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 56007d68d..7e01f3d9b 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx
@@ -205,26 +205,38 @@
تلقّى Resgrid ردك على اختبار الاتصال. شكرًا لك.
-
- مرحبًا {0}،
-
-
-
-تُجري {1} اختبار اتصال ({2}) للتأكد من إمكانية الوصول إليك.
-
-
-
-هذا اختبار. لا توجد حالة طوارئ ولا يلزم اتخاذ أي إجراء سوى تأكيد استلامك لهذا البريد الإلكتروني.
-
-
-
-يرجى النقر على الرابط أدناه لتأكيد استلام هذه الرسالة:
-
-{3}
-
-
-
-فريق Resgrid
+
+ اختبار اتصال
+
+
+ مرحبًا {0}،
+
+
+ تُجري {0} اختبار اتصال ({1}) للتأكد من إمكانية الوصول إليك.
+
+
+ هذا اختبار. لا توجد حالة طوارئ ولا يلزم اتخاذ أي إجراء سوى تأكيد استلامك لهذا البريد الإلكتروني.
+
+
+ يرجى تأكيد استلامك لهذه الرسالة.
+
+
+ تأكيد الاستلام
+
+
+ إذا لم يعمل الزر أعلاه، فانسخ الرابط أدناه والصقه في متصفح الويب لديك.
+
+
+ شكرًا،
+
+
+ فريق Resgrid
+
+
+ القسم:
+
+
+ اسم الاختبار:اختبار اتصال Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
index 2500155b7..3944a13e2 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx
@@ -205,26 +205,38 @@
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
+
+ Kommunikationstest
+
+
+ Hallo {0},
+
+
+ {0} führt einen Kommunikationstest ({1}) 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 bestätigen Sie den Empfang dieser Nachricht.
+
+
+ Empfang bestätigen
+
+
+ Falls die Schaltfläche oben nicht funktioniert, kopieren Sie die folgende URL in Ihren Webbrowser.
+
+
+ Danke,
+
+
+ Ihr Resgrid-Team
+
+
+ Abteilung:
+
+
+ Testname:Resgrid-Kommunikationstest: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
index 63ec6d097..f32f0ff72 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx
@@ -205,26 +205,38 @@
Το Resgrid έλαβε την απάντησή σας στη δοκιμή επικοινωνίας. Ευχαριστούμε.
-
- Γεια σας {0},
-
-
-
-Η υπηρεσία {1} εκτελεί δοκιμή επικοινωνίας ({2}) για να επιβεβαιώσει ότι μπορεί να επικοινωνήσει μαζί σας.
-
-
-
-Πρόκειται για ΔΟΚΙΜΗ. Δεν υπάρχει έκτακτη ανάγκη και δεν απαιτείται καμία ενέργεια πέραν της επιβεβαίωσης λήψης αυτού του email.
-
-
-
-Κάντε κλικ στον παρακάτω σύνδεσμο για να επιβεβαιώσετε τη λήψη αυτού του μηνύματος:
-
-{3}
-
-
-
-Η ομάδα του Resgrid
+
+ Δοκιμή επικοινωνίας
+
+
+ Γεια σας {0},
+
+
+ Η υπηρεσία {0} εκτελεί δοκιμή επικοινωνίας ({1}) για να επιβεβαιώσει ότι μπορεί να επικοινωνήσει μαζί σας.
+
+
+ Πρόκειται για ΔΟΚΙΜΗ. Δεν υπάρχει έκτακτη ανάγκη και δεν απαιτείται καμία ενέργεια πέραν της επιβεβαίωσης λήψης αυτού του email.
+
+
+ Επιβεβαιώστε ότι λάβατε αυτό το μήνυμα.
+
+
+ Επιβεβαίωση λήψης
+
+
+ Αν το παραπάνω κουμπί δεν λειτουργεί, αντιγράψτε και επικολλήστε την παρακάτω διεύθυνση URL στο πρόγραμμα περιήγησής σας.
+
+
+ Ευχαριστούμε,
+
+
+ Η ομάδα του Resgrid
+
+
+ Τμήμα:
+
+
+ Όνομα δοκιμής:Δοκιμή επικοινωνίας Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
index 76b40bd50..7d3dde486 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx
@@ -205,26 +205,38 @@
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
+
+ Communication Test
+
+
+ Hi {0},
+
+
+ {0} is running a communication test ({1}) 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 confirm you received this message.
+
+
+ Confirm I Got This
+
+
+ If you're having trouble with the button above, copy and paste the URL below into your web browser.
+
+
+ Thanks,
+
+
+ The Resgrid Team
+
+
+ Department:
+
+
+ Test Name:Resgrid Communication Test: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
index a09cacf0b..e609226d6 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx
@@ -205,26 +205,38 @@
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
+
+
+ Hola {0}:
+
+
+ {0} está realizando una prueba de comunicación ({1}) 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.
+
+
+ Confirme que recibió este mensaje.
+
+
+ Confirmar recepción
+
+
+ Si tiene problemas con el botón anterior, copie y pegue la siguiente URL en su navegador.
+
+
+ Gracias,
+
+
+ El equipo de Resgrid
+
+
+ Departamento:
+
+
+ Nombre de la prueba:Prueba de comunicación de Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
index dd737e15a..a14d368aa 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx
@@ -205,26 +205,38 @@
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
+
+
+ Bonjour {0},
+
+
+ {0} effectue un test de communication ({1}) 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 confirmer la réception de ce message.
+
+
+ Confirmer la réception
+
+
+ Si le bouton ci-dessus ne fonctionne pas, copiez et collez l'URL ci-dessous dans votre navigateur.
+
+
+ Merci,
+
+
+ L'équipe Resgrid
+
+
+ Département :
+
+
+ Nom du test :Test de communication Resgrid : {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
index 964f5988c..894ee635a 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx
@@ -205,26 +205,38 @@
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
+
+
+ Ciao {0},
+
+
+ {0} sta effettuando un test di comunicazione ({1}) 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.
+
+
+ Conferma di aver ricevuto questo messaggio.
+
+
+ Conferma ricezione
+
+
+ Se il pulsante qui sopra non funziona, copia e incolla l'URL seguente nel tuo browser.
+
+
+ Grazie,
+
+
+ Il team Resgrid
+
+
+ Dipartimento:
+
+
+ Nome del test:Test di comunicazione Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
index 8fc237823..69fa65485 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx
@@ -205,26 +205,38 @@
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
+
+
+ Cześć {0},
+
+
+ {0} przeprowadza test łączności ({1}), 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.
+
+
+ Potwierdź odbiór tej wiadomości.
+
+
+ Potwierdź odbiór
+
+
+ Jeśli powyższy przycisk nie działa, skopiuj i wklej poniższy adres URL do przeglądarki.
+
+
+ Dziękujemy,
+
+
+ Zespół Resgrid
+
+
+ Oddział:
+
+
+ Nazwa testu:Test łączności Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
index 0ff61d98d..9b09d026f 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx
@@ -205,26 +205,38 @@
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
+
+ Kommunikationstest
+
+
+ Hej {0},
+
+
+ {0} genomför ett kommunikationstest ({1}) 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.
+
+
+ Bekräfta att du fått detta meddelande.
+
+
+ Bekräfta mottagande
+
+
+ Om knappen ovan inte fungerar, kopiera och klistra in webbadressen nedan i din webbläsare.
+
+
+ Tack,
+
+
+ Resgrid-teamet
+
+
+ Avdelning:
+
+
+ Testnamn:Resgrid kommunikationstest: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
index dc2f79f41..93bf8d1f9 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx
@@ -205,26 +205,38 @@
Resgrid отримав вашу відповідь на тест зв’язку. Дякуємо.
-
- Вітаємо, {0}!
-
-
-
-{1} проводить тест зв’язку ({2}), щоб підтвердити можливість зв’язатися з вами.
-
-
-
-Це ТЕСТ. Жодної надзвичайної ситуації немає, і не потрібно нічого робити, окрім підтвердження отримання цього листа.
-
-
-
-Натисніть посилання нижче, щоб підтвердити отримання цього повідомлення:
-
-{3}
-
-
-
-Команда Resgrid
+
+ Тест зв’язку
+
+
+ Вітаємо, {0}!
+
+
+ {0} проводить тест зв’язку ({1}), щоб підтвердити можливість зв’язатися з вами.
+
+
+ Це ТЕСТ. Жодної надзвичайної ситуації немає, і не потрібно нічого робити, окрім підтвердження отримання цього листа.
+
+
+ Підтвердьте отримання цього повідомлення.
+
+
+ Підтвердити отримання
+
+
+ Якщо кнопка вище не працює, скопіюйте та вставте наведене нижче посилання у свій браузер.
+
+
+ Дякуємо,
+
+
+ Команда Resgrid
+
+
+ Підрозділ:
+
+
+ Назва тесту:Тест зв’язку Resgrid: {0}
diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs
index f219930a4..e6ca91f0e 100644
--- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs
+++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTestMessageCatalog.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Text;
namespace Resgrid.Localization.Areas.User.CommunicationTest
{
@@ -30,9 +31,87 @@ public static string BuildEmailSubject(string testName, string? culture)
return CommunicationTestResources.Get("MessageEmailSubject", culture, testName);
}
+ ///
+ /// The plain text rendering of the email, assembled from the same pieces the HTML template
+ /// uses. The sent email is HTML — this is what the "what your people will see" preview shows
+ /// and what a client that cannot render HTML falls back to, so it is composed from the
+ /// segments rather than from a second copy of the wording that could drift from them.
+ ///
public static string BuildEmailBody(string firstName, string departmentName, string testName, string confirmUrl, string? culture)
{
- return CommunicationTestResources.Get("MessageEmailBody", culture, firstName, departmentName, testName, confirmUrl);
+ var builder = new StringBuilder();
+ builder.AppendLine(BuildEmailGreeting(firstName, culture));
+ builder.AppendLine();
+ builder.AppendLine(BuildEmailIntro(departmentName, testName, culture));
+ builder.AppendLine();
+ builder.AppendLine(BuildEmailDisclaimer(culture));
+ builder.AppendLine();
+ builder.AppendLine(BuildEmailAction(culture));
+ builder.AppendLine();
+ builder.AppendLine(confirmUrl);
+ builder.AppendLine();
+ builder.AppendLine(BuildEmailSignoff(culture));
+ builder.Append(BuildEmailTeam(culture));
+
+ return builder.ToString();
+ }
+
+ /// Short summary line email clients show next to the subject.
+ public static string BuildEmailPreheader(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailPreheader", culture);
+ }
+
+ public static string BuildEmailGreeting(string firstName, string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailGreeting", culture, firstName);
+ }
+
+ public static string BuildEmailIntro(string departmentName, string testName, string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailIntro", culture, departmentName, testName);
+ }
+
+ public static string BuildEmailDisclaimer(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailDisclaimer", culture);
+ }
+
+ public static string BuildEmailAction(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailAction", culture);
+ }
+
+ /// Label on the confirmation button in the HTML email.
+ public static string BuildEmailButton(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailButton", culture);
+ }
+
+ /// Fallback copy shown under the button for clients that strip it.
+ public static string BuildEmailTrouble(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailTrouble", culture);
+ }
+
+ public static string BuildEmailSignoff(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailSignoff", culture);
+ }
+
+ public static string BuildEmailTeam(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailTeam", culture);
+ }
+
+ public static string BuildEmailDepartmentLabel(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailDepartmentLabel", culture);
+ }
+
+ public static string BuildEmailTestLabel(string? culture)
+ {
+ return CommunicationTestResources.Get("MessageEmailTestLabel", culture);
}
public static string BuildPushTitle(string? culture)
diff --git a/Core/Resgrid.Model/Chat/ChatEnums.cs b/Core/Resgrid.Model/Chat/ChatEnums.cs
index 9db360980..fbd07865d 100644
--- a/Core/Resgrid.Model/Chat/ChatEnums.cs
+++ b/Core/Resgrid.Model/Chat/ChatEnums.cs
@@ -35,7 +35,18 @@ public enum ChatChannelType
/// dispatch when there is no incident to anchor the conversation. One per unit, provisioned the
/// first time the unit's operator lists channels.
///
- UnitDispatch = 11
+ UnitDispatch = 11,
+
+ ///
+ /// A private line to whoever is currently running an incident, opened from "Message the IC" on the
+ /// call's Command tab. Addressed to the command ROLE, not to a person: one channel per (call,
+ /// requester), with the commander side resolved live from the incident's current commander. That
+ /// distinction is the point — "messaging Mike" is a and follows Mike,
+ /// while "messaging the IC" stays with the incident, so a command transfer hands the running
+ /// conversation to the incoming commander with its history intact and drops it from the outgoing
+ /// one on their next access check. Only provisioned once a command has been established.
+ ///
+ IncidentCommanderLine = 12
}
/// Who a chat participant is: a person, a unit-shared identity ("Engine 6"), or the chatbot.
diff --git a/Core/Resgrid.Model/CommunicationTestEmailContent.cs b/Core/Resgrid.Model/CommunicationTestEmailContent.cs
new file mode 100644
index 000000000..0a0824abd
--- /dev/null
+++ b/Core/Resgrid.Model/CommunicationTestEmailContent.cs
@@ -0,0 +1,56 @@
+namespace Resgrid.Model
+{
+ ///
+ /// The already-localized wording of a communication test email, handed to the email provider so
+ /// it can drop the text into the shared Resgrid HTML template. Every message is composed for a
+ /// recipient whose language is their own, so the strings arrive translated rather than the
+ /// provider picking a culture of its own.
+ ///
+ public class CommunicationTestEmailContent
+ {
+ /// Subject line of the email.
+ public string Subject { get; set; }
+
+ /// Short summary email clients show next to the subject in the inbox list.
+ public string Preheader { get; set; }
+
+ /// Salutation, already carrying the recipient's first name.
+ public string Greeting { get; set; }
+
+ /// Sentence explaining who is running the test and why.
+ public string Intro { get; set; }
+
+ /// The "this is only a test, there is no emergency" line.
+ public string Disclaimer { get; set; }
+
+ /// Sentence asking the recipient to confirm.
+ public string Action { get; set; }
+
+ /// Label on the confirmation button.
+ public string ButtonText { get; set; }
+
+ /// Where the confirmation button points.
+ public string ConfirmUrl { get; set; }
+
+ /// Copy under the button telling the recipient to paste the URL if it does not work.
+ public string TroubleText { get; set; }
+
+ /// Closing line, such as "Thanks,".
+ public string Signoff { get; set; }
+
+ /// Who the email is from, such as "The Resgrid Team".
+ public string TeamName { get; set; }
+
+ /// Label for the department row in the details block.
+ public string DepartmentLabel { get; set; }
+
+ /// Name of the department running the test.
+ public string DepartmentName { get; set; }
+
+ /// Label for the test name row in the details block.
+ public string TestLabel { get; set; }
+
+ /// Name of the communication test being run.
+ public string TestName { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
index 8616742bd..069572a95 100644
--- a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
+++ b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs
@@ -92,6 +92,15 @@ public class IncidentChatChannels
/// True once the incident is closed: the conversations are readable but frozen.
public bool IsFrozen { get; set; }
+
+ ///
+ /// Whether "Message the IC" is offerable: true only while a commander actually holds the incident.
+ /// The line is addressed to the command role, so with nobody in the seat there is no one to
+ /// address — clients keep the action disabled rather than opening a conversation into the void.
+ /// The caller for the commander themselves is left false; they have no reason to message the seat
+ /// they are sitting in.
+ ///
+ public bool CanMessageCommander { get; set; }
}
/// Contact card for a person relevant to a resource (commander or lane lead).
diff --git a/Core/Resgrid.Model/Providers/IEmailProvider.cs b/Core/Resgrid.Model/Providers/IEmailProvider.cs
index 7c7b117e2..33f983ecb 100644
--- a/Core/Resgrid.Model/Providers/IEmailProvider.cs
+++ b/Core/Resgrid.Model/Providers/IEmailProvider.cs
@@ -42,6 +42,8 @@ Task SendUpgradePaymentReciept(string departmentName, string processDate,
Task SendReportDeliveryMail(string email, string subject, string messageBody, string sentOn,
string reportName, string attachmentFilename, byte[] attachmentData, string reportUrl);
+ Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content);
+
// Internal Template Only Emails
Task SendDeleteDepartmentEmail(string requesterName, string departmentName, DateTime localCompletedOn, string sendingToPersonName, string email);
diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs
index f44f703e1..16eb6186a 100644
--- a/Core/Resgrid.Model/Services/IChatServices.cs
+++ b/Core/Resgrid.Model/Services/IChatServices.cs
@@ -123,6 +123,16 @@ public interface IChatChannelService
///
Task EnsureUnitDispatchChannelAsync(int departmentId, int unitId, CancellationToken cancellationToken = default(CancellationToken));
+ ///
+ /// Ensures the requester's private line to the incident's current commander ("Message the IC").
+ /// One channel per (call, requester); the requester is stamped as an explicit member row while the
+ /// commander side stays implicit, so a command transfer moves the conversation to the incoming
+ /// commander without touching its history. Returns null when the call has no established command
+ /// with a current commander — the button that calls this is expected to stay disabled until then.
+ ///
+ Task EnsureIncidentCommanderLineAsync(int departmentId, int callId, string requesterUserId,
+ int? requesterUnitId, CancellationToken cancellationToken = default(CancellationToken));
+
///
/// Backfills every chat channel an ACTIVE incident should have — the call's incident channel, the
/// command and "All Leads" channels, and one per live lane — inserting only what is missing.
diff --git a/Core/Resgrid.Model/Services/IPushService.cs b/Core/Resgrid.Model/Services/IPushService.cs
index e42b10799..a414752af 100644
--- a/Core/Resgrid.Model/Services/IPushService.cs
+++ b/Core/Resgrid.Model/Services/IPushService.cs
@@ -19,10 +19,14 @@ public interface IPushService
Task PushCallUnit(StandardPushCall call, int unitId, DepartmentCallPriority priority = null);
///
- /// Realtime-chat push to a user across the Responder and IC app subscribers. EventCode is the
- /// chat deep-link (t:{channelId} / g:{channelId}); unreadCount drives the app badge.
+ /// Realtime-chat push to a user's Responder app subscriber, and — only when
+ /// is set — to their IC app subscriber as well.
+ /// EventCode is the chat deep-link (t:{channelId} / g:{channelId}); unreadCount drives the app badge.
+ /// The caller decides IC eligibility because it depends on the channel, not the user: see
+ /// ChatNotificationService.
///
- Task PushChatMessage(StandardPushMessage message, string userId, string eventCode, int unreadCount, UserProfile profile = null);
+ Task PushChatMessage(StandardPushMessage message, string userId, string eventCode, int unreadCount,
+ bool includeIncidentCommandApp, UserProfile profile = null);
/// Realtime-chat push to a unit-device subscriber (Unit app on the rig).
Task PushChatMessageUnit(StandardPushMessage message, int unitId, string eventCode, int unreadCount);
diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs
index 3320bfb7f..15d9bf4bb 100644
--- a/Core/Resgrid.Services/ChatChannelService.cs
+++ b/Core/Resgrid.Services/ChatChannelService.cs
@@ -40,11 +40,15 @@ public class ChatChannelService : IChatChannelService
private readonly ICacheProvider _cacheProvider;
private readonly IUnitOfWork _unitOfWork;
+ // IncidentCommandService reaches back for channel provisioning through ServiceLocator, so this
+ // constructor edge does not close a resolution cycle.
+ private readonly IIncidentCommandService _incidentCommandService;
+
public ChatChannelService(IChatChannelRepository chatChannelRepository, IChatChannelMemberRepository chatChannelMemberRepository,
IChatChannelAccessRuleRepository chatChannelAccessRuleRepository, IChatDepartmentSettingRepository chatDepartmentSettingRepository,
IChatPermissionService chatPermissionService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService,
IUnitsService unitsService, IUserProfileService userProfileService, ICallsService callsService, IEventAggregator eventAggregator,
- ICacheProvider cacheProvider, IUnitOfWork unitOfWork)
+ ICacheProvider cacheProvider, IUnitOfWork unitOfWork, IIncidentCommandService incidentCommandService)
{
_chatChannelRepository = chatChannelRepository;
_chatChannelMemberRepository = chatChannelMemberRepository;
@@ -59,6 +63,7 @@ public ChatChannelService(IChatChannelRepository chatChannelRepository, IChatCha
_eventAggregator = eventAggregator;
_cacheProvider = cacheProvider;
_unitOfWork = unitOfWork;
+ _incidentCommandService = incidentCommandService;
}
public async Task GetChannelByIdAsync(string chatChannelId)
@@ -207,7 +212,7 @@ async Task> getChannels()
if (type != ChatChannelType.CustomLocked && type != ChatChannelType.Incident &&
type != ChatChannelType.IncidentLane && type != ChatChannelType.IncidentCommand &&
type != ChatChannelType.IncidentLeads && type != ChatChannelType.IncidentDispatch &&
- type != ChatChannelType.UnitDispatch)
+ type != ChatChannelType.UnitDispatch && type != ChatChannelType.IncidentCommanderLine)
continue;
if (await _chatPermissionService.CanAccessChannelAsync(channel, userId, activeUnitId))
@@ -930,6 +935,83 @@ private async Task RebindCommandScopedChannelAsync(ChatChannel chan
return saved;
}
+ public async Task EnsureIncidentCommanderLineAsync(int departmentId, int callId, string requesterUserId,
+ int? requesterUnitId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (callId <= 0 || (string.IsNullOrWhiteSpace(requesterUserId) && !requesterUnitId.HasValue))
+ return null;
+
+ // Addressed to the command role, so there has to be a role to address. Returning null here is
+ // what keeps the client's "Message the IC" button disabled until a command is established —
+ // otherwise the first message would sit in a channel with nobody on the other side.
+ var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId);
+ if (command == null || string.IsNullOrWhiteSpace(command.CurrentCommanderUserId))
+ return null;
+
+ Unit requesterUnit = null;
+ if (requesterUnitId.HasValue)
+ {
+ requesterUnit = await _unitsService.GetUnitByIdAsync(requesterUnitId.Value);
+ if (requesterUnit == null || requesterUnit.DepartmentId != departmentId)
+ throw new UnauthorizedAccessException("The requesting unit does not belong to this department.");
+ }
+
+ var dmKey = BuildIncidentCommanderLineKey(callId, requesterUserId, requesterUnitId);
+ var prefix = await ResolveIncidentPrefixAsync(callId, command.Name);
+ var desiredName = await BuildIncidentCommanderLineChannelNameAsync(prefix, requesterUnit?.Name, requesterUserId);
+
+ var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+ if (existing != null)
+ return await ApplyProvisionedNameAsync(existing, desiredName, cancellationToken);
+
+ var channel = new ChatChannel
+ {
+ ChatChannelId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ ChannelType = (int)ChatChannelType.IncidentCommanderLine,
+ Name = desiredName,
+ CallId = callId,
+ IncidentCommandId = command.IncidentCommandId,
+ CreatedByUserId = requesterUserId,
+ CreatedOn = DateTime.UtcNow,
+ DmKey = dmKey
+ };
+
+ // Only the requester gets a member row. The commander side is deliberately implicit so the
+ // channel follows the role: see ChatPermissionService's IncidentCommanderLine cases.
+ var members = new List
+ {
+ requesterUnitId.HasValue
+ ? NewMemberRow(channel, ChatParticipantType.Unit, null, requesterUnitId, requesterUnit?.Name, requesterUserId)
+ : NewMemberRow(channel, ChatParticipantType.User, requesterUserId, null, null, requesterUserId)
+ };
+
+ ChatChannel saved;
+ try
+ {
+ saved = await _chatChannelRepository.CreateDirectMessageChannelAsync(channel, members, cancellationToken);
+ }
+ catch (Exception)
+ {
+ var winner = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+ if (winner != null)
+ return winner;
+
+ throw;
+ }
+
+ if (saved == null)
+ saved = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
+
+ if (saved != null && string.Equals(saved.ChatChannelId, channel.ChatChannelId, StringComparison.OrdinalIgnoreCase))
+ {
+ await _chatPermissionService.InvalidateChannelCacheAsync(saved.ChatChannelId);
+ PublishChannelEvent(saved, ChatEventKinds.ChannelProvisioned);
+ }
+
+ return saved;
+ }
+
public async Task EnsureIncidentChannelsAsync(IncidentCommand command, IEnumerable nodes, CancellationToken cancellationToken = default(CancellationToken))
{
if (command == null || command.CallId <= 0)
@@ -1221,6 +1303,43 @@ private static string BuildDmKey(string creatorUserId, string targetUserId, int?
///
private static string BuildUnitDispatchKey(int unitId) => $"unitdispatch:{unitId}";
+ ///
+ /// One commander line per (call, requester), riding the same (DepartmentId, DmKey) unique index.
+ /// Keyed on the CALL rather than on the commander: that is what lets command change hands without
+ /// forking the conversation or stranding its history on the outgoing commander.
+ ///
+ private static string BuildIncidentCommanderLineKey(int callId, string requesterUserId, int? requesterUnitId)
+ => requesterUnitId.HasValue
+ ? $"iccommander:{callId}|unit:{requesterUnitId.Value}"
+ : $"iccommander:{callId}|u:{requesterUserId?.ToLowerInvariant()}";
+
+ ///
+ /// Names the commander line. The requester is part of the name because the commander holds one of
+ /// these per requester — without it their channel list is a column of identical rows.
+ ///
+ private async Task BuildIncidentCommanderLineChannelNameAsync(string prefix, string requesterUnitName, string requesterUserId)
+ {
+ var requester = requesterUnitName;
+
+ if (string.IsNullOrWhiteSpace(requester) && !string.IsNullOrWhiteSpace(requesterUserId))
+ {
+ try
+ {
+ var profile = await _userProfileService.GetProfileByUserIdAsync(requesterUserId);
+ requester = profile?.FullName?.AsFirstNameLastName;
+ }
+ catch (Exception ex)
+ {
+ // Naming is cosmetic next to provisioning — never let a profile lookup block the line.
+ Logging.LogException(ex);
+ }
+ }
+
+ return string.IsNullOrWhiteSpace(requester)
+ ? $"{prefix} Incident Commander"
+ : $"{prefix} Incident Commander ({requester.Trim()})";
+ }
+
///
/// The incident prefix every incident-scoped channel name starts with: the incident's own name when
/// command gave it one, otherwise the call's number-prefixed name ("26-45 Structure Fire"), otherwise
diff --git a/Core/Resgrid.Services/ChatNotificationService.cs b/Core/Resgrid.Services/ChatNotificationService.cs
index 048a71473..2e069bbcb 100644
--- a/Core/Resgrid.Services/ChatNotificationService.cs
+++ b/Core/Resgrid.Services/ChatNotificationService.cs
@@ -79,7 +79,13 @@ public async Task NotifyMessageSentAsync(ChatChannel channel, ChatMessage messag
await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, audience, channel.ChatChannelId),
StringComparer.OrdinalIgnoreCase);
- var isDm = channel.ChannelType == (int)ChatChannelType.DirectMessage;
+ // The commander line is one-to-one, so it carries the DM deep-link prefix ("t:") rather than the
+ // group one — the apps route it to a conversation view, not a channel view.
+ var isDm = channel.ChannelType == (int)ChatChannelType.DirectMessage
+ || channel.ChannelType == (int)ChatChannelType.IncidentCommanderLine;
+ var channelType = (ChatChannelType)channel.ChannelType;
+ var notifyIncidentCommandApp = ShouldNotifyIncidentCommandApp(channelType);
+ var notifyUnitApp = ShouldNotifyUnitApp(channelType);
var eventCode = $"{(isDm ? "t" : "g")}:{channel.ChatChannelId}";
var title = BuildTitle(channel, message, isDm, isUrgent);
var body = BuildPreview(message);
@@ -96,6 +102,8 @@ await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, au
using (var throttler = new SemaphoreSlim(MaxConcurrentPushes))
{
var pushes = new List();
+ var suppressedActive = 0;
+ var suppressedPreference = 0;
foreach (var userId in audience)
{
@@ -103,20 +111,34 @@ await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, au
continue;
if (activeUsers.Contains(userId))
+ {
+ suppressedActive++;
continue;
+ }
membersByUser.TryGetValue(userId, out var member);
if (!ShouldNotify(member, isUrgent, urgentOverridesMute, mentionedEveryone || mentionedUsers.Contains(userId)))
+ {
+ suppressedPreference++;
continue;
+ }
var unread = (int)Math.Max(0, channel.LastMessageSeq - (member?.LastReadSeq ?? 0));
- pushes.Add(SendThrottledAsync(throttler, () => _pushService.PushChatMessage(pushMessage, userId, eventCode, Math.Max(unread, 1))));
+ pushes.Add(SendThrottledAsync(throttler, () => _pushService.PushChatMessage(pushMessage, userId, eventCode, Math.Max(unread, 1), notifyIncidentCommandApp)));
}
- // Unit participants (DM to "Engine 6", unit invited to a group chat): alert the rig device.
- foreach (var unitMember in memberRows.Where(m => m.ParticipantType == (int)ChatParticipantType.Unit && m.UnitId.HasValue && !m.RemovedOn.HasValue && !m.IsBanned))
+ // The fan-out runs detached from the request, so without this line an empty audience, a
+ // stale active-channel marker and a channel full of muted members are indistinguishable
+ // from "pushes were sent" when someone reports missing chat notifications.
+ Logging.LogInfo($"Chat push fan-out for channel {channel.ChatChannelId} (type {channel.ChannelType}, event {eventCode}): audience {audience.Count}, queued {pushes.Count}, suppressed active {suppressedActive}, suppressed by preference {suppressedPreference}, IC app {notifyIncidentCommandApp}, unit app {notifyUnitApp}.");
+
+ // Unit participants (DM to "Engine 6", unit invited to a group chat): alert the rig device,
+ // but only for the conversations the rig owns — see ShouldNotifyUnitApp.
+ foreach (var unitMember in notifyUnitApp
+ ? memberRows.Where(m => m.ParticipantType == (int)ChatParticipantType.Unit && m.UnitId.HasValue && !m.RemovedOn.HasValue && !m.IsBanned)
+ : Enumerable.Empty())
{
if (message.SenderUnitId.HasValue && message.SenderUnitId.Value == unitMember.UnitId.Value)
continue;
@@ -136,6 +158,57 @@ await _chatPresenceService.GetUsersActiveInChannelAsync(channel.DepartmentId, au
}
}
+ ///
+ /// Whether this channel's traffic may wake a recipient's IC app. The IC app is an incident device:
+ /// it carries incident conversations only, so department-wide, station, ad-hoc, custom and peer
+ /// chatter reaches the person on their Responder app instead of burying incident traffic on the
+ /// device they are commanding from. A plain user-to-user DM stays off the IC app deliberately —
+ /// "messaging Mike" and "messaging the IC" are different conversations, and the latter belongs to
+ /// the command role rather than to whoever currently holds it.
+ ///
+ private static bool ShouldNotifyIncidentCommandApp(ChatChannelType channelType)
+ {
+ switch (channelType)
+ {
+ case ChatChannelType.Incident:
+ case ChatChannelType.IncidentLane:
+ case ChatChannelType.IncidentCommand:
+ case ChatChannelType.IncidentLeads:
+ case ChatChannelType.IncidentDispatch:
+ case ChatChannelType.IncidentCommanderLine:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Whether this channel's traffic may wake a unit's rig device. A unit is woken by its own standing
+ /// dispatch line, by any incident channel it is working, and by a DM addressed to the unit identity
+ /// ("Engine 6") — that last one from any sender, because a unit has no Responder app of its own and
+ /// an unnotified DM would reach nobody. Department-wide, station, ad-hoc and custom channels are
+ /// excluded: the rig can read and post in them, but those members are notified as users.
+ ///
+ private static bool ShouldNotifyUnitApp(ChatChannelType channelType)
+ {
+ switch (channelType)
+ {
+ case ChatChannelType.DirectMessage:
+ case ChatChannelType.UnitDispatch:
+ case ChatChannelType.Incident:
+ case ChatChannelType.IncidentLane:
+ case ChatChannelType.IncidentCommand:
+ case ChatChannelType.IncidentLeads:
+ case ChatChannelType.IncidentDispatch:
+ case ChatChannelType.IncidentCommanderLine:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
/// Bounded-concurrency send: one recipient's failure is logged, never fails the fan-out.
private static async Task SendThrottledAsync(SemaphoreSlim throttler, Func send)
{
diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs
index 30af4590a..e25050e9b 100644
--- a/Core/Resgrid.Services/ChatPermissionService.cs
+++ b/Core/Resgrid.Services/ChatPermissionService.cs
@@ -225,6 +225,15 @@ public async Task> ResolveChannelAudienceUserIdsAsync(ChatChannel c
AddIfSet(userIds, dispatcherId);
break;
+ case ChatChannelType.IncidentCommanderLine:
+ // Requester side is an explicit member row; the commander side is resolved live from the
+ // call so a command transfer moves the conversation rather than copying it. Only the
+ // CURRENT commander — deliberately not EstablishedByUserId or the wider command staff,
+ // which is what separates this from the IncidentCommand channel.
+ await AddExplicitMemberAudienceAsync(channel, userIds);
+ AddIfSet(userIds, await GetCurrentCommanderUserIdAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault()));
+ break;
+
default: // DirectMessage, AdHocGroup
await AddExplicitMemberAudienceAsync(channel, userIds);
break;
@@ -341,11 +350,44 @@ private async Task EvaluateAccessAsync(ChatChannel channel, string userId,
&& await CanSendAsUnitAsync(userId, owningUnitId.Value, channel.DepartmentId);
}
+ case ChatChannelType.IncidentCommanderLine:
+ {
+ // Whoever currently holds command, by virtue of holding it. An outgoing commander loses
+ // the line here on their next check — the history stays on the channel for the incoming
+ // one. Deliberately NOT widened to department admins or dispatch: this is a private line.
+ if (string.Equals(await GetCurrentCommanderUserIdAsync(channel.DepartmentId, channel.CallId.GetValueOrDefault()), userId, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ // Requester side, proven the same way DMs are — a unit's row only counts when the caller
+ // actually crews that unit.
+ if (await HasActiveMembershipAsync(channel.ChatChannelId, userId, null))
+ return true;
+
+ return activeUnitId.HasValue
+ && await CanSendAsUnitAsync(userId, activeUnitId.Value, channel.DepartmentId)
+ && await HasActiveMembershipAsync(channel.ChatChannelId, userId, activeUnitId);
+ }
+
default:
return false;
}
}
+ ///
+ /// The user currently running the incident, or null when no command is established. Single source
+ /// for every IncidentCommanderLine decision so the audience and the access check can never disagree
+ /// about who "the IC" is mid-transfer.
+ ///
+ private async Task GetCurrentCommanderUserIdAsync(int departmentId, int callId)
+ {
+ if (callId <= 0)
+ return null;
+
+ var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId);
+
+ return command?.CurrentCommanderUserId;
+ }
+
private async Task EvaluateModerateAsync(ChatChannel channel, string userId)
{
// Department admins moderate every channel type (including DMs, for flagged-content handling).
@@ -370,6 +412,7 @@ private async Task EvaluateModerateAsync(ChatChannel channel, string userI
case ChatChannelType.IncidentCommand:
case ChatChannelType.IncidentLeads:
case ChatChannelType.IncidentDispatch:
+ case ChatChannelType.IncidentCommanderLine:
if (!channel.CallId.HasValue)
return false;
diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs
index 64b1fd41d..cc3e5bf44 100644
--- a/Core/Resgrid.Services/EmailService.cs
+++ b/Core/Resgrid.Services/EmailService.cs
@@ -779,15 +779,29 @@ public async Task SendCommunicationTestEmailAsync(string toEmailAddress, s
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);
- return true;
+ // Sent through the template provider so it carries the same Resgrid chrome and
+ // clickable confirmation button as every other system email, rather than arriving as
+ // a wall of plain text with a URL the recipient has to copy by hand.
+ var content = new CommunicationTestEmailContent
+ {
+ Subject = CommunicationTestMessages.BuildEmailSubject(testName, culture),
+ Preheader = CommunicationTestMessages.BuildEmailPreheader(culture),
+ Greeting = CommunicationTestMessages.BuildEmailGreeting(firstName, culture),
+ Intro = CommunicationTestMessages.BuildEmailIntro(departmentName, testName, culture),
+ Disclaimer = CommunicationTestMessages.BuildEmailDisclaimer(culture),
+ Action = CommunicationTestMessages.BuildEmailAction(culture),
+ ButtonText = CommunicationTestMessages.BuildEmailButton(culture),
+ ConfirmUrl = confirmUrl,
+ TroubleText = CommunicationTestMessages.BuildEmailTrouble(culture),
+ Signoff = CommunicationTestMessages.BuildEmailSignoff(culture),
+ TeamName = CommunicationTestMessages.BuildEmailTeam(culture),
+ DepartmentLabel = CommunicationTestMessages.BuildEmailDepartmentLabel(culture),
+ DepartmentName = departmentName,
+ TestLabel = CommunicationTestMessages.BuildEmailTestLabel(culture),
+ TestName = testName
+ };
+
+ return await _emailProvider.SendCommunicationTestMail(toEmailAddress, content);
}
catch (Exception ex)
{
diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs
index 69c8655f4..db7443c64 100644
--- a/Core/Resgrid.Services/IncidentCommandService.cs
+++ b/Core/Resgrid.Services/IncidentCommandService.cs
@@ -1121,6 +1121,12 @@ private async Task PopulateResourceViewContactsAndChatAsync(ResourceIncidentView
// Anyone on the incident can raise dispatch; no command standing required.
view.Chat.DispatchChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch)?.ChatChannelId;
+ // The commander line is provisioned on demand (one per requester), so there is no id to
+ // hand back here — only whether the action is offerable at all.
+ view.Chat.CanMessageCommander = !view.Chat.IsFrozen
+ && !string.IsNullOrWhiteSpace(command.CurrentCommanderUserId)
+ && !isCommander;
+
if (isCommandStaff)
view.Chat.CommandChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.IncidentCommand)?.ChatChannelId;
diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs
index d01df6bdc..018349d07 100644
--- a/Core/Resgrid.Services/PushService.cs
+++ b/Core/Resgrid.Services/PushService.cs
@@ -32,8 +32,14 @@ public PushService(IPushLogsService pushLogsService, INotificationProvider notif
public async Task Register(PushUri pushUri)
{
+ // A device that never lands its token on the Novu subscriber gets zero pushes forever, and every
+ // exit below used to be a bare `false` nobody inspected. Log each one: a missing registration
+ // is indistinguishable from a delivered-but-unseen push without it.
if (pushUri == null || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation))
+ {
+ Framework.Logging.LogWarning($"PushService.Register: incomplete registration (userId {pushUri?.UserId}, platform {pushUri?.PlatformType}, hasToken {!string.IsNullOrWhiteSpace(pushUri?.DeviceId)}, prefix '{pushUri?.PushLocation}'), skipped.");
return false;
+ }
var code = pushUri.PushLocation;
// IC app registrations target the IC-specific Novu subscriber, keeping its inbox/push separate from the Responder app.
@@ -42,20 +48,33 @@ public async Task Register(PushUri pushUri)
if (isICApp)
await EnsureICUserSubscriber(pushUri, code);
+ bool registered;
+
// 1) iOS -> APNS
if (pushUri.PlatformType == (int)Platforms.iOS)
- return isICApp
+ {
+ registered = isICApp
? await _novuProvider.UpdateICUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId)
: await _novuProvider.UpdateUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId);
-
+ }
// 2) Android -> FCM
- if (pushUri.PlatformType == (int)Platforms.Android)
- return isICApp
+ else if (pushUri.PlatformType == (int)Platforms.Android)
+ {
+ registered = isICApp
? await _novuProvider.UpdateICUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId)
: await _novuProvider.UpdateUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId);
-
+ }
// 3) TODO: Web Push (other platforms)
- return false;
+ else
+ {
+ Framework.Logging.LogWarning($"PushService.Register: unsupported platform {pushUri.PlatformType} for user {pushUri.UserId} (prefix '{code}', IC {isICApp}), no push channel registered.");
+ return false;
+ }
+
+ if (!registered)
+ Framework.Logging.LogError($"PushService.Register: Novu rejected the credential write for user {pushUri.UserId} (platform {pushUri.PlatformType}, prefix '{code}', IC {isICApp}); subscriber will have no configured push channel.");
+
+ return registered;
}
public async Task UnRegister(PushUri pushUri)
@@ -245,7 +264,8 @@ public async Task PushChat(StandardPushMessage message, string userId, Use
return true;
}
- public async Task PushChatMessage(StandardPushMessage message, string userId, string eventCode, int unreadCount, UserProfile profile = null)
+ public async Task PushChatMessage(StandardPushMessage message, string userId, string eventCode, int unreadCount,
+ bool includeIncidentCommandApp, UserProfile profile = null)
{
if (message == null || string.IsNullOrWhiteSpace(userId))
return false;
@@ -253,11 +273,25 @@ public async Task PushChatMessage(StandardPushMessage message, string user
if (profile == null)
profile = await _userProfileService.GetProfileByUserIdAsync(userId);
- if (profile == null || !profile.SendMessagePush)
+ // Both of these silently drop the push, and both are user/profile state rather than a fault —
+ // log them so "chat pushes aren't arriving" can be told apart from "the user turned them off".
+ if (profile == null)
+ {
+ Framework.Logging.LogWarning($"PushChatMessage: no user profile for {userId}, chat push dropped ({eventCode}).");
+ return false;
+ }
+
+ if (!profile.SendMessagePush)
+ {
+ Framework.Logging.LogInfo($"PushChatMessage: SendMessagePush disabled for {userId}, chat push dropped ({eventCode}).");
return false;
+ }
string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Message, PushSoundTypes.ModernChat);
+ if (string.IsNullOrWhiteSpace(message.DepartmentCode))
+ Framework.Logging.LogWarning($"PushChatMessage: department {message.DepartmentId} has no Code, Novu chat push skipped for {userId} ({eventCode}).");
+
try
{
await _notificationProvider.SendAllNotifications(message.Title, message.SubTitle, userId, eventCode, soundType, true, unreadCount, "#000000");
@@ -272,7 +306,13 @@ public async Task PushChatMessage(StandardPushMessage message, string user
if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
{
await _novuProvider.SendUserChatMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, eventCode, soundType, unreadCount);
- await _novuProvider.SendICUserChatMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, eventCode, soundType, unreadCount);
+
+ // The IC app is an incident device: waking it for department, station, ad-hoc or peer
+ // traffic both buries incident chatter and errors out for every user who never installed
+ // it (that subscriber only exists after an IC-sourced registration). The caller gates it
+ // on the channel, so this only fires for incident conversations.
+ if (includeIncidentCommandApp)
+ await _novuProvider.SendICUserChatMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, eventCode, soundType, unreadCount);
}
}
catch (Exception ex)
diff --git a/Core/Resgrid.Services/QueueService.cs b/Core/Resgrid.Services/QueueService.cs
index 2fbb552ce..ee0783b64 100644
--- a/Core/Resgrid.Services/QueueService.cs
+++ b/Core/Resgrid.Services/QueueService.cs
@@ -20,6 +20,23 @@ public class QueueService : IQueueService
private static readonly Func IsPendingDepartmentDeletion =
x => x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null;
+ ///
+ /// Drops the avatar blob off every profile bound for a queue. Profiles are materialized
+ /// fresh per call (Dapper, or a Redis round-trip when cached) so this never mutates
+ /// shared state, and no queue consumer reads the image.
+ ///
+ private static void StripProfileImages(List profiles)
+ {
+ if (profiles == null)
+ return;
+
+ foreach (var profile in profiles)
+ {
+ if (profile != null)
+ profile.Image = null;
+ }
+ }
+
private readonly IQueueItemsRepository _queueItemsRepository;
private readonly IOutboundQueueProvider _outboundQueueProvider;
private readonly IDepartmentSettingsService _departmentSettingsService;
@@ -165,6 +182,8 @@ public async Task> GetAllPendingDeleteDepartmentQueueItemsAsync(
//mqi.DepartmentTextNumber = departmentNumber;
}
+ StripProfileImages(mqi.Profiles);
+
return await _outboundQueueProvider.EnqueueMessage(mqi);
}
@@ -218,6 +237,13 @@ public async Task> GetAllPendingDeleteDepartmentQueueItemsAsync(
// We can't queue up any attachment data as it'll be too large.
cqi.Call.Attachments = null;
+ // Same story for the avatar blobs hanging off every profile. On the "dispatch a
+ // group/unit/role so send every profile in the department" path these are the only
+ // unbounded part of the payload and they've pushed the serialized message past
+ // RabbitMQ's 16MB frame limit, which kills the channel and the entire dispatch.
+ // Nothing downstream of the queue reads UserProfile.Image.
+ StripProfileImages(cqi.Profiles);
+
if (!await _outboundQueueProvider.EnqueueCall(cqi))
throw new InvalidOperationException("Failed to enqueue call broadcast for processing.");
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
index 1039d9d6d..2597844b3 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
@@ -22,6 +22,19 @@ public async Task EnqueueCall(CallQueueItem callQueue)
{
string serializedObject = ObjectSerialization.Serialize(callQueue);
+ // Last line of defense before the broker kills the channel on an oversized frame.
+ // Profiles are the only unbounded piece of the payload and CallBroadcast refetches
+ // them from the database when they're absent, so shedding them costs a query and
+ // saves the dispatch. Losing the dispatch is not an acceptable alternative.
+ if (serializedObject.Length > ServiceBusConfig.MaxMessageSizeInBytes && callQueue?.Profiles != null)
+ {
+ Logging.LogWarning(
+ $"Call broadcast for call {callQueue.Call?.CallId} serialized to {serializedObject.Length} bytes, over the {ServiceBusConfig.MaxMessageSizeInBytes} byte limit. Dropping {callQueue.Profiles.Count} profiles; the broadcast worker will reload them.");
+
+ callQueue.Profiles = null;
+ serializedObject = ObjectSerialization.Serialize(callQueue);
+ }
+
return await SendMessage(ServiceBusConfig.CallBroadcastQueueName, serializedObject,
requirePublisherConfirmation: true);
}
diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs
index 025f65cf5..d2ec1b0ac 100644
--- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs
+++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs
@@ -539,6 +539,53 @@ public async Task SendReportDeliveryMail(string email, string subject, str
return false;
}
+ public async Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content)
+ {
+ // Every string arrives already rendered in the recipient's language -- the template only
+ // supplies the Resgrid chrome around them.
+ var templateModel = new Dictionary
+ {
+ { "preheader", content.Preheader },
+ { "greeting", content.Greeting },
+ { "intro", content.Intro },
+ { "disclaimer", content.Disclaimer },
+ { "department_label", content.DepartmentLabel },
+ { "department_name", content.DepartmentName },
+ { "test_label", content.TestLabel },
+ { "test_name", content.TestName },
+ { "action", content.Action },
+ { "button_text", content.ButtonText },
+ { "confirm_url", content.ConfirmUrl },
+ { "trouble_text", content.TroubleText },
+ { "signoff", content.Signoff },
+ { "team_name", content.TeamName }
+ };
+
+ try
+ {
+ var template = Mustachio.Parser.Parse(GetTempate("CommunicationTest.html"));
+ var body = template(templateModel);
+
+ Email newEmail = new Email();
+ newEmail.HtmlBody = body;
+ newEmail.Sender = DONOTREPLY_EMAIL;
+ newEmail.To.Add(email);
+ newEmail.From = DONOTREPLY_EMAIL;
+ newEmail.Subject = content.Subject;
+
+ return await _emailSender.Send(newEmail);
+ }
+ catch (Exception ex)
+ {
+ // A test that cannot reach someone is the answer the run is looking for, so this is
+ // recorded as a failed send rather than thrown -- but it is still logged, because a
+ // template or provider fault would otherwise read as "the member is unreachable".
+ Logging.LogException(ex);
+ }
+
+ return false;
+ }
+
private string GetTempate(string templateName)
{
var assembly = typeof(PostmarkTemplateProvider).Assembly;
diff --git a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj
index 5c7c40805..ffb80352f 100644
--- a/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj
+++ b/Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj
@@ -5,6 +5,7 @@
+
@@ -18,6 +19,7 @@
+
diff --git a/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html
new file mode 100644
index 000000000..e3d60a072
--- /dev/null
+++ b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html
@@ -0,0 +1,487 @@
+
+
+
+
+
+ Resgrid Communication Test
+
+
+
+
+ {{preheader}}
+