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