From 8f384a551eff14c985f6df0d1838a543a9d37624 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 21 Sep 2026 11:57:27 -0700 Subject: [PATCH 1/2] RG-T51 Backoffice Fixes, Signal Support, --- .../Models/ChatbotPlatformCapabilities.cs | 4 +- Core/Resgrid.Config/ChatbotConfig.cs | 6 + .../Areas/User/Department/Department.ar.resx | 2 +- .../Areas/User/Department/Department.de.resx | 2 +- .../Areas/User/Department/Department.el.resx | 2 +- .../Areas/User/Department/Department.en.resx | 2 +- .../Areas/User/Department/Department.es.resx | 2 +- .../Areas/User/Department/Department.fr.resx | 2 +- .../Areas/User/Department/Department.it.resx | 2 +- .../Areas/User/Department/Department.pl.resx | 2 +- .../Areas/User/Department/Department.sv.resx | 2 +- .../Areas/User/Department/Department.uk.resx | 2 +- .../Areas/User/Workforce/Workforce.en.resx | 2 +- .../Areas/User/Workforce/Workforce.resx | 2 +- Core/Resgrid.Localization/Common.ar.resx | 1 + Core/Resgrid.Localization/Common.de.resx | 1 + Core/Resgrid.Localization/Common.el.resx | 1 + Core/Resgrid.Localization/Common.en.resx | 3 +- Core/Resgrid.Localization/Common.es.resx | 1 + Core/Resgrid.Localization/Common.fr.resx | 1 + Core/Resgrid.Localization/Common.it.resx | 1 + Core/Resgrid.Localization/Common.pl.resx | 1 + Core/Resgrid.Localization/Common.sv.resx | 1 + Core/Resgrid.Localization/Common.uk.resx | 1 + .../IBusinessOperationsAccessService.cs | 5 +- .../WorkOrders/WorkOrderCurrencies.cs | 41 +- .../BusinessOperationsAccessService.cs | 18 + .../ChecklistReportDocuments.cs | 2 +- .../CalOesMarsService.WorkItems.cs | 2 +- .../InventoryReportDocuments.cs | 2 +- .../InventoryWorkOrderAllocations.cs | 2 +- .../Resgrid.Services/Invoicing/BidsService.cs | 2 +- .../Invoicing/DeploymentService.Documents.cs | 2 +- .../Invoicing/InvoicingService.Delivery.cs | 2 +- .../Invoicing/TimeTrackingService.cs | 2 +- .../Records/RecordsBulkPacketService.cs | 2 +- .../RecordsDisclosureService.Packet.cs | 2 +- .../Records/RecordsDocumentService.cs | 4 +- Core/Resgrid.Services/WorkOrdersService.cs | 17 +- Docker/Signal/.env.example | 8 + Docker/Signal/.gitignore | 1 + Docker/Signal/README.md | 72 ++++ Docker/Signal/compose.setup.yml | 5 + Docker/Signal/compose.yml | 35 ++ Docker/Signal/gateway.conf.template | 49 +++ .../Adapters/HttpChatbotAdapter.cs | 1 + .../Adapters/SignalBotAdapter.cs | 45 +++ .../ChatbotProviderModule.cs | 1 + .../Services/ChatbotHttpClient.cs | 14 +- .../Chatbot/MessagingAccountsTests.cs | 12 + .../Chatbot/SignalMessagingTests.cs | 358 ++++++++++++++++++ .../Services/ChecklistP1M4Tests.cs | 2 +- .../Services/WorkOrderSettingsTests.cs | 23 ++ .../Resgrid.Tests/Web/DepartmentTimeTests.cs | 25 ++ .../User/LegacyCertificationsCutoverTests.cs | 212 +++++++++++ .../ProfileReportScheduleSecurityTests.cs | 2 +- .../Controllers/ChatbotPlatformsController.cs | 20 + .../Controllers/CertificationsController.cs | 26 +- .../User/Controllers/ChecklistsController.cs | 5 +- .../ChecklistsSchedulingController.cs | 1 - .../User/Controllers/ProfileController.cs | 29 +- .../User/Controllers/ReportsController.cs | 11 +- .../Areas/User/Controllers/TypesController.cs | 13 +- .../Certifications/CertificationViews.cs | 7 + .../WorkOrders/WorkOrderSettingChoices.cs | 4 +- .../Areas/User/Views/Bids/Index.cshtml | 2 +- .../Areas/User/Views/CalOesMars/Rates.cshtml | 2 +- .../User/Views/Certifications/Index.cshtml | 2 +- .../User/Views/Certifications/Person.cshtml | 47 +++ .../User/Views/Certifications/Record.cshtml | 2 +- .../User/Views/Certifications/_Shell.cshtml | 2 +- .../Areas/User/Views/Checklists/Index.cshtml | 2 +- .../Areas/User/Views/Checklists/_Shell.cshtml | 4 +- .../Areas/User/Views/Contracts/View.cshtml | 2 +- .../Areas/User/Views/Department/Types.cshtml | 6 +- .../User/Views/Deployments/_Shell.cshtml | 2 +- .../User/Views/Home/EditUserProfile.cshtml | 6 +- .../Areas/User/Views/Invoicing/Index.cshtml | 2 +- .../User/Views/Invoicing/RateCards.cshtml | 2 +- .../Areas/User/Views/Invoicing/_Shell.cshtml | 2 +- .../Areas/User/Views/Personnel/Roles.cshtml | 2 +- .../User/Views/RateSchedules/Edit.cshtml | 2 +- .../Areas/User/Views/Records/Print.cshtml | 2 +- .../Areas/User/Views/Records/PrintDiff.cshtml | 2 +- .../User/Views/Records/PrintRevision.cshtml | 2 +- .../CertificationComplianceReport.cshtml | 2 +- .../Areas/User/Views/Reports/Index.cshtml | 4 + .../UpcomingShiftReadinessReport.cshtml | 2 +- .../User/Views/Shared/_CalOesMarsShell.cshtml | 2 +- .../User/Views/Shared/_ContractorShell.cshtml | 2 +- .../User/Views/Shared/_MinimalLayout.cshtml | 10 +- .../User/Views/Shared/_Navigation.cshtml | 4 +- .../User/Views/Shared/_UserLayout.cshtml | 10 +- .../User/Views/Shared/_WorkforceShell.cshtml | 2 +- .../Shared/_WorkspaceHeaderActions.cshtml | 2 +- .../Areas/User/Views/WorkOrders/Index.cshtml | 2 +- .../Areas/User/Views/WorkOrders/_Shell.cshtml | 4 +- .../User/Views/Workforce/Contractors.cshtml | 2 +- .../Views/Workforce/Establishments.cshtml | 2 +- .../User/Views/Workforce/ResourceCosts.cshtml | 2 +- .../Areas/User/Views/Workforce/Worker.cshtml | 2 +- Web/Resgrid.Web/Helpers/DepartmentTime.cs | 11 +- .../Views/Shared/_RecoveryLayout.cshtml | 9 +- .../wwwroot/css/module-workspace.css | 45 ++- Web/Resgrid.Web/wwwroot/css/style.css | 44 +-- Web/Resgrid.Web/wwwroot/scss/_base.scss | 2 +- Web/Resgrid.Web/wwwroot/scss/_custom.scss | 6 +- Web/Resgrid.Web/wwwroot/scss/_md-skin.scss | 2 +- Web/Resgrid.Web/wwwroot/scss/_navigation.scss | 6 +- Web/Resgrid.Web/wwwroot/scss/_rtl.scss | 4 +- Web/Resgrid.Web/wwwroot/scss/_variables.scss | 2 +- .../Logic/ReportDeliveryLogic.cs | 14 +- 112 files changed, 1278 insertions(+), 148 deletions(-) create mode 100644 Docker/Signal/.env.example create mode 100644 Docker/Signal/.gitignore create mode 100644 Docker/Signal/README.md create mode 100644 Docker/Signal/compose.setup.yml create mode 100644 Docker/Signal/compose.yml create mode 100644 Docker/Signal/gateway.conf.template create mode 100644 Providers/Resgrid.Providers.Chatbot/Adapters/SignalBotAdapter.cs create mode 100644 Tests/Resgrid.Tests/Chatbot/SignalMessagingTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/LegacyCertificationsCutoverTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Certifications/Person.cshtml diff --git a/Core/Resgrid.Chatbot/Models/ChatbotPlatformCapabilities.cs b/Core/Resgrid.Chatbot/Models/ChatbotPlatformCapabilities.cs index 2629a13da..179ffd7b0 100644 --- a/Core/Resgrid.Chatbot/Models/ChatbotPlatformCapabilities.cs +++ b/Core/Resgrid.Chatbot/Models/ChatbotPlatformCapabilities.cs @@ -117,11 +117,11 @@ public static ChatbotPlatformCapabilities ForPlatform(ChatbotPlatform platform) }, ChatbotPlatform.Signal => new ChatbotPlatformCapabilities { - MaxMessageLength = 2000, + MaxMessageLength = 1500, SupportsMarkdown = false, SupportsButtons = false, SupportsEmbeds = false, - SupportsImages = true, + SupportsImages = false, SupportsSelectMenus = false, SupportsModals = false, SupportsQuickReplies = false, diff --git a/Core/Resgrid.Config/ChatbotConfig.cs b/Core/Resgrid.Config/ChatbotConfig.cs index 00d4fb0fe..7e862b50f 100644 --- a/Core/Resgrid.Config/ChatbotConfig.cs +++ b/Core/Resgrid.Config/ChatbotConfig.cs @@ -53,6 +53,12 @@ public static class ChatbotConfig public static string LineChannelAccessToken = ""; public static string LineChannelSecret = ""; public static string ViberBotToken = ""; + // Optional self-hosted signal-cli-rest-api gateway; see Docker/Signal/README.md. + // HTTPS origin, or HTTP on loopback only. Never expose the unauthenticated bridge API. + public static string SignalBridgeUrl = ""; + public static string SignalAccountNumber = ""; + public static string SignalBridgeApiToken = ""; + public static string SignalWebhookSecret = ""; public static string TeamsAppId = ""; public static string TeamsAppPassword = ""; public static string TeamsTenantId = ""; diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index a2756a403..3e681089a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -655,7 +655,7 @@ المنصات المسموح بها - أسماء المنصات مفصولة بفواصل المسموح بها لهذا القسم، أو * للكل. الأسماء الصالحة: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + أسماء المنصات مفصولة بفواصل المسموح بها لهذا القسم، أو * للكل. الأسماء الصالحة: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. السماح بالإرسال عبر المساعد diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index 843d31c66..d92761e7a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -606,7 +606,7 @@ Zugelassene Plattformen - Durch Kommas getrennte Plattformnamen, die diese Abteilung zulässt, oder * für alle. Gültige Namen: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Durch Kommas getrennte Plattformnamen, die diese Abteilung zulässt, oder * für alle. Gültige Namen: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Disposition über den Assistenten zulassen diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index f3840794e..1a2cf1048 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -685,7 +685,7 @@ Επιτρεπόμενες Πλατφόρμες - Ονόματα πλατφορμών χωρισμένα με κόμμα που επιτρέπει αυτό το τμήμα ή * για όλες. Έγκυρα ονόματα: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Ονόματα πλατφορμών χωρισμένα με κόμμα που επιτρέπει αυτό το τμήμα ή * για όλες. Έγκυρα ονόματα: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Να Επιτρέπεται Αποστολή μέσω Βοηθού diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 91dd0224d..adc9c1050 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -685,7 +685,7 @@ Allowed Platforms - Comma-separated platform names this department allows, or * for all. Valid names: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Comma-separated platform names this department allows, or * for all. Valid names: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Allow Dispatch via Assistant diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index aa8f54247..ae5a4c77f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -541,7 +541,7 @@ Plataformas permitidas - Nombres de plataformas separados por comas permitidos para este departamento, o * para todas. Nombres válidos: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Nombres de plataformas separados por comas permitidos para este departamento, o * para todas. Nombres válidos: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Permitir despacho mediante el asistente diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 2193a133b..b62554c00 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -606,7 +606,7 @@ Plateformes autorisées - Noms de plateformes séparés par des virgules autorisés pour ce service, ou * pour toutes. Noms valides : SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Noms de plateformes séparés par des virgules autorisés pour ce service, ou * pour toutes. Noms valides : SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Autoriser la répartition via l'assistant diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index cee7136fa..a6064fa7e 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -606,7 +606,7 @@ Piattaforme consentite - Nomi di piattaforme separati da virgole consentiti per questo dipartimento, oppure * per tutte. Nomi validi: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Nomi di piattaforme separati da virgole consentiti per questo dipartimento, oppure * per tutte. Nomi validi: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Consenti l'invio di chiamate tramite l'assistente diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index cdc3b3c30..78ee9e6ee 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -606,7 +606,7 @@ Dozwolone platformy - Nazwy platform oddzielone przecinkami dozwolone dla tego działu lub * dla wszystkich. Prawidłowe nazwy: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Nazwy platform oddzielone przecinkami dozwolone dla tego działu lub * dla wszystkich. Prawidłowe nazwy: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Zezwól na dysponowanie przez asystenta diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index b1530eb94..658ca4924 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -606,7 +606,7 @@ Tillåtna plattformar - Kommaseparerade plattformsnamn som denna avdelning tillåter, eller * för alla. Giltiga namn: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Kommaseparerade plattformsnamn som denna avdelning tillåter, eller * för alla. Giltiga namn: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Tillåt utlarmning via assistenten diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index 395638e97..4d07afa41 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -606,7 +606,7 @@ Дозволені платформи - Назви платформ, розділені комами, дозволені для цього підрозділу, або * для всіх. Дійсні назви: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, WebChat, Line, Viber, GoogleChat. + Назви платформ, розділені комами, дозволені для цього підрозділу, або * для всіх. Дійсні назви: SmsTwilio, SmsSignalWire, Discord, Slack, Telegram, WhatsApp, MicrosoftTeams, Signal, WebChat, Line, Viber, GoogleChat. Дозволити диспетчеризацію через асистент diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx index d9ebd6db6..205cc76df 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx @@ -310,7 +310,7 @@ Member Miles Missing inputs - My demographic response + My demographics Answer or update your voluntary self-identification for California pay data reporting. NAICS Name diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx index d9ebd6db6..205cc76df 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx @@ -310,7 +310,7 @@ Member Miles Missing inputs - My demographic response + My demographics Answer or update your voluntary self-identification for California pay data reporting. NAICS Name diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 1973c0a76..997f84a28 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -313,4 +313,5 @@ في Discord، استخدم /resgrid وأدخل LINK متبوعًا بالرمز في خيار message. استخدم /resgrid أيضًا للأوامر اللاحقة. القوى العاملة وعمليات الأعمال الجاهزية + لوحة المعلومات diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index fc464edba..fdc33ce08 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -746,4 +746,5 @@ Verwenden Sie in Discord /resgrid und geben Sie LINK gefolgt vom Code in der Option message ein. Nutzen Sie /resgrid auch für weitere Befehle. Personal & Betriebsverwaltung Einsatzbereitschaft + Übersicht diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 978f8a128..029ff8079 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -798,4 +798,5 @@ Στο Discord, χρησιμοποιήστε /resgrid και εισαγάγετε LINK μαζί με τον κωδικό στην επιλογή message. Χρησιμοποιήστε /resgrid και για τις επόμενες εντολές. Ανθρώπινο δυναμικό και διοίκηση Ετοιμότητα + Πίνακας ελέγχου diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 99b980e98..9fe6c22d1 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -781,7 +781,7 @@ Department Profile - Records preservation holds + Legal holds All records Messaging accounts Generate linking code @@ -798,4 +798,5 @@ For Discord, use /resgrid and enter LINK followed by your code in the message option. Use /resgrid for subsequent commands too. Workforce & Business Ops Readiness + Dashboard diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 11a48548e..27bb7311a 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -786,4 +786,5 @@ En Discord, usa /resgrid e introduce LINK seguido de tu código en la opción message. Usa /resgrid también para los comandos siguientes. Personal y operaciones Preparación + Panel diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index dfb622efe..4357f4f74 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -746,4 +746,5 @@ Sur Discord, utilisez /resgrid et saisissez LINK suivi du code dans l’option message. Utilisez aussi /resgrid pour les commandes suivantes. Effectifs et gestion Préparation opérationnelle + Tableau de bord diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index da1ed1b73..40fb0d592 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -746,4 +746,5 @@ Su Discord, usa /resgrid e inserisci LINK seguito dal codice nell’opzione message. Usa /resgrid anche per i comandi successivi. Personale e gestione Prontezza operativa + Panoramica diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 36a732f03..22e7f7496 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -746,4 +746,5 @@ W Discord użyj /resgrid i wpisz LINK oraz kod w opcji message. Kolejne polecenia również wysyłaj przez /resgrid. Kadry i zarządzanie Gotowość + Pulpit diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 15e2647eb..afbf51e45 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -746,4 +746,5 @@ I Discord använder du /resgrid och skriver LINK följt av koden i alternativet message. Använd även /resgrid för efterföljande kommandon. Personal och verksamhet Beredskap + Översikt diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 476cdfa71..c00c9c3ee 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -746,4 +746,5 @@ У Discord використайте /resgrid і введіть LINK та код у полі message. Для наступних команд також використовуйте /resgrid. Персонал і бізнес-операції Готовність + Панель огляду diff --git a/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs index 6fae67915..828014300 100644 --- a/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs +++ b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs @@ -6,10 +6,13 @@ namespace Resgrid.Model.Services /// Entitlement checks for the paid Business Operations add-on surfaces (Workforce & Business Operations plan, /// decision 42). Each check is master flag → capability flag → module switch → billing configured → live /// PaymentAddons window, exactly as ReadinessAccessService.CanUseMaintenanceAsync; no entitlement cache. - /// Free surfaces (pre-plans, certifications, the deployment core) never call this service. + /// Free surfaces never require paid entitlement; IsEnabledAsync only checks the module rollout. /// public interface IBusinessOperationsAccessService { + /// The master feature flag and department module switch are enabled, independently of paid entitlements. + Task IsEnabledAsync(int departmentId); + /// The department may create and work invoices, rate cards and billing profiles (Phase B). Task CanUseInvoicingAsync(int departmentId); diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs index 71469d791..e07d57f75 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderCurrencies.cs @@ -1,20 +1,45 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; namespace Resgrid.Model.WorkOrders { public static class WorkOrderCurrencies { - // Use the runtime's region catalog for both the selector and server validation. - public static IReadOnlyDictionary Options { get; } = CultureInfo.GetCultures(CultureTypes.SpecificCultures) - .Select(c => new RegionInfo(c.Name)) - .Where(r => r.ISOCurrencySymbol != "XXX") - .GroupBy(r => r.ISOCurrencySymbol, StringComparer.Ordinal) - .OrderBy(g => g.Key, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First().CurrencyEnglishName, StringComparer.Ordinal); + // Always present so a runtime without ICU data (globalization-invariant mode returns no specific + // cultures) still validates the currencies departments are most likely to have saved. + private static readonly IReadOnlyDictionary Baseline = new Dictionary(StringComparer.Ordinal) + { + ["AUD"] = "Australian Dollar", ["CAD"] = "Canadian Dollar", ["CHF"] = "Swiss Franc", ["DKK"] = "Danish Krone", ["EUR"] = "Euro", + ["GBP"] = "British Pound", ["JPY"] = "Japanese Yen", ["MXN"] = "Mexican Peso", ["NOK"] = "Norwegian Krone", ["NZD"] = "New Zealand Dollar", + ["PLN"] = "Polish Zloty", ["SEK"] = "Swedish Krona", ["USD"] = "US Dollar" + }; + + // The runtime's region catalog feeds both the selector and server validation; the baseline is merged in. + public static IReadOnlyDictionary Options { get; } = Load(); public static bool IsSupported(string currency) => currency != null && Options.ContainsKey(currency); + + private static IReadOnlyDictionary Load() + { + var options = new SortedDictionary(StringComparer.Ordinal); + try + { + foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) + { + RegionInfo region; + try { region = new RegionInfo(culture.Name); } catch (ArgumentException) { continue; } + var code = region.ISOCurrencySymbol; + // ICU reports "XXX" or the "¤¤" placeholder for world/region cultures; ISO 4217 codes are three ASCII letters. + if (!IsIsoCode(code) || code == "XXX" || options.ContainsKey(code)) continue; + options[code] = string.IsNullOrWhiteSpace(region.CurrencyEnglishName) ? code : region.CurrencyEnglishName; + } + } + catch (Exception) { options.Clear(); } // A broken culture catalog must not poison the type initializer. + foreach (var pair in Baseline) options.TryAdd(pair.Key, pair.Value); + return options; + } + + private static bool IsIsoCode(string code) => code?.Length == 3 && code[0] is >= 'A' and <= 'Z' && code[1] is >= 'A' and <= 'Z' && code[2] is >= 'A' and <= 'Z'; } } diff --git a/Core/Resgrid.Services/BusinessOperationsAccessService.cs b/Core/Resgrid.Services/BusinessOperationsAccessService.cs index ce9a96511..099936c11 100644 --- a/Core/Resgrid.Services/BusinessOperationsAccessService.cs +++ b/Core/Resgrid.Services/BusinessOperationsAccessService.cs @@ -24,6 +24,24 @@ public BusinessOperationsAccessService(IFeatureToggleService flags, IDepartmentS public Task CanUseInvoicingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CustomerInvoicing); + public async Task IsEnabledAsync(int departmentId) + { + if (departmentId <= 0) + return false; + try + { + if ((await _flags.EvaluateFreshAsync(FeatureFlagKeys.BusinessOperations, departmentId))?.IsEnabled != true) + return false; + var settings = await _settings.GetDepartmentModuleSettingsAsync(departmentId, bypassCache: true); + return settings != null && !settings.BusinessOperationsDisabled; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } + public Task CanUseContractorBillingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.ContractorBilling); public Task CanUseCostRecoveryAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CalOesMars); public Task CanUseWorkforceAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.WorkforceInternalCosting); diff --git a/Core/Resgrid.Services/ChecklistReportDocuments.cs b/Core/Resgrid.Services/ChecklistReportDocuments.cs index 9e155a3b2..9cfa98ec7 100644 --- a/Core/Resgrid.Services/ChecklistReportDocuments.cs +++ b/Core/Resgrid.Services/ChecklistReportDocuments.cs @@ -20,7 +20,7 @@ public static class ChecklistReportDocuments private static string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.CurrentCulture)); private static string Cell(object value) => "" + H(value) + ""; private static string Head(params string[] keys) => "" + string.Concat(keys.Select(k => "" + H(Text(k)) + "")) + ""; - private static string Page(string title, string body) => "" + H(Text(title)) + "

" + H(Text(title)) + "

" + body + ""; + private static string Page(string title, string body) => "Resgrid | " + H(Text(title)) + "

" + H(Text(title)) + "

" + body + ""; public static string Locked() => Page("ChecklistComplianceReport", "

" + H(Text("ScheduledReportProtected")) + "

"); public static string Compliance(ChecklistComplianceSummary report, bool missedOnly = false) { diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs index 7693e4b22..59a879b9e 100644 --- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs @@ -604,7 +604,7 @@ public async Task RenderWorkItemHtmlAsync(string workItemId, int departm var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); var manifest = await BuildManifestAsync(item, departmentId, null); var sb = new StringBuilder(); - sb.Append("Prepared for MARS"); + sb.Append("Resgrid | Prepared for MARS"); sb.Append("

Prepared for Cal OES MARS — ").Append(WebUtility.HtmlEncode(((CalOesMarsRecordTypes)item.RecordType).ToString())).Append("

"); sb.Append("

Authority profile ").Append(WebUtility.HtmlEncode(item.AuthorityProfileCode ?? "—")).Append(" · rate profile ").Append(WebUtility.HtmlEncode(item.RateProfileVersion ?? "—")).Append(" · checksum ").Append(WebUtility.HtmlEncode(manifest.Checksum)).Append(" · not an accepted MARS import file

"); sb.Append(""); diff --git a/Core/Resgrid.Services/InventoryReportDocuments.cs b/Core/Resgrid.Services/InventoryReportDocuments.cs index 79eff65b7..e47122e47 100644 --- a/Core/Resgrid.Services/InventoryReportDocuments.cs +++ b/Core/Resgrid.Services/InventoryReportDocuments.cs @@ -26,7 +26,7 @@ private static CultureInfo ReportCulture(CultureInfo culture) public static string Title(InventoryReportKind kind, CultureInfo culture = null) => Text("M5Report" + kind, culture); private static string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.InvariantCulture)); private static string Page(InventoryReportKind kind, string body, CultureInfo culture) => "" + H(Title(kind, culture)) + "

" + H(Title(kind, culture)) + "

" + body + ""; + + "\">Resgrid | " + H(Title(kind, culture)) + "

" + H(Title(kind, culture)) + "

" + body + ""; public static string Locked(InventoryReportKind kind, CultureInfo culture = null) { culture = ReportCulture(culture); diff --git a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs index 0806ee80d..296037fa2 100644 --- a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs +++ b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs @@ -51,7 +51,7 @@ private async Task ValidatePartMovementAsync(int departmentId, InventoryPosting if (line.WorkOrderPartId == null || line.WorkOrderPartMovementId == null || line.ReversesTransactionId != null) throw new InventoryException(409, "WorkOrderPostingRequired"); var part = await _workOrders.GetAsync(departmentId, line.WorkOrderPartId); var movement = await _workOrders.GetAsync(departmentId, line.WorkOrderPartMovementId); - if (part == null || !part.Staged || part.VoidedOn.HasValue || movement?.PartId != part.Id || movement.WorkOrderId != part.WorkOrderId || movement.Cancelled || movement.InventoryTransactionId != null + if (part == null || movement == null || !part.Staged || part.VoidedOn.HasValue || movement.PartId != part.Id || movement.WorkOrderId != part.WorkOrderId || movement.Cancelled || movement.InventoryTransactionId != null || movement.Quantity != line.Quantity || movement.FromLocationId != line.FromLocationId || movement.ToLocationId != line.ToLocationId || line.AssetId != part.ReservedAssetId || line.LotId != part.ReservedLotId) throw new InventoryException(409, "ReferenceUnavailable"); var kind = (WorkOrderPartMovementKind)movement.Kind; if (kind == WorkOrderPartMovementKind.Issue) diff --git a/Core/Resgrid.Services/Invoicing/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs index a4a329a31..e3f8e05b4 100644 --- a/Core/Resgrid.Services/Invoicing/BidsService.cs +++ b/Core/Resgrid.Services/Invoicing/BidsService.cs @@ -471,7 +471,7 @@ public static string RenderBidHtml(BidRenderModel model) var bid = model.Bid; var currency = model.Currency ?? "USD"; var sb = new StringBuilder(); - sb.Append("").Append(E($"Bid #{bid.BidNumber}")).Append(""); + sb.Append("Resgrid | ").Append(E($"Bid #{bid.BidNumber}")).Append(""); sb.Append(""); sb.Append("

").Append(E(model.DepartmentName)).Append("

"); sb.Append("

Bid #").Append(bid.BidNumber).Append(" ").Append(E(((BidStatuses)bid.Status).ToString())).Append("

"); diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs index e48e22c88..84af9f7b8 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs @@ -48,7 +48,7 @@ public static string RenderManifestHtml(Deployment deployment, Department depart string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); string D(DateTime? value) => value.HasValue ? (department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") + " UTC" : value.Value.TimeConverter(department).ToString("yyyy-MM-dd HH:mm")) : "—"; var sb = new StringBuilder(); - sb.Append("Manifest"); + sb.Append("Resgrid | Manifest"); sb.Append("

").Append(E(department?.Name)).Append(" — Deployment Manifest

"); sb.Append("
").Append(E(deployment.Name)).Append(" · ").Append(E(((DeploymentStatuses)deployment.Status).ToString())).Append("
"); sb.Append("
BoxValueSource
"); diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs index be5db91f3..2a3297c90 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs @@ -147,7 +147,7 @@ public static string RenderInvoiceHtml(InvoiceRenderModel model) var invoice = model.Invoice; var currency = invoice.Currency ?? "USD"; var sb = new StringBuilder(); - sb.Append("").Append(E($"Invoice #{invoice.InvoiceNumber}")).Append(""); + sb.Append("Resgrid | ").Append(E($"Invoice #{invoice.InvoiceNumber}")).Append(""); sb.Append(""); sb.Append("
Incident # ").Append(E(deployment.IncidentNumber)).Append("Resource order # ").Append(E(deployment.ResourceOrderNumber)).Append("Request # ").Append(E(deployment.RequestNumber)).Append("
"); diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs index a7ad7301d..712d1adbc 100644 --- a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs +++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs @@ -443,7 +443,7 @@ public static string RenderTimeReportHtml(DeploymentTimeReport report, Deploymen string T(DateTime value) => department == null ? value.ToString("HH:mm") : value.TimeConverter(department).ToString("HH:mm"); string D(DateTime? value) => value.HasValue ? (department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") : value.Value.TimeConverter(department).ToString("yyyy-MM-dd HH:mm")) : "—"; var sb = new StringBuilder(); - sb.Append("Daily Time Report"); + sb.Append("Resgrid | Daily Time Report"); sb.Append("

").Append(E(department?.Name)).Append(" — Daily Time Report #").Append(report.ReportNumber).Append("

"); sb.Append("
").Append(E(deployment?.Name)).Append(" · ").Append(report.ReportDate.ToString("yyyy-MM-dd")).Append(" · ").Append(E(((DeploymentTimeReportStatuses)report.Status).ToString())).Append("
"); sb.Append(""); diff --git a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs index 9b316f290..63704dca9 100644 --- a/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs +++ b/Core/Resgrid.Services/Records/RecordsBulkPacketService.cs @@ -218,7 +218,7 @@ private static bool IsPlausibleEmail(string value) internal static string CompiledHtml(List<(RmsOperationalRecord Record, RecordDocument Document, string Html)> entries, string title, Department department, DateTime now, string userId) { - var html = new StringBuilder("").Append(E(title)).Append(""); + var html = new StringBuilder("Resgrid | ").Append(E(title)).Append(""); html.Append("

").Append(E(title)).Append("

").Append(E(department?.Name ?? string.Empty)).Append(" · compiled ").Append(E(now.ToString("u"))).Append(" · ").Append(entries.Count).Append(" record(s)

"); html.Append("

Manifest

Incident # ").Append(E(report.IncidentNumber)).Append("Resource order # ").Append(E(report.ResourceOrderNumber)).Append("Request # ").Append(E(report.RequestNumber)).Append("
"); for (var i = 0; i < entries.Count; i++) diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs index 66daee530..932aaba49 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs @@ -266,7 +266,7 @@ private static JObject PrepareDisclosure(RecordDocument doc, string profile, Lis private static string PacketHtml(RmsDisclosureRequest request, JArray produced, JArray documents, List withheld, DateTime now) { string E(string value) => WebUtility.HtmlEncode(value ?? ""); - var html = new StringBuilder("Records disclosure

Records disclosure "); + var html = new StringBuilder("Resgrid | Records disclosure

Records disclosure "); html.Append(E(request.RequestNumber)).Append("

").Append(E(request.JurisdictionProfile)).Append(" · Prepared ").Append(now.ToString("u")).Append("

Contents

    "); foreach (var item in produced) html.Append("
  1. ").Append(E((string)item["record_number"])).Append(" · revision ").Append((int)item["revision_number"]).Append("
  2. "); html.Append("

Attachments are separate files in the packet. The manifest records each file and checksum.

"); diff --git a/Core/Resgrid.Services/Records/RecordsDocumentService.cs b/Core/Resgrid.Services/Records/RecordsDocumentService.cs index b3e23004a..218d2f7cd 100644 --- a/Core/Resgrid.Services/Records/RecordsDocumentService.cs +++ b/Core/Resgrid.Services/Records/RecordsDocumentService.cs @@ -124,7 +124,7 @@ public async Task RenderHtmlAsync(int departmentId, string userId, Recor var content = JObject.Parse(document.ContentJson); var resolved = await ResolveLayoutAsync(departmentId, content); var config = resolved.Layout.Branding ?? RecordsPrintLayoutConfig.Default(); - var html = new StringBuilder("Department record"); + var html = new StringBuilder("Resgrid | Department record"); if (config.ShowLogo && branding?.HasLogo == true) { var logo = await _branding.GetMediaAsync(departmentId, DepartmentProfileMediaKind.PrintHeader); @@ -272,7 +272,7 @@ public async Task RenderDiffPdfAsync(int departmentId, string userId, st var to = await GetAsync(departmentId, userId, recordId, kind, toRevisionId, true); if (from == null || to == null) throw new InvalidOperationException("A requested revision is unavailable."); var changes = await DiffAsync(departmentId, userId, from, to); - var html = new StringBuilder("Report revision changes"); + var html = new StringBuilder("Resgrid | Report revision changes"); html.Append("

").Append(E(config.UseShortName ? branding?.ShortName : branding?.DisplayName)).Append("

Report ").Append(E(to.RecordNumber)).Append(" — revision ").Append(from.RevisionNumber).Append(" to ").Append(to.RevisionNumber).Append("

"); html.Append("

Only changes visible under your current permissions are shown. Department custom fields are included.

"); if (from.WithheldFields.Count > 0 || to.WithheldFields.Count > 0) html.Append("

Some fields are withheld under your current permissions.

"); diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index d154d312a..228f4eb6f 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -63,12 +63,19 @@ private async Task ReadOrderAsync(ChecklistActor actor, string id) } private async Task RevealAsync(ChecklistActor actor, T row) where T : WorkOrderRow { - if (row == null || row.DepartmentId != actor.DepartmentId) throw new WorkOrderException(404, "Unavailable"); - var plain = !string.IsNullOrEmpty(row.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content); - var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, new[] { (row, Key(row)) }, WorkOrderTables.Fields(), actor.GrantToken, actor.UserId); - if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && plain) throw new WorkOrderException(403, "ProtectedDataRequired"); + await RevealAllAsync(actor, new[] { row }); return row; } + // One broker round trip per batch; the outcome is all-or-nothing exactly like the single-row path. + private async Task> RevealAllAsync(ChecklistActor actor, IReadOnlyList rows) where T : WorkOrderRow + { + if (rows.Any(row => row == null || row.DepartmentId != actor.DepartmentId)) throw new WorkOrderException(404, "Unavailable"); + if (rows.Count == 0) return rows; + var plain = rows.Any(row => !string.IsNullOrEmpty(row.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)); + var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, rows.Select(row => (row, Key(row))).ToList(), WorkOrderTables.Fields(), actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && plain) throw new WorkOrderException(403, "ProtectedDataRequired"); + return rows; + } private async Task SaveAsync(ChecklistActor actor, T row, bool insert = false) where T : WorkOrderRow { row.UpdatedOn = Now; @@ -341,7 +348,7 @@ public async Task ListAsync(ChecklistActor actor, WorkOrderFilter private async Task> ChildrenAsync(ChecklistActor actor, string id) where T : WorkOrderRow { var result = new List(); - for (var skip = 0; ; skip += 500) { var page = await _store.ChildrenAsync(actor.DepartmentId, id, skip); foreach (var row in page) result.Add(await RevealAsync(actor, row)); if (page.Count < 500) return result; if (skip >= 9500) throw new WorkOrderException(400, "HistoryLimit"); } + for (var skip = 0; ; skip += 500) { var page = await _store.ChildrenAsync(actor.DepartmentId, id, skip); result.AddRange(await RevealAllAsync(actor, page)); if (page.Count < 500) return result; if (skip >= 9500) throw new WorkOrderException(400, "HistoryLimit"); } } public async Task GetAsync(ChecklistActor actor, string id) { diff --git a/Docker/Signal/.env.example b/Docker/Signal/.env.example new file mode 100644 index 000000000..b07ca3af9 --- /dev/null +++ b/Docker/Signal/.env.example @@ -0,0 +1,8 @@ +# Pin maintained images that you have validated; webhook support and json-rpc mode are required. +SIGNAL_BRIDGE_IMAGE= +SIGNAL_GATEWAY_IMAGE= +# Generate two independent random base64url secrets, 32-128 characters, without padding. +SIGNAL_API_TOKEN= +SIGNAL_WEBHOOK_SECRET= +# Hostname only (no scheme, path or port); certificate must be trusted by the nginx container. +RESGRID_API_HOST= diff --git a/Docker/Signal/.gitignore b/Docker/Signal/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/Docker/Signal/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Docker/Signal/README.md b/Docker/Signal/README.md new file mode 100644 index 000000000..d5118e3fc --- /dev/null +++ b/Docker/Signal/README.md @@ -0,0 +1,72 @@ +# Signal bridge for Resgrid + +Resgrid supports private Signal commands, replies and proactive personnel notifications using the community-maintained [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) and [signal-cli](https://github.com/AsamK/signal-cli). This is an optional, self-hosted integration, not an official Signal bot service. Facebook Messenger remains excluded. + +The operator supplies a dedicated Signal account and runs this bridge. Departments allow `Signal` and enable proactive notifications; each person links their own Signal account in **Profile → Messaging accounts**. Provider credentials and the sending account are system-wide, not department-owned. No database migration or new .NET package is needed. + +## 1. Prepare the bridge + +Use a dedicated account in the Signal mobile app. The REST bridge links as a secondary device; do not register over an existing personal account. It holds Signal account keys and processes decrypted message text, so its host and persistent volume belong inside the Resgrid trust boundary. + +Copy `.env.example` to `.env` in this directory and fill in: + +- `SIGNAL_BRIDGE_IMAGE`: an explicitly pinned, maintained `bbernhard/signal-cli-rest-api` image supporting `RECEIVE_WEBHOOK_URL` in `json-rpc` mode. Revalidate after bridge upgrades; Signal protocol compatibility requires keeping signal-cli current. +- `SIGNAL_GATEWAY_IMAGE`: a pinned official `nginx` Alpine image, with its standard template entrypoint and CA bundle. +- `SIGNAL_API_TOKEN` and `SIGNAL_WEBHOOK_SECRET`: two different random secrets, each 32–128 base64url characters (`A-Z`, `a-z`, `0-9`, `_`, `-`), without padding. For example, generate each with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Do not reuse the example names as values or commit secrets. +- `RESGRID_API_HOST`: your Resgrid API's public DNS hostname, without scheme, path or port. It must have a valid, trusted HTTPS certificate. + +From this directory, start the temporary setup configuration: + +```sh +docker compose -f compose.yml -f compose.setup.yml up -d +``` + +On the bridge host, open `http://127.0.0.1:8089/v1/qrcodelink?device_name=Resgrid`, then scan the QR code from **Signal → Settings → Linked devices** on the dedicated account's phone. For a remote host, use a secure local tunnel to port 8089. Once linked, recreate the containers using only the base configuration: + +```sh +docker compose -f compose.yml up -d --force-recreate +``` + +This removes the temporary administration port while preserving `signal-data`. The gateway publishes only `127.0.0.1:8088` for authenticated outbound sends. It forwards `/v2/send` with the configured bearer token; other paths are denied. The bridge's administrative API has no published port in normal operation. + +The bridge sends full JSON-RPC receive events to the gateway on port 8081. The gateway attaches the shared webhook secret and forwards them over verified HTTPS to `/api/v4/ChatbotPlatforms/Signal`. **Never publish port 8081, route public traffic to it, or attach untrusted containers to this Docker network.** The bridge does not add an authentication header itself; pointing its webhook directly at Resgrid will receive HTTP 401. Access logs are disabled on the gateway, and bridge logging is set to `warn` to avoid debug message bodies. Configure the host's log retention and volume access appropriately. + +## 2. Configure Resgrid + +Set these static configuration fields through the existing deployment configuration/secret mechanism, consistently on the API, MVC and workers: + +| JSON key | Value | +|---|---| +| `ChatbotConfig.SignalBridgeUrl` | `http://127.0.0.1:8088` if Resgrid runs directly on that host; otherwise a secured HTTPS gateway origin | +| `ChatbotConfig.SignalAccountNumber` | Dedicated account's exact E.164 phone number, such as `+12025550123` | +| `ChatbotConfig.SignalBridgeApiToken` | Same value as gateway `SIGNAL_API_TOKEN` | +| `ChatbotConfig.SignalWebhookSecret` | Same value as gateway `SIGNAL_WEBHOOK_SECRET` | + +Environment variable names follow the existing convention, for example `RESGRID:ChatbotConfig:SignalBridgeUrl`. Restart the hosts after changing configuration. The bridge URL must be an origin with no credentials, query, fragment or path; HTTPS is required except for loopback HTTP. + +If Resgrid runs in containers or on other machines, their loopback address is **not** the bridge host. Put your existing HTTPS reverse proxy in front of the gateway's localhost port 8088, preserve the Authorization header and allow only the Resgrid hosts. Set `SignalBridgeUrl` to that HTTPS origin. Keep the internal webhook and raw administration API private. Private HTTP across hosts/containers is deliberately not accepted by the adapter. + +Shared Redis, the chatbot queue consumer, matching encryption/environment settings, and existing account-linking prerequisites still apply; see the [messaging deployment guide](../../docs/architecture/messaging-platforms.md#deployment-prerequisites). + +## 3. Enable departments and link people + +1. In **Department settings → Chatbot settings**, enable the assistant and add `Signal` to allowed platforms (or use `*`). Enable proactive notifications for dispatches, messages and notifications. +2. Each person opens their own **Profile → Messaging accounts**, generates a code and sends `LINK ABC123` privately to the dedicated Signal account. The bot replies through Signal. People with hidden phone numbers are supported: Resgrid links the verified sender UUID, not their phone number or username. +3. Send `help`, then an authorized read-only command. `STOP` / `UNLINK` or the profile's unlink action removes the account link. Group messages, sync events, edits, receipts and attachment-only messages are ignored. + +Signal gets the same private personnel dispatch/cancellation, messages, general/calendar notifications and trouble alerts as the existing native channel path, without an active chatbot session. Existing department policies, recipient checks and protected-data rules apply. There is no group-channel broadcasting, unit-device mirroring, weather delivery or internal Resgrid chat mirroring in this integration. + +## 4. Acceptance checks and operating limits + +- Validate the gateway configuration and access boundaries: unauthenticated `/v2/send` returns 401; exposed `/receive` returns 404; the raw bridge API and internal webhook port are inaccessible externally. +- After linking, verify a private command and its reply, then a test dispatch, message and notification to an authorized test person. Verify that removing `Signal` from the department allow-list or unlinking that person prevents subsequent broadcasts. No provider messages are sent by the automated tests. +- Verify the configured account matches webhook `params.account` (or `params.result.account` for a subscription wrapper). The adapter requires a UUID in `sourceUuid` and matching original envelope/data-message timestamps. Messages older than 15 minutes are ignored; malformed, future, unsigned or wrong-account messages cannot execute commands. Receipt IDs combine account and original timestamp, with sender identity included in the existing replay cache key. +- HTTP success plus the bridge's positive send timestamp means provider acceptance, not proof of delivery/read. Failed HTTP requests, missing timestamps and provider errors are not reported as successful sends. Blocking the account, untrusted identity keys, registration changes and provider throttling can interrupt delivery. +- The upstream webhook implementation attempts forwarding once; it has no durable retry spool. A bridge/API outage can therefore lose an inbound command before Resgrid's queue accepts it. Monitor bridge/gateway failures, and resend only after checking whether a command took effect. Once accepted, existing Resgrid execution claims and reply retries apply. Proactive delivery remains best effort with no new durable per-recipient outbox or read-receipt tracking; use the existing additional delivery channels as needed. +- To disable, clear the Signal configuration or remove it from department allow-lists. Preserve the bridge volume if you intend to resume the same account. Account registration, keys, credentials and live platform acceptance are operator rollout steps, not performed by the code change. + +Protocol references: [bridge webhook implementation](https://github.com/bbernhard/signal-cli-rest-api/blob/master/src/client/jsonrpc2.go), [Signal JSON-RPC message format](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc), [REST send/UUID handling](https://github.com/bbernhard/signal-cli-rest-api/blob/master/src/client/client.go), and [NGINX proxy TLS verification](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_verify). + +## Code verification (2026-09-20) + +The Release solution build and all 488 focused chatbot/communication tests passed with zero failures or skips. Coverage includes Signal UUID linking, webhook authentication and event filtering, original timestamps, private replies, department-gated broadcasts, provider rejection, Unicode splitting, legacy/current response shapes and account-page availability. Both normal/setup Compose configurations validate and expose only their intended loopback ports. The local Docker engine was unavailable, so gateway runtime tests and live Signal acceptance have not been performed. No Signal account or production configuration was changed. diff --git a/Docker/Signal/compose.setup.yml b/Docker/Signal/compose.setup.yml new file mode 100644 index 000000000..c0c5ca11e --- /dev/null +++ b/Docker/Signal/compose.setup.yml @@ -0,0 +1,5 @@ +# Temporary localhost-only access for an operator to link the dedicated Signal account. +services: + signal-api: + ports: + - "127.0.0.1:8089:8080" diff --git a/Docker/Signal/compose.yml b/Docker/Signal/compose.yml new file mode 100644 index 000000000..a63c723ee --- /dev/null +++ b/Docker/Signal/compose.yml @@ -0,0 +1,35 @@ +services: + signal-api: + image: ${SIGNAL_BRIDGE_IMAGE:?Set a tested signal-cli-rest-api image tag or digest} + restart: unless-stopped + environment: + MODE: json-rpc + RECEIVE_WEBHOOK_URL: http://signal-gateway:8081/receive + JSON_RPC_IGNORE_ATTACHMENTS: "true" + JSON_RPC_IGNORE_STORIES: "true" + JSON_RPC_IGNORE_AVATARS: "true" + JSON_RPC_IGNORE_STICKERS: "true" + LOG_LEVEL: warn + volumes: + - signal-data:/home/.local/share/signal-cli + networks: [signal] + + signal-gateway: + image: ${SIGNAL_GATEWAY_IMAGE:?Set a tested nginx alpine image tag or digest} + restart: unless-stopped + environment: + SIGNAL_API_TOKEN: ${SIGNAL_API_TOKEN:?Set the outbound gateway token} + SIGNAL_WEBHOOK_SECRET: ${SIGNAL_WEBHOOK_SECRET:?Set the inbound webhook secret} + RESGRID_API_HOST: ${RESGRID_API_HOST:?Set the Resgrid HTTPS API hostname} + NGINX_ENVSUBST_FILTER: "^(SIGNAL_API_TOKEN|SIGNAL_WEBHOOK_SECRET|RESGRID_API_HOST)$" + ports: + - "127.0.0.1:8088:8080" + volumes: + - ./gateway.conf.template:/etc/nginx/templates/default.conf.template:ro + networks: [signal] + depends_on: [signal-api] + +networks: + signal: +volumes: + signal-data: diff --git a/Docker/Signal/gateway.conf.template b/Docker/Signal/gateway.conf.template new file mode 100644 index 000000000..5cb9c64b2 --- /dev/null +++ b/Docker/Signal/gateway.conf.template @@ -0,0 +1,49 @@ +# Resolve service names again after container replacement rather than retaining an old IP. +resolver 127.0.0.11 valid=30s ipv6=off; +resolver_timeout 5s; + +# The published port exposes only authenticated sending, never registration or intake. +server { + listen 8080; + server_name _; + access_log off; + client_max_body_size 256k; + location = /v2/send { + if ($request_method != POST) { return 405; } + if ($http_authorization != "Bearer ${SIGNAL_API_TOKEN}") { return 401; } + set $signal_upstream signal-api:8080; + proxy_pass http://$signal_upstream/v2/send; + proxy_set_header Authorization ""; + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + proxy_next_upstream off; + } + location / { return 404; } +} + +# This port is reachable only on the dedicated Docker network. Do not publish it +# or attach untrusted containers: it authenticates bridge events to Resgrid. +server { + listen 8081; + server_name _; + access_log off; + client_max_body_size 256k; + location = /receive { + if ($request_method != POST) { return 405; } + set $resgrid_upstream ${RESGRID_API_HOST}; + proxy_pass https://$resgrid_upstream/api/v4/ChatbotPlatforms/Signal; + proxy_pass_request_headers off; + proxy_set_header Host ${RESGRID_API_HOST}; + proxy_set_header Content-Type application/json; + proxy_set_header X-Resgrid-Signal-Secret "${SIGNAL_WEBHOOK_SECRET}"; + proxy_ssl_server_name on; + proxy_ssl_name ${RESGRID_API_HOST}; + proxy_ssl_verify on; + proxy_ssl_verify_depth 3; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + proxy_next_upstream off; + } + location / { return 404; } +} diff --git a/Providers/Resgrid.Providers.Chatbot/Adapters/HttpChatbotAdapter.cs b/Providers/Resgrid.Providers.Chatbot/Adapters/HttpChatbotAdapter.cs index eca3125e4..8e2a28f4e 100644 --- a/Providers/Resgrid.Providers.Chatbot/Adapters/HttpChatbotAdapter.cs +++ b/Providers/Resgrid.Providers.Chatbot/Adapters/HttpChatbotAdapter.cs @@ -23,6 +23,7 @@ public abstract class HttpChatbotAdapter : IExternalChatbotAdapter ChatbotPlatform.Telegram => !string.IsNullOrWhiteSpace(Config.ChatbotConfig.TelegramWebhookSecretToken), ChatbotPlatform.WhatsApp => Uri.TryCreate(Config.ChatbotConfig.WhatsAppWebhookUrl, UriKind.Absolute, out var url) && url.Scheme == "https", ChatbotPlatform.Line => !string.IsNullOrWhiteSpace(Config.ChatbotConfig.LineChannelSecret), + ChatbotPlatform.Signal => SignalBotAdapter.IsValidSecret(Config.ChatbotConfig.SignalWebhookSecret), _ => true }); public virtual bool CanInitiateProactively => IsConfigured; diff --git a/Providers/Resgrid.Providers.Chatbot/Adapters/SignalBotAdapter.cs b/Providers/Resgrid.Providers.Chatbot/Adapters/SignalBotAdapter.cs new file mode 100644 index 000000000..eda2e19d9 --- /dev/null +++ b/Providers/Resgrid.Providers.Chatbot/Adapters/SignalBotAdapter.cs @@ -0,0 +1,45 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Resgrid.Chatbot.Models; +using Resgrid.Config; +using Resgrid.Providers.Chatbot.Services; + +namespace Resgrid.Providers.Chatbot.Adapters +{ + /// Private Signal text delivery through an operator-owned signal-cli REST gateway. + public class SignalBotAdapter : HttpChatbotAdapter + { + public SignalBotAdapter(ChatbotHttpClient http) : base(http) { } + public override ChatbotPlatform Platform => ChatbotPlatform.Signal; + public override bool IsConfigured => IsValidBridgeUrl(ChatbotConfig.SignalBridgeUrl) + && Regex.IsMatch(ChatbotConfig.SignalAccountNumber ?? "", @"\A\+[1-9][0-9]{6,14}\z") + && IsValidSecret(ChatbotConfig.SignalBridgeApiToken); + // Conservative text chunks, leaving headroom for the bridge's UTF-8 message limit. + protected override int MessageLength => 1500; + public static bool IsValidSecret(string value) => Regex.IsMatch(value ?? "", @"\A[A-Za-z0-9_-]{32,128}\z"); + public static bool IsValidBridgeUrl(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) + && (uri.Scheme == "https" || (uri.Scheme == "http" && uri.IsLoopback)) + && string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) + && string.IsNullOrEmpty(uri.Fragment) && uri.AbsolutePath == "/"; + + protected override async Task SendTextAsync(string recipient, string text, ChatbotMessage inbound) + { + // Only verified Signal account UUIDs are linked. Never accept phone, username, or group routing. + if (!Guid.TryParseExact(recipient, "D", out var userId) || userId == Guid.Empty) + throw new ArgumentException("A linked Signal account is required."); + var result = await Http.PostJsonAsync(ChatbotConfig.SignalBridgeUrl.TrimEnd('/') + "/v2/send", + new { number = ChatbotConfig.SignalAccountNumber, recipients = new[] { userId.ToString("D") }, message = text, + text_mode = "normal", notify_self = false }, "Bearer " + ChatbotConfig.SignalBridgeApiToken); + // Newer REST bridges return an array by recipient type; older releases return one object. + // This transport sends exactly one private recipient, so exactly one result is expected. + var response = result is JArray results ? (results.Count == 1 ? results[0] as JObject : null) : result as JObject; + var errors = response?["errors"]; + if (response == null || !long.TryParse(response["timestamp"]?.ToString(), out var timestamp) || timestamp <= 0 + || response["error"] is { Type: not JTokenType.Null } + || (errors is { Type: not JTokenType.Null } && !(errors is JArray array && array.Count == 0))) + throw new InvalidOperationException("Signal bridge did not accept the message."); + } + } +} diff --git a/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs b/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs index 768ee83d0..b54ed5521 100644 --- a/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs +++ b/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs @@ -13,6 +13,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // SMS Adapters (Phase 1) diff --git a/Providers/Resgrid.Providers.Chatbot/Services/ChatbotHttpClient.cs b/Providers/Resgrid.Providers.Chatbot/Services/ChatbotHttpClient.cs index 092715816..9156265cb 100644 --- a/Providers/Resgrid.Providers.Chatbot/Services/ChatbotHttpClient.cs +++ b/Providers/Resgrid.Providers.Chatbot/Services/ChatbotHttpClient.cs @@ -19,13 +19,19 @@ public ChatbotHttpClient() : this(Shared) { } public Task PostAsync(string url, object payload, string authorization = null, string tokenHeader = null, string token = null) => SendAsync(url, new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"), authorization, tokenHeader, token); + public Task PostJsonAsync(string url, object payload, string authorization) + => RequestAsync(HttpMethod.Post, url, new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"), authorization, null, null); + public Task GetAsync(string url, string authorization) - => RequestAsync(HttpMethod.Get, url, null, authorization, null, null); + => RequireObjectAsync(RequestAsync(HttpMethod.Get, url, null, authorization, null, null)); public Task SendAsync(string url, HttpContent content, string authorization = null, string tokenHeader = null, string token = null) - => RequestAsync(HttpMethod.Post, url, content, authorization, tokenHeader, token); + => RequireObjectAsync(RequestAsync(HttpMethod.Post, url, content, authorization, tokenHeader, token)); + + private static async Task RequireObjectAsync(Task request) + => await request as JObject ?? throw new InvalidOperationException("Messaging provider returned an invalid response."); - private async Task RequestAsync(HttpMethod method, string url, HttpContent content, string authorization, string tokenHeader, string token) + private async Task RequestAsync(HttpMethod method, string url, HttpContent content, string authorization, string tokenHeader, string token) { using var request = new HttpRequestMessage(method, url) { Content = content }; if (authorization != null) request.Headers.TryAddWithoutValidation("Authorization", authorization); @@ -36,7 +42,7 @@ private async Task RequestAsync(HttpMethod method, string url, HttpCont if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Messaging provider rejected the request (HTTP {(int)response.StatusCode})."); var body = await response.Content.ReadAsStringAsync(); - return string.IsNullOrWhiteSpace(body) ? new JObject() : JObject.Parse(body); + return string.IsNullOrWhiteSpace(body) ? new JObject() : JToken.Parse(body); } catch (HttpRequestException) { throw new InvalidOperationException("Messaging provider connection failed."); } catch (TaskCanceledException) { throw new InvalidOperationException("Messaging provider request timed out."); } diff --git a/Tests/Resgrid.Tests/Chatbot/MessagingAccountsTests.cs b/Tests/Resgrid.Tests/Chatbot/MessagingAccountsTests.cs index 9593f2590..8db1ede82 100644 --- a/Tests/Resgrid.Tests/Chatbot/MessagingAccountsTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/MessagingAccountsTests.cs @@ -58,6 +58,18 @@ public void SetUp() [TearDown] public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + [TestCase(true)] + [TestCase(false)] + public async Task Signal_account_option_reflects_inbound_configuration(bool configured) + { + var signal = new Mock(); + signal.SetupGet(a => a.IsInboundConfigured).Returns(configured); + _registry.Setup(r => r.GetAdapter(ChatbotPlatform.Signal)).Returns(signal.Object); + var result = (ViewResult)await _controller.Index(); + var model = (MessagingAccountsViewModel)result.Model; + model.Platforms.Should().ContainSingle(p => p.Name == "Signal" && p.IsConfigured == configured); + } + [Test] public async Task Unlink_IdOutsideCurrentUsersAccounts_DoesNotDelete() { diff --git a/Tests/Resgrid.Tests/Chatbot/SignalMessagingTests.cs b/Tests/Resgrid.Tests/Chatbot/SignalMessagingTests.cs new file mode 100644 index 000000000..ad4987945 --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/SignalMessagingTests.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Models; +using Resgrid.Chatbot.Services; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Chatbot; +using Resgrid.Providers.Chatbot.Adapters; +using Resgrid.Providers.Chatbot.Interfaces; +using Resgrid.Providers.Chatbot.Services; +using Resgrid.Web.Services.Controllers; + +namespace Resgrid.Tests.Chatbot +{ + [TestFixture, NonParallelizable] + public class SignalMessagingTests + { + private const string Account = "+12025550123"; + private const string Sender = "aa519fd1-44bd-4ab3-91c1-322650fd73bf"; + private const string ApiToken = "signal-outbound-test-token-1234567890"; + private const string WebhookSecret = "signal-inbound-test-secret-1234567890"; + private Dictionary _config; + private RecordingHandler _handler; + private HttpClient _client; + private SignalBotAdapter _adapter; + private ChatbotAdapterRegistry _registry; + private Mock _queue; + private List _messages; + + [SetUp] + public void SetUp() + { + _config = typeof(ChatbotConfig).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => !f.IsLiteral && !f.IsInitOnly).ToDictionary(f => f, f => f.GetValue(null)); + ChatbotConfig.SignalBridgeUrl = "https://signal.example.test"; + ChatbotConfig.SignalAccountNumber = Account; + ChatbotConfig.SignalBridgeApiToken = ApiToken; + ChatbotConfig.SignalWebhookSecret = WebhookSecret; + _handler = new(); + _client = new HttpClient(_handler); + _adapter = new(new ChatbotHttpClient(_client)); + _registry = new(new Lazy>(() => new[] { _adapter })); + _queue = new(); + _messages = new(); + _queue.Setup(q => q.EnqueueChatbotMessageAsync(It.IsAny(), It.IsAny())) + .Callback((item, _) => _messages.Add(item)).ReturnsAsync(true); + } + + [TearDown] + public void TearDown() + { + _client.Dispose(); + foreach (var field in _config) field.Key.SetValue(null, field.Value); + } + + [Test] + public void Provider_module_registers_signal_on_existing_platform_id() + { + var builder = new ContainerBuilder(); + builder.RegisterModule(); + using var container = builder.Build(); + var registration = container.ComponentRegistry.Registrations.Single(r => r.Activator.LimitType == typeof(SignalBotAdapter)); + Assert.That(registration.Services.OfType().Select(s => s.ServiceType), Does.Contain(typeof(IChatbotPlatformAdapter))); + Assert.That((int)_adapter.Platform, Is.EqualTo(8)); + Assert.That(_registry.CanInitiateProactively(ChatbotPlatform.Signal), Is.True); + Assert.That(_adapter.IsInboundConfigured, Is.True); + Assert.That(ChatbotPlatformCapabilities.ForPlatform(ChatbotPlatform.Signal).MaxMessageLength, Is.EqualTo(_adapter.GetCapabilities().MaxMessageLength)); + Assert.That(ChatbotPlatformCapabilities.ForPlatform(ChatbotPlatform.Signal).SupportsImages, Is.False); + } + + [TestCase("http://signal.example.test")] + [TestCase("https://user:password@signal.example.test")] + [TestCase("https://signal.example.test?token=secret")] + [TestCase("https://signal.example.test#fragment")] + [TestCase("https://signal.example.test/redirect")] + [TestCase("file:///tmp/bridge")] + [TestCase("")] + public void Invalid_bridge_configuration_disables_delivery(string url) + { + ChatbotConfig.SignalBridgeUrl = url; + Assert.That(_adapter.IsConfigured, Is.False); + Assert.That(_registry.CanInitiateProactively(ChatbotPlatform.Signal), Is.False); + } + + [TestCase("http://127.0.0.1:8088")] + [TestCase("http://[::1]:8088/")] + [TestCase("https://signal.example.test/")] + public void Loopback_or_https_origins_are_supported(string url) + { + ChatbotConfig.SignalBridgeUrl = url; + Assert.That(_adapter.IsConfigured, Is.True); + } + + [TestCase("")] + [TestCase("short")] + [TestCase("test-token-with-newline-1234567890\r\n")] + public void Missing_or_unsafe_gateway_credentials_disable_send(string token) + { + ChatbotConfig.SignalBridgeApiToken = token; + Assert.That(_adapter.IsConfigured, Is.False); + } + + [TestCase("group.abc")] + [TestCase("+12025550999")] + [TestCase("user.123")] + [TestCase("00000000-0000-0000-0000-000000000000")] + public void Outbound_requires_a_linked_uuid_not_a_phone_or_shared_destination(string recipient) + { + Assert.ThrowsAsync(() => _adapter.SendRichResponseAsync(recipient, new() { Text = "Test" })); + Assert.That(_handler.Bodies, Is.Empty); + } + + [Test] + public async Task Replies_use_configured_bridge_account_and_private_uuid_and_split_unicode_safely() + { + var text = new string('x', 1499) + "🚒 more"; + await _adapter.SendReplyAsync(new() { From = Sender, PlatformMetadata = new() { ["url"] = "https://untrusted.test" } }, new() { Text = text }); + Assert.That(_handler.Bodies, Has.Count.EqualTo(2)); + Assert.That(string.Concat(_handler.Bodies.Select(b => b["message"].ToString())), Is.EqualTo(text)); + Assert.That(_handler.Bodies[1]["message"].ToString(), Does.StartWith("🚒")); + Assert.That(_handler.Urls, Is.All.EqualTo("https://signal.example.test/v2/send")); + Assert.That(_handler.Authorizations, Is.All.EqualTo("Bearer " + ApiToken)); + foreach (var payload in _handler.Bodies) + { + Assert.That(payload["number"].ToString(), Is.EqualTo(Account)); + Assert.That(payload["recipients"].Values(), Is.EqualTo(new[] { Sender })); + Assert.That(payload["text_mode"].ToString(), Is.EqualTo("normal")); + Assert.That(payload["notify_self"].Value(), Is.False); + } + } + + [TestCase("{}")] + [TestCase("[]")] + [TestCase("[{}]")] + [TestCase("[null]")] + [TestCase("[{\"timestamp\":\"1234\"},{\"timestamp\":\"5678\"}]")] + [TestCase("[{\"timestamp\":\"1234\",\"errors\":[{\"reason\":\"UNREGISTERED_FAILURE\"}]}]")] + [TestCase("{\"timestamp\":\"0\"}")] + [TestCase("{\"timestamp\":\"1234\",\"errors\":[{\"reason\":\"UNREGISTERED_FAILURE\"}]}")] + [TestCase("{\"timestamp\":\"1234\",\"error\":\"sensitive provider error\"}")] + public void Missing_or_failed_acceptance_is_not_reported_as_success(string response) + { + _handler.Response = response; + var error = Assert.ThrowsAsync(() => _adapter.SendRichResponseAsync(Sender, new() { Text = "Test" })); + Assert.That(error.Message, Is.EqualTo("Signal bridge did not accept the message.")); + } + + [Test] + public async Task Older_bridge_object_response_is_supported() + { + _handler.Response = "{\"timestamp\":\"1750000000000\"}"; + await _adapter.SendRichResponseAsync(Sender, new() { Text = "Test" }); + Assert.That(_handler.Bodies, Has.Count.EqualTo(1)); + } + + [TestCase(HttpStatusCode.Unauthorized)] + [TestCase(HttpStatusCode.TooManyRequests)] + [TestCase(HttpStatusCode.ServiceUnavailable)] + public void Rejected_send_does_not_expose_provider_body_or_credentials(HttpStatusCode status) + { + _handler.Status = status; + _handler.Response = "{\"error\":\"private message and secret\"}"; + var error = Assert.ThrowsAsync(() => _adapter.SendRichResponseAsync(Sender, new() { Text = "Test" })); + Assert.That(error.Message, Is.EqualTo($"Messaging provider rejected the request (HTTP {(int)status}).")); + } + + [Test] + public void Other_provider_object_contract_is_preserved() + { + var error = Assert.ThrowsAsync(() => new ChatbotHttpClient(_client) + .PostAsync("https://other.example.test", new { message = "Test" })); + Assert.That(error.Message, Is.EqualTo("Messaging provider returned an invalid response.")); + } + + [TestCase(ChatbotOutboundType.Dispatch)] + [TestCase(ChatbotOutboundType.Message)] + [TestCase(ChatbotOutboundType.Notification)] + public async Task Linked_people_receive_broadcasts_without_an_inbound_conversation(ChatbotOutboundType type) + { + var identities = new Mock(); + identities.Setup(i => i.GetUserIdentitiesAsync("user-1")).ReturnsAsync(new List + { new() { UserId = "user-1", Platform = ChatbotPlatform.Signal, PlatformUserId = Sender, IsActive = true } }); + var policy = new ChatbotDepartmentConfig { IsEnabled = true, ProactiveNotificationsEnabled = true, AllowedPlatforms = "Signal" }; + var config = new Mock(); + config.Setup(c => c.GetConfigAsync(7, It.IsAny())).ReturnsAsync(policy); + var service = new ChatbotOutboundService(identities.Object, _registry, config.Object); + var message = new ChatbotOutboundMessage { Type = type, Title = "Resgrid notification", Body = "Test details" }; + var result = await service.SendToUserAsync("user-1", 7, message); + Assert.That(result.DeliveredPlatforms, Is.EqualTo(new[] { "Signal" })); + Assert.That(_handler.Bodies.Single()["message"].ToString(), Is.EqualTo("Resgrid notification\nTest details")); + policy.AllowedPlatforms = "Telegram"; + Assert.That((await service.SendToUserAsync("user-1", 7, message)).AnyDelivered, Is.False); + policy.AllowedPlatforms = "Signal"; + policy.ProactiveNotificationsEnabled = false; + Assert.That((await service.SendToUserAsync("user-1", 7, message)).AnyDelivered, Is.False); + Assert.That(_handler.Bodies, Has.Count.EqualTo(1)); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Authenticated_private_intake_preserves_uuid_and_original_time(bool subscription) + { + var time = DateTimeOffset.UtcNow.AddSeconds(-20).ToUnixTimeMilliseconds(); + var body = Event(time); + if (subscription) body["params"] = new JObject { ["subscription"] = 0, ["result"] = body["params"] }; + Assert.That(await Receive(body), Is.TypeOf()); + var queued = _messages.Single(); + Assert.That(queued.Platform, Is.EqualTo(8)); + Assert.That(queued.From, Is.EqualTo(Sender)); + Assert.That(queued.MessageId, Is.EqualTo(Account + ":" + time)); + Assert.That(queued.Body, Is.EqualTo("LINK ABC123")); + Assert.That(queued.ReceivedAtUtc, Is.EqualTo(DateTimeOffset.FromUnixTimeMilliseconds(time).UtcDateTime)); + Assert.That(queued.DepartmentId, Is.Zero); + } + + [TestCase("")] + [TestCase("wrong-secret")] + public async Task Unauthenticated_webhooks_cannot_link_or_execute(string secret) + { + Assert.That(await Receive(Event(), secret), Is.TypeOf()); + Assert.That(_messages, Is.Empty); + } + + [Test] + public async Task Unconfigured_intake_is_unavailable_even_when_outbound_is_configured() + { + ChatbotConfig.SignalWebhookSecret = ""; + Assert.That(_adapter.IsInboundConfigured, Is.False); + Assert.That(((StatusCodeResult)await Receive(Event())).StatusCode, Is.EqualTo(503)); + } + + [Test] + public async Task Other_bridge_accounts_are_rejected() + { + var body = Event(); + body["params"]["account"] = "+12025550999"; + Assert.That(await Receive(body), Is.TypeOf()); + Assert.That(_messages, Is.Empty); + } + + [TestCase("group")] + [TestCase("sync")] + [TestCase("receipt")] + [TestCase("typing")] + [TestCase("edit")] + [TestCase("attachment")] + public async Task Shared_and_nontext_events_do_not_enter_the_command_queue(string kind) + { + var body = Event(); + var envelope = (JObject)body["params"]["envelope"]; + if (kind == "group") envelope["dataMessage"]["groupInfo"] = new JObject { ["groupId"] = "private-group-id" }; + else if (kind == "attachment") ((JObject)envelope["dataMessage"]).Remove("message"); + else { envelope.Remove("dataMessage"); envelope[kind + "Message"] = new JObject { ["message"] = "LINK ABC123" }; } + Assert.That(await Receive(body), Is.TypeOf()); + Assert.That(_messages, Is.Empty); + } + + [TestCase(null)] + [TestCase("+12025550999")] + [TestCase("00000000-0000-0000-0000-000000000000")] + public async Task Missing_sender_uuid_never_falls_back_to_phone_identity(string sender) + { + var body = Event(); + body["params"]["envelope"]["sourceUuid"] = sender; + body["params"]["envelope"]["sourceNumber"] = "+12025550999"; + Assert.That(await Receive(body), Is.TypeOf()); + Assert.That(_messages, Is.Empty); + } + + [Test] + public async Task Replay_and_invalid_timestamps_are_not_queued() + { + Assert.That(await Receive(Event(DateTimeOffset.UtcNow.AddHours(-2).ToUnixTimeMilliseconds())), Is.TypeOf()); + Assert.That(await Receive(Event(DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeMilliseconds())), Is.TypeOf()); + var missing = Event(); + ((JObject)missing["params"]["envelope"]["dataMessage"]).Remove("timestamp"); + Assert.That(await Receive(missing), Is.TypeOf()); + var inconsistent = Event(); + inconsistent["params"]["envelope"]["timestamp"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeMilliseconds(); + Assert.That(await Receive(inconsistent), Is.TypeOf()); + Assert.That(_messages, Is.Empty); + } + + [Test] + public async Task Queue_failure_is_not_acknowledged_as_success() + { + _queue.Setup(q => q.EnqueueChatbotMessageAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + Assert.That(((StatusCodeResult)await Receive(Event())).StatusCode, Is.EqualTo(503)); + } + + [Test] + public async Task Private_signal_link_code_binds_the_resgrid_owner_to_the_signal_uuid() + { + await Receive(Event()); + var item = _messages.Single(); + var codes = new Mock(); + codes.Setup(c => c.GetByCodeAsync("ABC123")).ReturnsAsync(new ChatbotLinkingCode + { Id = "code-id", Code = "ABC123", UserId = "user-1", ExpiresAt = DateTime.UtcNow.AddMinutes(5) }); + codes.Setup(c => c.TryConsumeAsync("code-id", 8, Sender, It.IsAny())).ReturnsAsync(true); + var identities = new Mock(); + await new CodeLinkingService(identities.Object, codes.Object).ProcessCodeAsync(item.Body.Substring(5), (ChatbotPlatform)item.Platform, item.From, null); + identities.Verify(i => i.LinkUserAsync("user-1", ChatbotPlatform.Signal, Sender, null, "code", "ABC123"), Times.Once); + } + + private Task Receive(JObject body, string secret = WebhookSecret) + { + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body.ToString(Formatting.None))); + context.Request.Headers["X-Resgrid-Signal-Secret"] = secret; + var controller = new ChatbotPlatformsController(_queue.Object, _registry, new ChatbotJwtValidator()) + { ControllerContext = new ControllerContext { HttpContext = context } }; + return controller.Receive("Signal"); + } + + private static JObject Event(long? time = null) + { + var timestamp = time ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return JObject.FromObject(new { jsonrpc = "2.0", method = "receive", @params = new + { account = Account, envelope = new { sourceUuid = Sender.ToUpperInvariant(), sourceNumber = (string)null, + timestamp, dataMessage = new { message = "LINK ABC123", timestamp, groupInfo = (object)null } } } }); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public readonly List Bodies = new(); + public readonly List Urls = new(); + public readonly List Authorizations = new(); + public string Response = "[{\"timestamp\":\"1750000000000\"}]"; + public HttpStatusCode Status = HttpStatusCode.Created; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Urls.Add(request.RequestUri.ToString()); + Authorizations.Add(request.Headers.Authorization?.ToString()); + Bodies.Add(JObject.Parse(await request.Content.ReadAsStringAsync(cancellationToken))); + return new HttpResponseMessage(Status) { Content = new StringContent(Response, Encoding.UTF8, "application/json") }; + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs index 0502e2cec..fc5e7aebb 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs @@ -168,7 +168,7 @@ public async Task P1M4_scheduled_notice_is_localized_value_free_and_delivery_log string html = null; var pdf = new Mock(); pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns((string value) => { html = value; return Encoding.ASCII.GetBytes("%PDF-1.4 synthetic"); }); var service = new ChecklistScheduledReportService(tasks.Object, _authorization.Object, _access.Object, users.Object, profiles.Object, pdf.Object); var notification = await service.BuildAsync(task); notification.Subject.Should().Contain("conformité"); html.Should().NotContain("CANARY").And.NotContain("author").And.NotContain("synthetic@example.invalid"); - var email = new Mock(); var logic = new ReportDeliveryLogic(tasks.Object, email.Object, pdf.Object, service); + var email = new Mock(); var logic = new ReportDeliveryLogic(tasks.Object, email.Object, pdf.Object, service, Mock.Of()); var item = new ReportDeliveryQueueItem { ScheduledTask = task, Department = new Department { DepartmentId = 77 }, Email = "stale@example.invalid" }; (await logic.Process(item)).Item1.Should().BeTrue(); email.Verify(e => e.SendReportDeliveryAsync(It.Is(n => n.To == "synthetic@example.invalid"), 77, It.IsAny(), It.IsAny()), Times.Once); tasks.Verify(t => t.CreateScheduleTaskLogAsync(task, It.IsAny()), Times.Once); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderSettingsTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderSettingsTests.cs index 86840bc57..67672a6d9 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderSettingsTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderSettingsTests.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using NUnit.Framework; using Resgrid.Model.Checklists; +using Resgrid.Model.Services; using Resgrid.Model.WorkOrders; using Resgrid.Services; @@ -12,6 +13,28 @@ namespace Resgrid.Tests.Services { public partial class WorkOrderP2M1Tests { + [Test] + public void Currency_catalog_always_carries_the_records_minimum_and_validates_ordinally() + { + WorkOrderCurrencies.Options.Should().NotBeEmpty(); + foreach (var code in Resgrid.Model.RmsCurrencies.Supported) WorkOrderCurrencies.Options.Should().ContainKey(code); + WorkOrderCurrencies.Options.Keys.Should().BeInAscendingOrder(StringComparer.Ordinal); + WorkOrderCurrencies.Options.Keys.Should().OnlyContain(code => code.Length == 3 && code != "XXX" && code.All(c => c >= 'A' && c <= 'Z'), "ICU's ¤¤ / XXX placeholders are not currencies"); + WorkOrderCurrencies.IsSupported("USD").Should().BeTrue(); WorkOrderCurrencies.IsSupported("usd").Should().BeFalse(); + WorkOrderCurrencies.IsSupported("XXX").Should().BeFalse(); WorkOrderCurrencies.IsSupported(null).Should().BeFalse(); + } + [Test] + public async Task Detail_reveals_each_child_table_in_one_protected_read_per_page() + { + var id = await Assigned(); + for (var i = 0; i < 3; i++) { var current = await _service.GetAsync(_actor, id); await _service.CommentAsync(_actor, id, current.Order.Revision, "note " + i); } + _read.Invocations.Clear(); + var detail = await _service.GetAsync(_actor, id); + detail.Activities.Count.Should().BeGreaterThan(3); + var activityReads = _read.Invocations.Where(i => i.Method.Name == nameof(IProtectedReadService.ResolveRecordsEntitiesForReadAsync) && i.Method.GetGenericArguments()[0] == typeof(WorkOrderActivity)).ToList(); + activityReads.Should().HaveCount(1, "the whole activity page is resolved in one broker call"); + ((System.Collections.ICollection)activityReads[0].Arguments[1]).Count.Should().Be(detail.Activities.Count); + } [Test] public async Task Department_currency_controls_orders_retries_edits_and_vendor_charges() { diff --git a/Tests/Resgrid.Tests/Web/DepartmentTimeTests.cs b/Tests/Resgrid.Tests/Web/DepartmentTimeTests.cs index 293793352..e8880bed8 100644 --- a/Tests/Resgrid.Tests/Web/DepartmentTimeTests.cs +++ b/Tests/Resgrid.Tests/Web/DepartmentTimeTests.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using FluentAssertions; using NUnit.Framework; using Resgrid.Model; @@ -99,6 +100,30 @@ public void Definition_datetime_fields_use_department_offset_and_leave_calendar_ time.RecordInput(birthday, schema).Value.Should().Be("1990-07-15"); } + [TestCase("Pacific Standard Time", "America/Los_Angeles")] + [TestCase("Saskatchewan", "America/Regina")] + [TestCase("Asia/Kolkata", "Asia/Kolkata")] + [TestCase("", "America/Los_Angeles")] + [TestCase(null, "America/Los_Angeles")] + [TestCase("Not/A_Zone", "UTC")] + [TestCase("
#RecordDefinitionRevisionFinalizedChecksum