From 65a09c12c308575dd5d1c019e086f96040708a9e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 17 Aug 2026 16:51:07 -0700 Subject: [PATCH 1/3] RG-T132 Comm Test email fix --- .../CommunicationTest.ar.resx | 52 +- .../CommunicationTest.de.resx | 52 +- .../CommunicationTest.el.resx | 52 +- .../CommunicationTest.en.resx | 52 +- .../CommunicationTest.es.resx | 52 +- .../CommunicationTest.fr.resx | 52 +- .../CommunicationTest.it.resx | 52 +- .../CommunicationTest.pl.resx | 52 +- .../CommunicationTest.sv.resx | 52 +- .../CommunicationTest.uk.resx | 52 +- .../CommunicationTestMessageCatalog.cs | 81 ++- .../CommunicationTestEmailContent.cs | 56 ++ .../Resgrid.Model/Providers/IEmailProvider.cs | 2 + Core/Resgrid.Services/EmailService.cs | 32 +- .../PostmarkTemplateProvider.cs | 47 ++ .../Resgrid.Providers.Email.csproj | 2 + .../Template/CommunicationTest.html | 487 ++++++++++++++++++ .../CommunicationTestEmailTemplateTests.cs | 151 ++++++ 18 files changed, 1168 insertions(+), 210 deletions(-) create mode 100644 Core/Resgrid.Model/CommunicationTestEmailContent.cs create mode 100644 Providers/Resgrid.Providers.Email/Template/CommunicationTest.html create mode 100644 Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs 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/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/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.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/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}} + + + + + + + diff --git a/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs new file mode 100644 index 000000000..c74b5fd90 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.EmailProvider; + +namespace Resgrid.Tests.Providers +{ + /// + /// A communication test proves a department can reach its people, and the proof is the recipient + /// clicking through. An email that arrives as a wall of plain text with a bare URL is a worse + /// proof than one that looks like every other Resgrid email and carries a button, so these pin + /// that the test email goes out through the shared HTML template with the confirm link wired to + /// a real anchor. + /// + [TestFixture] + public class CommunicationTestEmailTemplateTests + { + /// Every placeholder the template is allowed to contain, mirroring the model the provider builds. + private static readonly string[] ExpectedPlaceholders = + { + "preheader", "greeting", "intro", "disclaimer", "department_label", "department_name", + "test_label", "test_name", "action", "button_text", "confirm_url", "trouble_text", + "signoff", "team_name" + }; + + private static CommunicationTestEmailContent SampleContent() + { + // Distinct sentinels rather than realistic copy: a field the template forgets to place + // renders as nothing, and a sentinel makes that omission visible. + return new CommunicationTestEmailContent + { + Subject = "SUBJECT-SENTINEL", + Preheader = "PREHEADER-SENTINEL", + Greeting = "GREETING-SENTINEL", + Intro = "INTRO-SENTINEL", + Disclaimer = "DISCLAIMER-SENTINEL", + Action = "ACTION-SENTINEL", + ButtonText = "BUTTON-SENTINEL", + ConfirmUrl = "https://confirm/link", + TroubleText = "TROUBLE-SENTINEL", + Signoff = "SIGNOFF-SENTINEL", + TeamName = "TEAM-SENTINEL", + DepartmentLabel = "DEPARTMENTLABEL-SENTINEL", + DepartmentName = "DEPARTMENTNAME-SENTINEL", + TestLabel = "TESTLABEL-SENTINEL", + TestName = "TESTNAME-SENTINEL" + }; + } + + private static string ReadTemplate() + { + var assembly = typeof(PostmarkTemplateProvider).Assembly; + using (var resource = assembly.GetManifestResourceStream(assembly.GetName().Name + ".Template.CommunicationTest.html")) + { + resource.Should().NotBeNull("the communication test template should be embedded in the email provider assembly"); + + using (var reader = new StreamReader(resource)) + return reader.ReadToEnd(); + } + } + + /// Sends through the provider with a fake sender and hands back the email it would have transmitted. + private static async Task Render(CommunicationTestEmailContent content) + { + Email sent = null; + + var senderMock = new Mock(); + senderMock + .Setup(x => x.Send(It.IsAny())) + .Callback(x => sent = x) + .ReturnsAsync(true); + + var provider = new PostmarkTemplateProvider(senderMock.Object); + + var result = await provider.SendCommunicationTestMail("member@example.com", content); + + result.Should().BeTrue("the template should render and hand off to the sender"); + sent.Should().NotBeNull(); + + return sent; + } + + [Test] + public async Task the_email_should_be_sent_as_html_using_the_shared_resgrid_template() + { + var sent = await Render(SampleContent()); + + sent.To.Should().Contain("member@example.com"); + sent.Subject.Should().Be("SUBJECT-SENTINEL"); + + // The masthead and footer are what make it read as a Resgrid email rather than a raw note. + sent.HtmlBody.Should().Contain("class=\"email-masthead_name\""); + sent.HtmlBody.Should().Contain("Resgrid, LLC. All rights reserved."); + } + + [Test] + public async Task the_confirm_url_should_be_a_clickable_button_and_a_pasteable_fallback() + { + var sent = await Render(SampleContent()); + + sent.HtmlBody.Should().Contain("() + .Select(x => x.Groups[2].Value) + .Distinct() + .ToList(); + + placeholders.Should().BeEquivalentTo((IEnumerable)ExpectedPlaceholders); + } + } +} From b8bb62ad66789c67fe06c562a0373a167e4626f3 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 18 Aug 2026 09:56:05 -0700 Subject: [PATCH 2/3] RG-T132 Chat fix, bug fixes, call creation issue fix --- Core/Resgrid.Config/ServiceBusConfig.cs | 8 + Core/Resgrid.Model/Chat/ChatEnums.cs | 13 +- .../IncidentCommand/ResourceIncidentView.cs | 9 + Core/Resgrid.Model/Services/IChatServices.cs | 10 + Core/Resgrid.Model/Services/IPushService.cs | 10 +- Core/Resgrid.Services/ChatChannelService.cs | 123 +++++- .../ChatNotificationService.cs | 81 +++- .../Resgrid.Services/ChatPermissionService.cs | 43 ++ .../IncidentCommandService.cs | 6 + Core/Resgrid.Services/PushService.cs | 58 ++- Core/Resgrid.Services/QueueService.cs | 26 ++ .../RabbitOutboundQueueProvider.cs | 13 + .../NovuProvider.cs | 36 +- .../ChatRepositories.cs | 33 +- .../ModerationRepositories.cs | 10 +- .../UdfFieldValueRepository.cs | 11 +- .../Services/ChatChannelServiceTests.cs | 5 +- .../Services/ChatCommanderLineTests.cs | 370 ++++++++++++++++++ .../Services/ChatIncidentBackfillTests.cs | 3 +- .../Services/QueueServiceTests.cs | 68 +++- .../Controllers/v4/ChatController.cs | 61 +++ .../Models/v4/Chat/ChatApiModels.cs | 18 +- .../Resgrid.Web.Services.xml | 27 +- .../Logic/SystemQueueLogic.cs | 13 +- 24 files changed, 1004 insertions(+), 51 deletions(-) create mode 100644 Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs 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.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/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/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/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.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs index 6c2c6fe7a..152a94b97 100644 --- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs @@ -130,7 +130,17 @@ private async Task UpdateSubscriberFcm(string id, string token, string fcm request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); HttpResponseMessage response = await client.SendAsync(request); - return response.IsSuccessStatusCode; + // An unknown integrationIdentifier, an inactive integration or a malformed token all come + // back as a 4xx here. Swallowing that left the subscriber with no push channel and no clue. + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Logging.LogError($"Novu FCM credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{fcmId}': {error}"); + + return false; + } + + return true; } } catch (Exception e) @@ -184,13 +194,22 @@ private async Task UpdateSubscriberApns(string id, string token, string ap if (string.IsNullOrWhiteSpace(jsonContent)) { + Logging.LogWarning($"Novu APNS credential write skipped for subscriber '{id}': neither an apns nor an fcm integration identifier was supplied."); return false; } request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); HttpResponseMessage response = await client.SendAsync(request); - return response.IsSuccessStatusCode; + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Logging.LogError($"Novu APNS credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{apnsId ?? fcmId}': {error}"); + + return false; + } + + return true; } } catch (Exception e) @@ -351,7 +370,18 @@ private async Task SendNotification(string title, string body, string reci var result = await httpClient.PostAsync("v1/events/trigger", content); - return result.IsSuccessStatusCode; + // A rejected trigger (unknown workflow identifier, unknown subscriber, bad payload) is a + // 4xx with a body explaining why. Returning the bare bool made every one of those silent, + // so a workflow that was never created in Novu looked exactly like a delivered push. + if (!result.IsSuccessStatusCode) + { + var error = await result.Content.ReadAsStringAsync(); + Logging.LogError($"Novu trigger failed ({(int)result.StatusCode} {result.StatusCode}) workflow '{workflowIdentifier}' subscriber '{recipientId}' event '{eventCode}': {error}"); + + return false; + } + + return true; } } catch (Exception e) diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs index 550dd61ca..d624196a6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Data.Common; @@ -252,9 +252,14 @@ public async Task> GetByIdsAsync(IEnumerable ch if (ids.Count == 0) return new List(); + // Dapper only rewrites an IN-list into individual bind variables on providers that + // lack array support. Npgsql has it, so Dapper binds the list as one array parameter + // and leaves the SQL untouched -- "IN @Ids" arrives at the server as "IN $1" and + // fails to parse. Postgres consumes the array directly with = ANY(); SQL Server + // still needs the IN form Dapper expands. Every list parameter below follows this. var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannels WHERE chatchannelid IN {notation}Ids" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannels WHERE chatchannelid = ANY({notation}Ids)" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannels] WHERE [ChatChannelId] IN {notation}Ids"; var select = new Func>>(connection => @@ -910,7 +915,7 @@ public async Task> GetActiveByChannelIdsAsync(IEn var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE chatchannelid IN {notation}Ids AND removedon IS NULL" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE chatchannelid = ANY({notation}Ids) AND removedon IS NULL" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannelMembers] WHERE [ChatChannelId] IN {notation}Ids AND [RemovedOn] IS NULL"; var select = new Func>>(connection => @@ -1352,7 +1357,7 @@ public async Task> SearchAsync(int departmentId, IEnume { var fromClause = from.HasValue ? $" AND senton >= {notation}From" : string.Empty; var toClause = to.HasValue ? $" AND senton <= {notation}To" : string.Empty; - sql = $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE departmentid = {notation}DepartmentId AND chatchannelid IN {notation}Ids AND deletedon IS NULL AND body ILIKE {notation}Query{fromClause}{toClause} ORDER BY senton DESC LIMIT {notation}PageSize OFFSET {notation}Offset"; + sql = $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE departmentid = {notation}DepartmentId AND chatchannelid = ANY({notation}Ids) AND deletedon IS NULL AND body ILIKE {notation}Query{fromClause}{toClause} ORDER BY senton DESC LIMIT {notation}PageSize OFFSET {notation}Offset"; } else { @@ -1472,13 +1477,13 @@ public async Task DeleteMessagesByIdsAsync(List chatMessageIds, Can { statements = new[] { - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagementions WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageacks WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageflags WHERE chatmessageid IN @Ids", - $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE chatmessageid IN @Ids" + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagementions WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageacks WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageflags WHERE chatmessageid = ANY(@Ids)", + $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE chatmessageid = ANY(@Ids)" }; } else @@ -1739,7 +1744,7 @@ public async Task> GetChatExportEditsByMessageIdsAs var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid IN {notation}Ids" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid = ANY({notation}Ids)" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatMessageEdits] WHERE [ChatMessageId] IN {notation}Ids"; var select = new Func>>(connection => @@ -1786,7 +1791,7 @@ public async Task> GetMetadataByMessageIdsAsync(IEnu var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT chatattachmentid, chatmessageid, chatchannelid, departmentid, filename, contenttype, size, sha256, uploadedbyuserid, uploadedon FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid IN {notation}Ids" + ? $"SELECT chatattachmentid, chatmessageid, chatchannelid, departmentid, filename, contenttype, size, sha256, uploadedbyuserid, uploadedon FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid = ANY({notation}Ids)" : $"SELECT [ChatAttachmentId], [ChatMessageId], [ChatChannelId], [DepartmentId], [FileName], [ContentType], [Size], [Sha256], [UploadedByUserId], [UploadedOn] FROM {_sqlConfiguration.SchemaName}.[ChatAttachments] WHERE [ChatMessageId] IN {notation}Ids"; var select = new Func>>(connection => @@ -1833,7 +1838,7 @@ public async Task> GetByMessageIdsAsync(IEnumer var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid IN {notation}Ids" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid = ANY({notation}Ids)" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatMessageReactions] WHERE [ChatMessageId] IN {notation}Ids"; var select = new Func>>(connection => diff --git a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs index dcec8880f..2166b6d80 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; @@ -84,7 +84,7 @@ public async Task> GetByItemsAndReporterAsync(int r.completedbyuserid, r.completedon, r.adminnote FROM {_sqlConfiguration.SchemaName}.moderationrequests r WHERE r.departmentid = {notation}DepartmentId AND r.itemtype = {notation}ItemType - AND r.itemid IN {notation}ItemIds + AND r.itemid = ANY({notation}ItemIds) AND EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports p WHERE p.moderationrequestid = r.moderationrequestid AND p.reportedbyuserid = {notation}ReporterUserId)" : $@"SELECT r.[ModerationRequestId], r.[DepartmentId], r.[ItemType], r.[ItemId], r.[CallId], @@ -181,7 +181,7 @@ public async Task> SearchAsync(int departmentId, ? $" AND rv.reportedbyuserid = {notation}ReportedByUserId" : $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId"; filters.Add(postgres - ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid IN {notation}VisibleGroupIds))" + ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid = ANY({notation}VisibleGroupIds)))" : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND (rv.[ReportedByUserId] = {notation}ReporterUserId OR rv.[ReporterGroupId] IN {notation}VisibleGroupIds))"); } else @@ -292,7 +292,7 @@ public async Task> GetByRequestIdsAsync(IEnumerabl var notation = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid IN {notation}Ids ORDER BY moderationrequestid, reportedon" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid = ANY({notation}Ids) ORDER BY moderationrequestid, reportedon" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationReports] WHERE [ModerationRequestId] IN {notation}Ids ORDER BY [ModerationRequestId], [ReportedOn]"; var select = new Func>>(connection => @@ -424,7 +424,7 @@ public async Task> GetByRequestIdsAsync(IEnumerabl performedbyuserid, performedon, note, previousstatus, newstatus, actorrole, ipaddress, useragent, traceid, servername, detailsjson, evidencetext, evidencemetadatajson FROM {_sqlConfiguration.SchemaName}.moderationactions -WHERE moderationrequestid IN {notation}Ids ORDER BY moderationrequestid, performedon" +WHERE moderationrequestid = ANY({notation}Ids) ORDER BY moderationrequestid, performedon" : $@"SELECT [ModerationActionId], [ModerationRequestId], [DepartmentId], [ActionType], [PerformedByUserId], [PerformedOn], [Note], [PreviousStatus], [NewStatus], [ActorRole], [IpAddress], [UserAgent], [TraceId], [ServerName], [DetailsJson], [EvidenceText], [EvidenceMetadataJson] diff --git a/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs index 9ebd8b859..b2c820fe6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs @@ -1,4 +1,5 @@ using Dapper; +using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; @@ -86,9 +87,13 @@ public async Task> GetFieldValuesByEntitiesAsync(int var schema = _sqlConfiguration.SchemaName; var table = _sqlConfiguration.UdfFieldValuesTableName; - // Build an inline SQL statement that leverages Dapper's native IN-list expansion. - // The @EntityIds parameter is expanded by Dapper into the correct number of bind variables. - var sql = $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId IN @EntityIds AND UdfDefinitionId = @UdfDefinitionId"; + // Dapper only expands an IN-list into individual bind variables on providers without + // array support. Against Npgsql it binds the list as a single array parameter and + // leaves the SQL alone, so "IN @EntityIds" reaches the server as "IN $1" and fails + // to parse. Postgres takes the array directly via = ANY(). + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId = ANY(@EntityIds) AND UdfDefinitionId = @UdfDefinitionId" + : $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId IN @EntityIds AND UdfDefinitionId = @UdfDefinitionId"; return await x.QueryAsync(sql: sql, param: new { EntityType = entityType, EntityIds = idList, UdfDefinitionId = definitionId }, diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index 871c32d99..14eae5670 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -34,6 +34,7 @@ public class with_the_chat_channel_service : TestBase protected Mock _eventAggregatorMock; protected Mock _cacheProviderMock; protected Mock _unitOfWorkMock; + protected Mock _incidentCommandServiceMock; protected with_the_chat_channel_service() { @@ -62,6 +63,7 @@ private void BuildService() _eventAggregatorMock = new Mock(); _cacheProviderMock = new Mock(); _unitOfWorkMock = new Mock(); + _incidentCommandServiceMock = new Mock(); // Inserts/updates echo back the entity they were handed (repository contract). _chatChannelRepositoryMock.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -98,7 +100,8 @@ private void BuildService() _callsServiceMock.Object, _eventAggregatorMock.Object, _cacheProviderMock.Object, - _unitOfWorkMock.Object); + _unitOfWorkMock.Object, + _incidentCommandServiceMock.Object); } } diff --git a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs new file mode 100644 index 000000000..1da919b0f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Framework.Testing; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace ChatCommanderLineTests + { + /// + /// The IncidentCommanderLine channel is addressed to the command ROLE rather than to a person, so + /// the behaviour worth pinning is what happens as command changes hands: the conversation and its + /// history stay on the incident, the incoming commander picks them up, and the outgoing one loses + /// them — all without touching a membership row. + /// + public class with_the_commander_line : TestBase + { + protected const int DepartmentId = 1; + protected const int CallId = 42; + protected const string CommandId = "command-1"; + + protected IChatChannelService _chatChannelService; + protected IChatPermissionService _chatPermissionService; + + protected Mock _channelRepositoryMock; + protected Mock _memberRepositoryMock; + protected Mock _incidentCommandServiceMock; + protected Mock _unitsServiceMock; + protected Mock _userProfileServiceMock; + protected Mock _callsServiceMock; + protected Mock _dispatchAccessServiceMock; + protected Mock _authorizationServiceMock; + + protected with_the_commander_line() + { + BuildServices(); + } + + protected override void Before_all_tests() + { + BuildServices(); + } + + private void BuildServices() + { + _channelRepositoryMock = new Mock(); + _memberRepositoryMock = new Mock(); + _incidentCommandServiceMock = new Mock(); + _unitsServiceMock = new Mock(); + _userProfileServiceMock = new Mock(); + _callsServiceMock = new Mock(); + _dispatchAccessServiceMock = new Mock(); + _authorizationServiceMock = new Mock(); + + var cacheProviderMock = new Mock(); + cacheProviderMock.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null); + cacheProviderMock.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(It.IsAny())).ReturnsAsync(new List()); + + // The atomic channel+members insert echoes the channel back, and the member rows stay + // inspectable through the callback argument. + _channelRepositoryMock + .Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((ChatChannel c, IEnumerable m, CancellationToken t) => c); + + _callsServiceMock.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + Name = "Structure Fire" + }); + + _chatPermissionService = new ChatPermissionService( + _memberRepositoryMock.Object, + Mock.Of(), + _authorizationServiceMock.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + _unitsServiceMock.Object, + _callsServiceMock.Object, + _incidentCommandServiceMock.Object, + _dispatchAccessServiceMock.Object, + cacheProviderMock.Object); + + _chatChannelService = new ChatChannelService( + _channelRepositoryMock.Object, + _memberRepositoryMock.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + _unitsServiceMock.Object, + _userProfileServiceMock.Object, + _callsServiceMock.Object, + Mock.Of(), + cacheProviderMock.Object, + Mock.Of(), + _incidentCommandServiceMock.Object); + } + + protected void GivenCommanderIs(string userId) + { + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = CommandId, + DepartmentId = DepartmentId, + CallId = CallId, + Name = "Structure Fire", + CurrentCommanderUserId = userId, + EstablishedByUserId = TestData.Users.TestUser3Id, + Status = (int)IncidentCommandStatus.Active + }); + } + + protected void GivenNoCommand() + { + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync((IncidentCommand)null); + } + + protected ChatChannel BuildCommanderLine() + => new ChatChannel + { + ChatChannelId = "commander-line-1", + DepartmentId = DepartmentId, + ChannelType = (int)ChatChannelType.IncidentCommanderLine, + CallId = CallId, + IncidentCommandId = CommandId, + DmKey = $"iccommander:{CallId}|u:{TestData.Users.TestUser1Id.ToLowerInvariant()}" + }; + } + + [TestFixture] + public class when_provisioning_a_commander_line : with_the_commander_line + { + [Test] + public async Task no_established_command_should_not_provision_a_line() + { + GivenNoCommand(); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().BeNull("there is no command role to address yet"); + _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_command_with_no_current_commander_should_not_provision_a_line() + { + GivenCommanderIs(null); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().BeNull("the seat is empty even though a command record exists"); + } + + [Test] + public async Task a_new_line_should_be_anchored_to_the_call_not_the_commander() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().NotBeNull(); + result.ChannelType.Should().Be((int)ChatChannelType.IncidentCommanderLine); + result.CallId.Should().Be(CallId); + + // The key carries the call and the requester and NOT the commander — that is precisely what + // lets command change hands without forking the conversation. + result.DmKey.Should().Be($"iccommander:{CallId}|u:{TestData.Users.TestUser1Id.ToLowerInvariant()}"); + result.DmKey.Should().NotContain(TestData.Users.TestUser2Id.ToLowerInvariant()); + } + + [Test] + public async Task only_the_requester_should_get_a_member_row() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null); + + List captured = null; + _channelRepositoryMock + .Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((ChatChannel c, IEnumerable m, CancellationToken t) => + { + captured = new List(m); + return c; + }); + + await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + captured.Should().HaveCount(1, "the commander side is implicit so the row cannot go stale on transfer"); + captured[0].UserId.Should().Be(TestData.Users.TestUser1Id); + } + + [Test] + public async Task a_unit_requester_should_get_a_unit_keyed_line() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = DepartmentId, Name = "Engine 6" }); + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, 7); + + result.Should().NotBeNull(); + result.DmKey.Should().Be($"iccommander:{CallId}|unit:7"); + } + + [Test] + public async Task a_unit_from_another_department_should_be_rejected() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 99, Name = "Engine 6" }); + + var act = async () => await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, 7); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task an_existing_line_should_be_reused_rather_than_duplicated() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + var existing = BuildCommanderLine(); + existing.Name = "Structure Fire Incident Commander"; + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, existing.DmKey)).ReturnsAsync(existing); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().BeSameAs(existing); + _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task the_same_line_should_be_reused_after_command_changes_hands() + { + var existing = BuildCommanderLine(); + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, existing.DmKey)).ReturnsAsync(existing); + + GivenCommanderIs(TestData.Users.TestUser2Id); + var before = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + GivenCommanderIs(TestData.Users.TestUser3Id); + var after = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + after.ChatChannelId.Should().Be(before.ChatChannelId, "the history has to follow the incident, not the outgoing commander"); + } + } + + [TestFixture] + public class when_resolving_commander_line_access : with_the_commander_line + { + [Test] + public async Task the_current_commander_should_have_access_without_a_member_row() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null); + + result.Should().BeTrue(); + } + + [Test] + public async Task an_outgoing_commander_should_lose_access_on_the_next_check() + { + _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null); + + GivenCommanderIs(TestData.Users.TestUser2Id); + var whileInCommand = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null); + + GivenCommanderIs(TestData.Users.TestUser3Id); + var afterHandover = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null); + + whileInCommand.Should().BeTrue(); + afterHandover.Should().BeFalse(); + } + + [Test] + public async Task the_requester_should_keep_access_across_a_handover() + { + GivenCommanderIs(TestData.Users.TestUser3Id); + _memberRepositoryMock + .Setup(x => x.GetUserMemberAsync("commander-line-1", TestData.Users.TestUser1Id)) + .ReturnsAsync(new ChatChannelMember + { + ChatChannelId = "commander-line-1", + UserId = TestData.Users.TestUser1Id, + ParticipantType = (int)ChatParticipantType.User + }); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null); + + result.Should().BeTrue(); + } + + [Test] + public async Task an_uninvolved_user_should_be_denied() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task a_dispatcher_should_not_get_in_on_dispatch_standing_alone() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null); + _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(DepartmentId, TestData.Users.TestUser1Id)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null); + + result.Should().BeFalse("this is a private line, not incident-wide dispatch traffic"); + } + + [Test] + public async Task a_department_admin_should_not_get_in_on_admin_standing_alone() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null); + _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(TestData.Users.TestUser1Id, DepartmentId)).ReturnsAsync(true); + + var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null); + + result.Should().BeFalse(); + } + + [Test] + public async Task the_audience_should_be_the_requester_plus_the_current_commander_only() + { + GivenCommanderIs(TestData.Users.TestUser2Id); + _memberRepositoryMock.Setup(x => x.GetByChannelIdAsync("commander-line-1")).ReturnsAsync(new List + { + new ChatChannelMember + { + ChatChannelId = "commander-line-1", + UserId = TestData.Users.TestUser1Id, + ParticipantType = (int)ChatParticipantType.User + } + }); + + var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(BuildCommanderLine()); + + audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id }); + + // EstablishedByUserId is TestUser3 — deliberately excluded. Only the seat, not the wider + // command staff, which is what separates this from the IncidentCommand channel. + audience.Should().NotContain(TestData.Users.TestUser3Id); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs index 99dc33f8b..e656d9c6e 100644 --- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs @@ -68,7 +68,8 @@ private ChatChannelService BuildService() _callsService.Object, Mock.Of(), _cacheProvider.Object, - Mock.Of()); + Mock.Of(), + Mock.Of()); private static IncidentCommand BuildCommand(IncidentCommandStatus status = IncidentCommandStatus.Active) => new IncidentCommand diff --git a/Tests/Resgrid.Tests/Services/QueueServiceTests.cs b/Tests/Resgrid.Tests/Services/QueueServiceTests.cs index a2f62d847..2a2babc41 100644 --- a/Tests/Resgrid.Tests/Services/QueueServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/QueueServiceTests.cs @@ -1,4 +1,6 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -42,5 +44,69 @@ await act.Should().ThrowAsync() .WithMessage("Failed to enqueue call broadcast for processing."); outboundQueueProvider.Verify(provider => provider.EnqueueCall(queueItem), Times.Once); } + + [Test] + public async Task EnqueueCallBroadcastAsync_StripsProfileImagesBeforePublishing() + { + // Arrange: avatar blobs on every profile are what pushed the serialized broadcast + // past RabbitMQ's 16MB frame limit and killed the dispatch outright. + var queueItem = new CallQueueItem + { + Call = new Call { Address = "123 Main Street" }, + Profiles = new List + { + new UserProfile { UserId = "user-1", Image = new byte[] { 1, 2, 3 } }, + new UserProfile { UserId = "user-2", Image = new byte[] { 4, 5, 6 } }, + null + } + }; + byte[][] imagesAsPublished = null; + var outboundQueueProvider = new Mock(); + outboundQueueProvider + .Setup(provider => provider.EnqueueCall(queueItem)) + .Callback(cqi => + imagesAsPublished = cqi.Profiles.Where(x => x != null).Select(x => x.Image).ToArray()) + .ReturnsAsync(true); + var service = new QueueService( + new Mock().Object, + outboundQueueProvider.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + + // Act + var result = await service.EnqueueCallBroadcastAsync(queueItem); + + // Assert + result.Should().BeTrue(); + imagesAsPublished.Should().OnlyContain(image => image == null); + } + + [Test] + public async Task EnqueueCallBroadcastAsync_WithNullProfiles_DoesNotThrow() + { + // Arrange + var queueItem = new CallQueueItem + { + Call = new Call { Address = "123 Main Street" }, + Profiles = null + }; + var outboundQueueProvider = new Mock(); + outboundQueueProvider + .Setup(provider => provider.EnqueueCall(queueItem)) + .ReturnsAsync(true); + var service = new QueueService( + new Mock().Object, + outboundQueueProvider.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + + // Act + var result = await service.EnqueueCallBroadcastAsync(queueItem); + + // Assert + result.Should().BeTrue(); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 0d55f3000..615050488 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -270,6 +270,67 @@ public async Task> CreateDirectMessage([F return result; } + /// + /// Finds or creates the caller's private line to the incident's current Incident Commander. + /// Addressed to the command role rather than to a person, so the conversation and its history + /// follow command transfers. Returns Failure when the call has no established command with a + /// current commander — clients keep the "Message the IC" action disabled until one exists. + /// + /// The call to reach command on, and optionally the unit to speak as + /// ChatChannelCreatedResult with the existing or newly created commander line + [HttpPost("CreateIncidentCommanderLine")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CreateIncidentCommanderLine([FromBody] CreateIncidentCommanderLineInput input, CancellationToken cancellationToken) + { + if (!await ChatEnabledAsync()) + return NotFound(); + + if (!ModelState.IsValid) + return BadRequest(); + + if (input == null || input.CallId <= 0) + return BadRequest(); + + // Speaking as a unit has to be earned, not asserted — otherwise a caller could open (and post + // into) a commander line in another unit's name. + if (input.AsUnitId.HasValue && !await _chatPermissionService.CanSendAsUnitAsync(UserId, input.AsUnitId.Value, DepartmentId)) + return StatusCode(StatusCodes.Status403Forbidden); + + var result = new ChatChannelCreatedResult(); + ChatChannel channel; + + try + { + channel = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, input.CallId, UserId, input.AsUnitId, cancellationToken); + } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden); + } + + if (channel != null) + { + var member = await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId); + + result.Data = ConvertChannelResultData(channel, member); + result.PageSize = 1; + result.Status = ResponseHelper.Created; + } + else + { + // No command established yet (or none with a current commander). Not an error: the incident + // simply has nobody to address. + result.PageSize = 0; + result.Status = ResponseHelper.Failure; + } + + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// /// Creates an ad-hoc group channel with an explicit member list. /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs index 421ea5a8a..fc3402986 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs @@ -349,7 +349,7 @@ public class ChatChannelResultData public string ChatChannelId { get; set; } /// - /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch) + /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch, 12 = IncidentCommanderLine) /// public int ChannelType { get; set; } @@ -1023,6 +1023,22 @@ public class CreateDirectMessageInput public int? TargetUnitId { get; set; } } +/// +/// Input to open the caller's private line to an incident's current commander ("Message the IC") +/// +public class CreateIncidentCommanderLineInput +{ + /// + /// The call whose current Incident Commander should be messaged + /// + public int CallId { get; set; } + + /// + /// Open the line as this unit rather than as the calling user (the caller must crew the unit) + /// + public int? AsUnitId { get; set; } +} + /// /// Input to create an ad-hoc group channel /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index d58d201bf..60fbefcce 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -533,6 +533,16 @@ Target user or unit for the direct message ChatChannelCreatedResult with the existing or newly created channel + + + Finds or creates the caller's private line to the incident's current Incident Commander. + Addressed to the command role rather than to a person, so the conversation and its history + follow command transfers. Returns Failure when the call has no established command with a + current commander — clients keep the "Message the IC" action disabled until one exists. + + The call to reach command on, and optionally the unit to speak as + ChatChannelCreatedResult with the existing or newly created commander line + Creates an ad-hoc group channel with an explicit member list. @@ -7518,7 +7528,7 @@ - Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch) + Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch, 12 = IncidentCommanderLine) @@ -8171,6 +8181,21 @@ Target unit for the DM (mutually exclusive with TargetUserId) + + + Input to open the caller's private line to an incident's current commander ("Message the IC") + + + + + The call whose current Incident Commander should be messaged + + + + + Open the line as this unit rather than as the calling user (the caller must crew the unit) + + Input to create an ad-hoc group channel diff --git a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs index b966b7043..1cfe864c2 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs @@ -39,14 +39,23 @@ public class SystemQueueLogic } catch (Exception ex) { - + // Silently dropping this left the device permanently unregistered with no record + // of the attempt anywhere in the pipeline. + Logging.LogException(ex, "Failed to deserialize a PushRegistration queue item; the device will not receive pushes."); } - if (data != null) + if (data == null) + { + Logging.LogWarning("PushRegistration queue item produced no PushUri; registration skipped."); + } + else { var pushService = Bootstrapper.GetKernel().Resolve(); var resgriterResult = await pushService.Register(data); + if (!resgriterResult) + Logging.LogError($"PushRegistration failed for user {data.UserId} (platform {data.PlatformType}, prefix '{data.PushLocation}', source '{data.Source}')."); + pushService = null; } break; From b33891e8b230760d7d96378e3bc195736684dbea Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 18 Aug 2026 13:59:45 -0700 Subject: [PATCH 3/3] RG-T131 PR#472 fixes --- Core/Resgrid.Config/ServiceBusConfig.cs | 8 +- .../CommunicationTestEmailContent.cs | 9 +- Core/Resgrid.Model/Services/IChatServices.cs | 5 +- Core/Resgrid.Services/ChatChannelService.cs | 24 ++- Core/Resgrid.Services/EmailService.cs | 3 +- .../RabbitOutboundQueueProvider.cs | 13 ++ .../PostmarkEmailSender.cs | 14 +- .../PostmarkTemplateProvider.cs | 7 + .../Template/Call.html | 6 +- .../Template/Cancelled.html | 6 +- .../Template/ChargeFailed.html | 6 +- .../Template/CommunicationTest.html | 6 +- .../Template/DeleteDepartment.html | 6 +- .../Template/DepartmentLinkCreated.html | 6 +- .../Template/Invitation.html | 6 +- .../Template/Message.html | 7 + .../Template/PasswordReset.html | 6 +- .../Template/Receipt.html | 6 +- .../Template/ReportDelivery.html | 7 + .../Template/TroubleAlert.html | 6 +- .../Template/Welcome.html | 6 +- .../NovuProvider.cs | 84 ++++++++- .../CommunicationTestEmailTemplateTests.cs | 16 +- .../Services/ChatCommanderLineTests.cs | 49 +++++- .../ChatControllerCommanderLineTests.cs | 164 ++++++++++++++++++ .../Controllers/v4/ChatController.cs | 7 +- .../Views/Subscription/ViewInvoice.cshtml | 6 +- Web/Resgrid.Web/Views/Shared/_Layout.cshtml | 6 +- 28 files changed, 439 insertions(+), 56 deletions(-) create mode 100644 Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs index 35483d288..925d3ebb5 100644 --- a/Core/Resgrid.Config/ServiceBusConfig.cs +++ b/Core/Resgrid.Config/ServiceBusConfig.cs @@ -61,9 +61,11 @@ public static class ServiceBusConfig /// /// 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. + /// max_message_size, which production reports as 16777216 (16MiB), leaving room to + /// shed payload before the broker refuses it. A body over max_message_size isn't + /// rejected cleanly, it closes the channel with a PRECONDITION_FAILED and takes the + /// connection's in-flight work with it. Keep this below whatever the brokers are + /// configured with; their config lives outside this repository. /// public static int MaxMessageSizeInBytes = 15 * 1024 * 1024; #endregion RabbitMQ Bus Values diff --git a/Core/Resgrid.Model/CommunicationTestEmailContent.cs b/Core/Resgrid.Model/CommunicationTestEmailContent.cs index 0a0824abd..3c7c9e15c 100644 --- a/Core/Resgrid.Model/CommunicationTestEmailContent.cs +++ b/Core/Resgrid.Model/CommunicationTestEmailContent.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Model +namespace Resgrid.Model { /// /// The already-localized wording of a communication test email, handed to the email provider so @@ -52,5 +52,12 @@ public class CommunicationTestEmailContent /// Name of the communication test being run. public string TestName { get; set; } + + /// + /// The whole message rendered as plain text, sent alongside the HTML so a client that + /// cannot render HTML still gets readable wording and a pasteable confirmation URL + /// instead of tag-stripped template chrome. + /// + public string TextBody { get; set; } } } diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 16eb6186a..0420ced51 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -65,6 +65,9 @@ public interface IChatChannelService /// A user's member row for a single channel (null if none); does not lazily create one. Task GetUserMembershipAsync(string chatChannelId, string userId); + /// A unit's member row for a single channel (null if none); does not lazily create one. + Task GetUnitMembershipAsync(string chatChannelId, int unitId); + /// /// Adds members. Enforcement inside: DirectMessage channels reject adds (InvalidOperationException), /// CustomLocked channels require the actor to be a moderator (UnauthorizedAccessException), and every diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 15d9bf4bb..957313027 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -461,6 +461,11 @@ public async Task GetUserMembershipAsync(string chatChannelId return await _chatChannelMemberRepository.GetUserMemberAsync(chatChannelId, userId); } + public async Task GetUnitMembershipAsync(string chatChannelId, int unitId) + { + return await _chatChannelMemberRepository.GetUnitMemberAsync(chatChannelId, unitId); + } + public async Task> AddMembersAsync(string chatChannelId, List userIds, string addedByUserId, CancellationToken cancellationToken = default(CancellationToken)) { var channel = await _chatChannelRepository.GetByIdAsync(chatChannelId); @@ -944,8 +949,14 @@ public async Task EnsureIncidentCommanderLineAsync(int departmentId // 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. + // + // Closing a command leaves CurrentCommanderUserId populated, so the seat looks filled long + // after anyone is sitting in it. The status check is what actually retires the line: matching + // EnsureIncidentChannelsAsync, a closed command provisions nothing, and reopening a reused + // line here would lift the archive freeze the close put on it. var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId); - if (command == null || string.IsNullOrWhiteSpace(command.CurrentCommanderUserId)) + if (command == null || command.Status != (int)IncidentCommandStatus.Active || + string.IsNullOrWhiteSpace(command.CurrentCommanderUserId)) return null; Unit requesterUnit = null; @@ -960,9 +971,16 @@ public async Task EnsureIncidentCommanderLineAsync(int departmentId var prefix = await ResolveIncidentPrefixAsync(callId, command.Name); var desiredName = await BuildIncidentCommanderLineChannelNameAsync(prefix, requesterUnit?.Name, requesterUserId); + // The dm key is scoped to the call and the requester, not to the command, so a call that + // establishes a second command reuses this row. Same reason the command-scoped channels + // rebind: left alone it keeps the closed command's id and stays archived, invisible to + // both the new command's unarchive and its eventual close sweep. var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey); if (existing != null) - return await ApplyProvisionedNameAsync(existing, desiredName, cancellationToken); + { + var rebound = await RebindCommandScopedChannelAsync(existing, command.IncidentCommandId, cancellationToken); + return await ApplyProvisionedNameAsync(rebound, desiredName, cancellationToken); + } var channel = new ChatChannel { diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index cc3e5bf44..d52dfb486 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -798,7 +798,8 @@ public async Task SendCommunicationTestEmailAsync(string toEmailAddress, s DepartmentLabel = CommunicationTestMessages.BuildEmailDepartmentLabel(culture), DepartmentName = departmentName, TestLabel = CommunicationTestMessages.BuildEmailTestLabel(culture), - TestName = testName + TestName = testName, + TextBody = CommunicationTestMessages.BuildEmailBody(firstName, departmentName, testName, confirmUrl, culture) }; return await _emailProvider.SendCommunicationTestMail(toEmailAddress, content); diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs index 2597844b3..42d4ba167 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs @@ -35,6 +35,19 @@ public async Task EnqueueCall(CallQueueItem callQueue) serializedObject = ObjectSerialization.Serialize(callQueue); } + // Shedding profiles is the only lever, so re-measure rather than assume it was enough. + // An oversized publish doesn't fail on its own -- the broker answers PRECONDITION_FAILED + // and closes the channel, taking the connection's other in-flight work down with it. + // Refusing here keeps the damage to this one dispatch, and the caller already turns a + // false into a surfaced "failed to enqueue call broadcast" error. + if (serializedObject.Length > ServiceBusConfig.MaxMessageSizeInBytes) + { + Logging.LogError( + $"RabbitOutboundQueueProvider->EnqueueCall: call {callQueue?.Call?.CallId} serialized to {serializedObject.Length} bytes, over the {ServiceBusConfig.MaxMessageSizeInBytes} byte limit, with nothing left to shed. Not publishing."); + + return false; + } + return await SendMessage(ServiceBusConfig.CallBroadcastQueueName, serializedObject, requirePublisherConfirmation: true); } diff --git a/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs b/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs index 06a32414b..94cccd9fb 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs @@ -76,7 +76,14 @@ public async Task Send(Email email) to.Append("," + t); } - var message = new PostmarkMessage(email.From, to.ToString(), email.Subject, StringHelpers.StripHtmlTagsCharArray(email.HtmlBody), email.HtmlBody); + // A composed plain text body reads properly; tag-stripping the HTML template is + // the fallback for callers that never built one and leaves the recipient with + // the chrome and the raw button markup flattened into prose. + var textBody = !String.IsNullOrWhiteSpace(email.TextBody) + ? email.TextBody + : StringHelpers.StripHtmlTagsCharArray(email.HtmlBody); + + var message = new PostmarkMessage(email.From, to.ToString(), email.Subject, textBody, email.HtmlBody); var newClient = new PostmarkClient(Config.OutboundEmailServerConfig.PostmarkApiKey); if (!String.IsNullOrWhiteSpace(email.AttachmentName) && email.AttachmentData.Length > 0) @@ -189,8 +196,11 @@ public MailMessage CreateMailMessageFromEmail(Email email) if (!string.IsNullOrEmpty(email.HtmlBody) && !string.IsNullOrEmpty(email.TextBody)) { + // Order carries meaning in multipart/alternative: clients render the last part they + // understand, so plain text goes first and HTML last. Reversing these two lines + // shows every HTML-capable client the plain text version instead. + message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.TextBody, new ContentType(ContentTypes.Text))); message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.HtmlBody, new ContentType(ContentTypes.Html))); - //message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.TextBody, new ContentType(ContentTypes.Text))); } else if (!string.IsNullOrEmpty(email.HtmlBody)) { diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs index d2ec1b0ac..17a77e17b 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs @@ -541,6 +541,12 @@ public async Task SendReportDeliveryMail(string email, string subject, str public async Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content) { + // The model is built before the try below, so without this a null content would throw past + // the catch that turns every other failure here into a recorded false. A send that cannot be + // composed is a failed send, same as one the provider rejects. + if (content == null) + return false; + // Every string arrives already rendered in the recipient's language -- the template only // supplies the Resgrid chrome around them. var templateModel = new Dictionary @@ -568,6 +574,7 @@ public async Task SendCommunicationTestMail(string email, CommunicationTes Email newEmail = new Email(); newEmail.HtmlBody = body; + newEmail.TextBody = content.TextBody; newEmail.Sender = DONOTREPLY_EMAIL; newEmail.To.Add(email); newEmail.From = DONOTREPLY_EMAIL; diff --git a/Providers/Resgrid.Providers.Email/Template/Call.html b/Providers/Resgrid.Providers.Email/Template/Call.html index ed65e0abe..fd37cbec1 100644 --- a/Providers/Resgrid.Providers.Email/Template/Call.html +++ b/Providers/Resgrid.Providers.Email/Template/Call.html @@ -497,9 +497,9 @@

{{subject}}

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/Cancelled.html b/Providers/Resgrid.Providers.Email/Template/Cancelled.html index ff7b8b9c5..0e5fabaff 100644 --- a/Providers/Resgrid.Providers.Email/Template/Cancelled.html +++ b/Providers/Resgrid.Providers.Email/Template/Cancelled.html @@ -471,9 +471,9 @@

Your Resgrid subscription has been canceled

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html b/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html index 4d896a34e..e9db57761 100644 --- a/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html +++ b/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html @@ -467,9 +467,9 @@

We were unable to charge your card for your {{plan_name}} plan.

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html index e3d60a072..93a501cf1 100644 --- a/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html +++ b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html @@ -469,9 +469,9 @@

{{greeting}}

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html b/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html index 27a4aaeba..0ab99f6a5 100644 --- a/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html +++ b/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html @@ -457,9 +457,9 @@

Hi {{name}},

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html b/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html index e7a4c76b1..264735326 100644 --- a/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html +++ b/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html @@ -468,9 +468,9 @@

Hi {{name}},

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/Invitation.html b/Providers/Resgrid.Providers.Email/Template/Invitation.html index 661ea7543..1286750b8 100644 --- a/Providers/Resgrid.Providers.Email/Template/Invitation.html +++ b/Providers/Resgrid.Providers.Email/Template/Invitation.html @@ -458,9 +458,9 @@

Hello,

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/Message.html b/Providers/Resgrid.Providers.Email/Template/Message.html index 1ae07ccf2..8767c22fd 100644 --- a/Providers/Resgrid.Providers.Email/Template/Message.html +++ b/Providers/Resgrid.Providers.Email/Template/Message.html @@ -450,6 +450,13 @@

{{title}}

By {{sender_name}} at {{timestamp}}

View the message

+

+ Resgrid, LLC +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150 +
USA +

diff --git a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html b/Providers/Resgrid.Providers.Email/Template/PasswordReset.html index f64e66006..d373b487e 100644 --- a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html +++ b/Providers/Resgrid.Providers.Email/Template/PasswordReset.html @@ -473,9 +473,9 @@

Hi {{name}},

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/Receipt.html b/Providers/Resgrid.Providers.Email/Template/Receipt.html index 775e3b8dc..5163df8b2 100644 --- a/Providers/Resgrid.Providers.Email/Template/Receipt.html +++ b/Providers/Resgrid.Providers.Email/Template/Receipt.html @@ -517,9 +517,9 @@

{{date}}

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html b/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html index 6544fcfe7..99cac84bf 100644 --- a/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html +++ b/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html @@ -428,6 +428,13 @@

{{title}}

Sent on: {{timestamp}}

View your Scheduled Report Deliveries

+

+ Resgrid, LLC +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150 +
USA +

diff --git a/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html b/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html index 9cc9e8a69..5c66daef5 100644 --- a/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html +++ b/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html @@ -442,9 +442,9 @@

TROUBLE ALERT

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Email/Template/Welcome.html b/Providers/Resgrid.Providers.Email/Template/Welcome.html index 284ae6ba2..6e1007045 100644 --- a/Providers/Resgrid.Providers.Email/Template/Welcome.html +++ b/Providers/Resgrid.Providers.Email/Template/Welcome.html @@ -488,9 +488,9 @@

Welcome, {{name}}!

© Resgrid, LLC. All rights reserved.

Resgrid, LLC -
1802 North Carson Street -
Suite 157 -
Carson City, NV 89701 +
3079 Harrison Ave +
Suite 110 +
South Lake Tahoe, CA 96150
USA

diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs index 152a94b97..58b7210ea 100644 --- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs @@ -9,12 +9,90 @@ using Resgrid.Providers.Bus.Models; using SharpCompress.Common; using System.Text; +using System.Text.RegularExpressions; namespace Resgrid.Providers.Messaging { public class NovuProvider : INovuProvider { + private const int MaxLoggedErrorBodyLength = 500; + + /// + /// Anything long and opaque enough to be a credential rather than prose. Device tokens are the + /// reason this exists: a rejected credential write echoes the token back inside its validation + /// message, and FCM/APNS tokens are well over this length. + /// + private static readonly Regex OpaqueValuePattern = new Regex(@"[A-Za-z0-9_\-:\.]{20,}", RegexOptions.Compiled); + + private static readonly Regex WhitespaceRunPattern = new Regex(@"\s+", RegexOptions.Compiled); + + /// + /// Novu's error bodies are provider-controlled and unbounded, and the calls that produce them + /// carry device tokens and notification wording -- a validation failure echoes the rejected + /// payload straight back. Rather than trusting the body, pull the few diagnostic fields worth + /// having, redact anything token-shaped inside them and cap the result. A body that isn't the + /// shape we expect is reported by size alone; its content never reaches the log. + /// + private static string DescribeErrorBody(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return ""; + + var summary = ExtractDiagnosticFields(body); + if (string.IsNullOrWhiteSpace(summary)) + return $""; + + summary = WhitespaceRunPattern.Replace(summary, " ").Trim(); + summary = OpaqueValuePattern.Replace(summary, ""); + + return summary.Length > MaxLoggedErrorBodyLength + ? summary.Substring(0, MaxLoggedErrorBodyLength) + "..." + : summary; + } + + /// + /// Whitelist, not blacklist: only these keys are ever read out of the body, so a field we have + /// not vetted cannot reach the log by being added on Novu's side. + /// + private static string? ExtractDiagnosticFields(string body) + { + try + { + var parsed = JToken.Parse(body); + var parts = new List(); + + foreach (var name in new[] { "statusCode", "error", "message" }) + { + var value = parsed.SelectToken(name); + if (value == null) + continue; + + // class-validator returns message as an array of strings. + if (value.Type == JTokenType.Array) + { + var items = value.Children() + .Where(x => x.Type != JTokenType.Object && x.Type != JTokenType.Array) + .Select(x => x.ToString()); + + var joined = string.Join("; ", items); + if (!string.IsNullOrWhiteSpace(joined)) + parts.Add($"{name}={joined}"); + } + else if (value.Type != JTokenType.Object) + { + parts.Add($"{name}={value}"); + } + } + + return string.Join(" ", parts); + } + catch (JsonException) + { + return null; + } + } + private async Task CreateSubscriber(string id, int departmentId, string email, string firstName, string lastName, List data) { try @@ -135,7 +213,7 @@ private async Task UpdateSubscriberFcm(string id, string token, string fcm if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(); - Logging.LogError($"Novu FCM credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{fcmId}': {error}"); + Logging.LogError($"Novu FCM credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{fcmId}': {DescribeErrorBody(error)}"); return false; } @@ -204,7 +282,7 @@ private async Task UpdateSubscriberApns(string id, string token, string ap if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(); - Logging.LogError($"Novu APNS credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{apnsId ?? fcmId}': {error}"); + Logging.LogError($"Novu APNS credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{apnsId ?? fcmId}': {DescribeErrorBody(error)}"); return false; } @@ -376,7 +454,7 @@ private async Task SendNotification(string title, string body, string reci if (!result.IsSuccessStatusCode) { var error = await result.Content.ReadAsStringAsync(); - Logging.LogError($"Novu trigger failed ({(int)result.StatusCode} {result.StatusCode}) workflow '{workflowIdentifier}' subscriber '{recipientId}' event '{eventCode}': {error}"); + Logging.LogError($"Novu trigger failed ({(int)result.StatusCode} {result.StatusCode}) workflow '{workflowIdentifier}' subscriber '{recipientId}' event '{eventCode}': {DescribeErrorBody(error)}"); return false; } diff --git a/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs index c74b5fd90..4cb145dab 100644 --- a/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs +++ b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -88,6 +88,20 @@ private static async Task Render(CommunicationTestEmailContent content) return sent; } + [Test] + public async Task a_null_content_should_be_a_failed_send_not_a_throw() + { + // The template model is assembled before the provider's try block, so a null content would + // escape the catch that records every other failure as a false. + var senderMock = new Mock(); + var provider = new PostmarkTemplateProvider(senderMock.Object); + + var result = await provider.SendCommunicationTestMail("member@example.com", null); + + result.Should().BeFalse(); + senderMock.Verify(x => x.Send(It.IsAny()), Times.Never); + } + [Test] public async Task the_email_should_be_sent_as_html_using_the_shared_resgrid_template() { diff --git a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs index 1da919b0f..da963940e 100644 --- a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -127,6 +127,21 @@ protected void GivenCommanderIs(string userId) }); } + protected void GivenClosedCommandWithCommander(string userId) + { + _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = CommandId, + DepartmentId = DepartmentId, + CallId = CallId, + Name = "Structure Fire", + CurrentCommanderUserId = userId, + EstablishedByUserId = TestData.Users.TestUser3Id, + Status = (int)IncidentCommandStatus.Closed, + ClosedOn = DateTime.UtcNow + }); + } + protected void GivenNoCommand() { _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync((IncidentCommand)null); @@ -168,6 +183,38 @@ public async Task a_command_with_no_current_commander_should_not_provision_a_lin result.Should().BeNull("the seat is empty even though a command record exists"); } + [Test] + public async Task a_closed_command_should_not_provision_a_line() + { + // Closing a command does not clear CurrentCommanderUserId, so the "is the seat filled" + // check passes on a command nobody is running any more. + GivenClosedCommandWithCommander(TestData.Users.TestUser2Id); + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().BeNull("the incident is over even though the record still names a commander"); + _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_closed_command_should_not_reopen_an_existing_line() + { + // The reuse path rebinds and unarchives, so a closed command reaching it would lift the + // archive freeze that closing the command put on the line. + GivenClosedCommandWithCommander(TestData.Users.TestUser2Id); + + var existing = BuildCommanderLine(); + existing.IsArchived = true; + _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync(existing); + + var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null); + + result.Should().BeNull(); + existing.IsArchived.Should().BeTrue("the freeze the close applied has to survive"); + _channelRepositoryMock.Verify(x => x.RebindToIncidentCommandAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task a_new_line_should_be_anchored_to_the_call_not_the_commander() { diff --git a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs new file mode 100644 index 000000000..e519a0909 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Chat; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// A commander line opened as a unit carries a single member row, and it is the unit's — the caller + /// has no user row on the channel. The read pointer the response reports has to come from that row, + /// or the client is told the whole conversation is unread every time it opens the line. + /// + [TestFixture] + [NonParallelizable] + public class ChatControllerCommanderLineTests + { + private const int DepartmentId = 10; + private const string UserId = "requester-user"; + private const int UnitId = 7; + private const int CallId = 42; + private const string ChannelId = "commander-line-1"; + + private Mock _chatChannelService; + private Mock _chatPermissionService; + private ChatController _controller; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _chatChannelService = new Mock(); + _chatPermissionService = new Mock(); + + var featureToggleService = new Mock(); + featureToggleService + .Setup(x => x.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId, It.IsAny(), It.IsAny>())) + .ReturnsAsync(true); + + _chatPermissionService.Setup(x => x.CanSendAsUnitAsync(UserId, UnitId, DepartmentId)).ReturnsAsync(true); + + _chatChannelService + .Setup(x => x.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, UserId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatChannel + { + ChatChannelId = ChannelId, + DepartmentId = DepartmentId, + ChannelType = (int)ChatChannelType.IncidentCommanderLine, + CallId = CallId, + LastMessageSeq = 12 + }); + + // The unit's row is the only one on the channel; a lookup by user id finds nothing. + _chatChannelService + .Setup(x => x.GetUserMembershipAsync(ChannelId, UserId)) + .ReturnsAsync((ChatChannelMember)null); + + _chatChannelService + .Setup(x => x.GetUnitMembershipAsync(ChannelId, UnitId)) + .ReturnsAsync(new ChatChannelMember + { + ChatChannelMemberId = "member-1", + ChatChannelId = ChannelId, + DepartmentId = DepartmentId, + ParticipantType = (int)ChatParticipantType.Unit, + UnitId = UnitId, + LastReadSeq = 9, + NotificationPreference = 2 + }); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + _activity = new Activity("ChatControllerCommanderLineTests").Start(); + + _controller = new ChatController( + _chatChannelService.Object, + _chatPermissionService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + featureToggleService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + _activity?.Stop(); + } + + [Test] + public async Task CreateIncidentCommanderLine_AsUnit_ReportsTheUnitsReadState() + { + var response = await _controller.CreateIncidentCommanderLine( + new CreateIncidentCommanderLineInput { CallId = CallId, AsUnitId = UnitId }, CancellationToken.None); + + response.Value.Should().NotBeNull(); + response.Value.Data.MyLastReadSeq.Should().Be(9); + response.Value.Data.UnreadCount.Should().Be(3, "12 sent, 9 read"); + response.Value.Data.NotificationPreference.Should().Be(2); + + _chatChannelService.Verify(x => x.GetUnitMembershipAsync(ChannelId, UnitId), Times.Once); + _chatChannelService.Verify(x => x.GetUserMembershipAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateIncidentCommanderLine_AsUser_StillUsesTheUserRow() + { + _chatChannelService + .Setup(x => x.GetUserMembershipAsync(ChannelId, UserId)) + .ReturnsAsync(new ChatChannelMember + { + ChatChannelMemberId = "member-2", + ChatChannelId = ChannelId, + DepartmentId = DepartmentId, + ParticipantType = (int)ChatParticipantType.User, + UserId = UserId, + LastReadSeq = 5, + NotificationPreference = 1 + }); + + var response = await _controller.CreateIncidentCommanderLine( + new CreateIncidentCommanderLineInput { CallId = CallId }, CancellationToken.None); + + response.Value.Should().NotBeNull(); + response.Value.Data.MyLastReadSeq.Should().Be(5); + response.Value.Data.UnreadCount.Should().Be(7, "12 sent, 5 read"); + response.Value.Data.NotificationPreference.Should().Be(1); + + _chatChannelService.Verify(x => x.GetUnitMembershipAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 615050488..5fe6eecc6 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -313,7 +313,12 @@ public async Task> CreateIncidentCommande if (channel != null) { - var member = await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId); + // A line opened as a unit gets one member row, and it is the unit's -- there is no user row + // to find. Looking the caller up by user id returns null, which silently reports the whole + // channel as unread with default notification settings every time the line is opened. + var member = input.AsUnitId.HasValue + ? await _chatChannelService.GetUnitMembershipAsync(channel.ChatChannelId, input.AsUnitId.Value) + : await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId); result.Data = ConvertChannelResultData(channel, member); result.PageSize = 1; diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/ViewInvoice.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/ViewInvoice.cshtml index 202989ee6..027385340 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/ViewInvoice.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/ViewInvoice.cshtml @@ -88,9 +88,9 @@
Resgrid, LLC.
- 1802 North Carson Street
- Suite 157
- Carson City, NV 89701
+ 3079 Harrison Ave
+ Suite 110
+ South Lake Tahoe, CA 96150
diff --git a/Web/Resgrid.Web/Views/Shared/_Layout.cshtml b/Web/Resgrid.Web/Views/Shared/_Layout.cshtml index 0cd46a19a..ce1b77faf 100644 --- a/Web/Resgrid.Web/Views/Shared/_Layout.cshtml +++ b/Web/Resgrid.Web/Views/Shared/_Layout.cshtml @@ -62,9 +62,9 @@
Resgrid, LLC.
- 1802 North Carson Street
- Suite 157
- Carson City, NV 89701
+ 3079 Harrison Ave
+ Suite 110
+ South Lake Tahoe, CA 96150