From 4a96343277642f0ac177022812d07447ad0c5eaa Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 26 Sep 2026 18:35:56 -0700 Subject: [PATCH] RG-T135 Sentry fixes --- .../Areas/User/Records/Records.ar.resx | 1 + .../Areas/User/Records/Records.de.resx | 1 + .../Areas/User/Records/Records.el.resx | 1 + .../Areas/User/Records/Records.en.resx | 1 + .../Areas/User/Records/Records.es.resx | 1 + .../Areas/User/Records/Records.fr.resx | 1 + .../Areas/User/Records/Records.it.resx | 1 + .../Areas/User/Records/Records.pl.resx | 1 + .../Areas/User/Records/Records.sv.resx | 1 + .../Areas/User/Records/Records.uk.resx | 1 + .../Resgrid.Model/Providers/ICacheProvider.cs | 8 + .../Repositories/ISearchRepositories.cs | 7 + .../Search/UnifiedSearchContracts.cs | 4 +- Core/Resgrid.Model/Services/IChatServices.cs | 5 +- Core/Resgrid.Services/ChatChannelService.cs | 2 +- Core/Resgrid.Services/ChatMessageService.cs | 20 +- .../Resgrid.Services/ChatPermissionService.cs | 24 ++- Core/Resgrid.Services/CoreEventService.cs | 32 +++- .../IncidentCommandService.cs | 38 ++-- Core/Resgrid.Services/PermissionsService.cs | 25 ++- .../Records/RecordsAnalyticsService.cs | 2 + .../Records/RecordsCrrService.cs | 5 +- .../Records/RecordsHydrantsService.cs | 4 +- .../Records/RecordsInspectionsService.cs | 8 +- .../Records/RecordsInvestigationsService.cs | 6 +- .../Records/RecordsOccupancyService.cs | 1 + .../Records/RecordsPermitsService.cs | 8 +- .../Records/RecordsPreventionGate.cs | 14 ++ .../Records/RecordsQualityReviewService.cs | 2 + .../Search/SystemActionCatalog.cs | 13 +- .../Search/SystemActionsService.cs | 19 +- .../Search/UnifiedSearchService.cs | 6 +- .../NotificationProvider.cs | 19 +- .../UnitNotificationProvider.cs | 22 ++- .../WorkflowEventProvider.cs | 80 ++++---- .../AzureRedisCacheProvider.cs | 42 +++++ .../SearchRepositories.cs | 43 +++++ Tests/Resgrid.Tests/Bootstrapper.cs | 2 +- ...otificationProviderUnconfiguredHubTests.cs | 85 +++++++++ Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs | 9 + .../Rms/RecordsAnalyticsServiceTests.cs | 10 + .../Rms/RecordsPreventionStorableDateTests.cs | 171 ++++++++++++++++++ .../Resgrid.Tests/RootScopeResolutionTests.cs | 93 ++++++++++ .../Search/SystemActionsServiceTests.cs | 36 +++- .../Search/UnifiedSearchServiceTests.cs | 19 +- .../Services/ChatPermissionServiceTests.cs | 53 +++++- .../Services/ChecklistEventDeliveryTests.cs | 2 +- .../Services/CoreEventServiceTests.cs | 57 +++++- .../Services/InventoryWorkflowTests.cs | 2 +- .../LazyChatDependencyCompositionTests.cs | 116 ++++++++++++ .../WorkflowEventProviderScopeTests.cs | 101 +++++++++++ .../LegacyLogsNavigationRenderingTests.cs | 141 +++++++++++++++ .../Web/User/RecordsDateInputTests.cs | 138 ++++++++++++++ Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs | 5 +- .../Areas/User/Controllers/LogsController.cs | 4 +- .../Controllers/RecordHydrantsController.cs | 4 +- .../RecordInspectionsController.cs | 4 +- .../RecordInvestigationsController.cs | 4 +- .../Controllers/RecordPermitsController.cs | 2 +- .../RecordsPreventionMvcControllerBase.cs | 19 +- .../Controllers/RecordsQualityController.cs | 2 +- .../Views/RecordInvestigations/Details.cshtml | 4 +- .../User/Views/Shared/_Navigation.cshtml | 20 +- Workers/Resgrid.Workers.Console/Program.cs | 6 +- .../Tasks/AdpMigrationTask.cs | 5 +- .../Tasks/CalendarNotificationTask.cs | 3 +- .../Tasks/CallEmailImportTask.cs | 3 +- .../Tasks/CallPruneTask.cs | 3 +- .../Tasks/CleanOIDCScheduleTask.cs | 7 +- .../Tasks/CommunicationTestTask.cs | 3 +- .../Tasks/DispatchScheduledCallsTask.cs | 13 +- .../Tasks/GdprExportTask.cs | 3 +- .../Tasks/MemberProfileRelocationTask.cs | 5 +- .../Tasks/ReportDeliveryTask.cs | 5 +- .../Tasks/ReportingRollupTask.cs | 5 +- .../Tasks/ShiftNotiferTask.cs | 7 +- .../Tasks/StaffingScheduleTask.cs | 5 +- .../Tasks/StatusScheduleTask.cs | 5 +- .../Tasks/SystemSqlQueueTask.cs | 5 +- .../Tasks/TrainingNotiferTask.cs | 3 +- .../Tasks/TtsStaticPromptRefreshTask.cs | 3 +- .../Tasks/Utf8CleanupTask.cs | 5 +- .../Tasks/WeatherAlertImportTask.cs | 3 +- .../Logic/AdpMigrationLogic.cs | 21 ++- .../Logic/AuditQueueLogic.cs | 8 +- .../Logic/BroadcastMessageLogic.cs | 16 +- .../Logic/CalendarNotifierLogic.cs | 63 +++---- .../Logic/CallBroadcast.cs | 45 ++--- .../Logic/CallEmailImporterLogic.cs | 38 ++-- .../Logic/CallPruneLogic.cs | 17 +- .../Logic/ChatExportLogic.cs | 18 +- .../Logic/ChatRetentionLogic.cs | 10 +- .../Logic/ChatbotMessageLogic.cs | 16 +- .../Logic/CommunicationTestLogic.cs | 3 +- .../Logic/DepartmentLockGuard.cs | 4 +- .../DistributionListEmailImporterLogic.cs | 28 ++- .../Logic/DistributionListLogic.cs | 10 +- .../Logic/FeatureToggleUsageProcessor.cs | 4 +- .../Logic/GdprExportLogic.cs | 13 +- .../Logic/MaintenanceLogic.cs | 17 -- .../Logic/MemberProfileRelocationLogic.cs | 7 +- .../Logic/NotificationBroadcastLogic.cs | 25 +-- .../Logic/ParEvaluationLogic.cs | 23 +-- .../Logic/PaymentQueueLogic.cs | 21 ++- .../Logic/PersonnelLocationQueueLogic.cs | 3 +- .../Logic/ReportDeliveryLogic.cs | 11 +- .../Logic/ResourceOrderNotifierLogic.cs | 13 -- .../Logic/SecurityLogic.cs | 44 ++--- .../Logic/ShiftNotificationLogic.cs | 20 +- .../Logic/ShiftNotifierLogic.cs | 38 ++-- .../Logic/StaffingScheduleLogic.cs | 27 +-- .../Logic/StatusScheduleLogic.cs | 22 +-- .../Logic/SystemQueueLogic.cs | 65 ++++--- .../Logic/TrainingNotifierLogic.cs | 33 ++-- .../Logic/UnitLocationQueueLogic.cs | 3 +- .../Logic/Utf8CleanupLogic.cs | 5 +- .../Logic/WorkflowQueueLogic.cs | 12 +- 117 files changed, 1817 insertions(+), 555 deletions(-) create mode 100644 Tests/Resgrid.Tests/Providers/NotificationProviderUnconfiguredHubTests.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsPreventionStorableDateTests.cs create mode 100644 Tests/Resgrid.Tests/RootScopeResolutionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/LazyChatDependencyCompositionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkflowEventProviderScopeTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/LegacyLogsNavigationRenderingTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/RecordsDateInputTests.cs diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index f66a130cd..e5532a7b7 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1655,6 +1655,7 @@ غير منطبق نص الملاحظة تم حفظ الملاحظة. + التاريخ غير صالح. تحقق من السنة. الموضوع تم إصدار الإشعار. مرجع الإشعار diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 51a84fc27..a09037814 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1655,6 +1655,7 @@ k. A. Notiztext Notiz gespeichert. + Das Datum ist ungültig. Prüfen Sie das Jahr. Betreff Bescheid ausgestellt. Bescheid-Referenz diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index c75c2b825..761336826 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1655,6 +1655,7 @@ Δ/Ε Κείμενο σημείωσης Η σημείωση αποθηκεύτηκε. + Η ημερομηνία δεν είναι έγκυρη. Ελέγξτε το έτος. Θέμα Η ειδοποίηση εκδόθηκε. Αναφορά ειδοποίησης diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index 7a78da112..6a15aed6a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1655,6 +1655,7 @@ N/A Note text Note saved. + The date is not valid. Check the year. Subject Notice issued. Notice reference diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 3bc380f90..22263687a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1655,6 +1655,7 @@ N/D Texto de la nota Nota guardada. + La fecha no es válida. Revise el año. Asunto Notificación emitida. Referencia de la notificación diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 39cad2845..fdfc73834 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1655,6 +1655,7 @@ S. O. Texte de la note Note enregistrée. + La date n'est pas valide. Vérifiez l'année. Objet Avis émis. Référence de l'avis diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 451bbb6ea..b7e8cfe79 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1655,6 +1655,7 @@ N/D Testo della nota Nota salvata. + La data non è valida. Verifica l'anno. Oggetto Avviso emesso. Riferimento avviso diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 801127df7..a06c2eae6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1655,6 +1655,7 @@ Nie dot. Treść notatki Notatka zapisana. + Data jest nieprawidłowa. Sprawdź rok. Temat Wezwanie wystawione. Numer wezwania diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index a070ea3a7..874629edf 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1655,6 +1655,7 @@ Ej tillämpl. Anteckningstext Anteckning sparad. + Datumet är ogiltigt. Kontrollera året. Ämne Föreläggande utfärdat. Föreläggandereferens diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index bf37a95ac..874d35892 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1655,6 +1655,7 @@ Н/З Текст нотатки Нотатку збережено. + Дата недійсна. Перевірте рік. Тема Припис видано. Номер припису diff --git a/Core/Resgrid.Model/Providers/ICacheProvider.cs b/Core/Resgrid.Model/Providers/ICacheProvider.cs index 7da534636..ba6958667 100644 --- a/Core/Resgrid.Model/Providers/ICacheProvider.cs +++ b/Core/Resgrid.Model/Providers/ICacheProvider.cs @@ -14,6 +14,14 @@ public interface ICacheProvider Task GetStringAsync(string cacheKey); Task GetAsync(string cacheKey) where T : class; + /// + /// Atomically returns the string stored at the key, or stores and + /// returns it when the key is missing (concurrent callers all get the first value stored). Either + /// way the key's expiration is reset, so a key in regular use never ages out. Returns null only + /// when the cache is unavailable — unlike , null never means "absent". + /// + Task GetOrAddStringAsync(string cacheKey, string valueIfAbsent, TimeSpan slidingExpiration); + /// /// Atomically increments a counter and returns the new value. Sets the expiration on first /// increment (when the value becomes 1). Returns 0 when the cache is unavailable. diff --git a/Core/Resgrid.Model/Repositories/ISearchRepositories.cs b/Core/Resgrid.Model/Repositories/ISearchRepositories.cs index c8c7cb3fd..11fd64178 100644 --- a/Core/Resgrid.Model/Repositories/ISearchRepositories.cs +++ b/Core/Resgrid.Model/Repositories/ISearchRepositories.cs @@ -36,6 +36,13 @@ public interface ISearchIndexStatesRepository : IRepository Task GetAsync(string indexName, int departmentId); Task> GetAllForIndexAsync(string indexName); + + /// + /// Inserts unless a row for its (IndexName, DepartmentId) already exists, in one statement, + /// so concurrent callers never trip the unique index. Returns false, and leaves the existing row untouched, when + /// another writer got there first. + /// + Task InsertIfMissingAsync(SearchIndexState state, CancellationToken cancellationToken = default); } public interface ISearchIndexLeasesRepository : IRepository diff --git a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs index ae8e41b22..7fdf6c574 100644 --- a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs +++ b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs @@ -144,8 +144,8 @@ public class SystemActionDefinition /// Feature flag key that must evaluate true for the department (FeatureFlagKeys). public string FeatureFlag { get; set; } - /// Hide when the Records module is on: the legacy Logs pages are replaced after cutover. - public bool HiddenWhenRecordsEnabled { get; set; } + /// Hide once the department's Records cutover is active: a legacy Logs write that the Logs pages now refuse. Reads stay listed. + public bool HiddenAfterRecordsCutover { get; set; } } public class SystemActionHit diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index a6ce790da..d0af2e920 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -217,7 +217,10 @@ public interface IChatPermissionService /// Drops cached permission evaluations for a channel (membership/roles changed) and bumps the channel-list cache version. Task InvalidateChannelCacheAsync(string chatChannelId); - /// Current distributed authorization epoch used to isolate realtime channel groups after access changes. + /// + /// Current distributed authorization epoch used to isolate realtime channel groups after access changes. + /// Minted on first use; null only when the cache is unavailable, and callers must then fail closed. + /// Task GetChannelAccessVersionAsync(string chatChannelId); } diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 19619e6e8..d51f34a93 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -41,7 +41,7 @@ public class ChatChannelService : IChatChannelService private readonly ICacheProvider _cacheProvider; private readonly IUnitOfWork _unitOfWork; - // IncidentCommandService reaches back for channel provisioning through ServiceLocator, so this + // IncidentCommandService reaches back for channel provisioning through a Lazy, so this // constructor edge does not close a resolution cycle. private readonly IIncidentCommandService _incidentCommandService; diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index e749b7492..335db2caf 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -3,7 +3,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using CommonServiceLocator; +using Autofac; +using Autofac.Core; using Microsoft.Data.SqlClient; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -33,6 +34,7 @@ public class ChatMessageService : IChatMessageService private readonly IChatMessageMentionRepository _chatMessageMentionRepository; private readonly IChatMessageAckRepository _chatMessageAckRepository; private readonly IChatChannelMemberRepository _chatChannelMemberRepository; + private readonly ILifetimeScope _lifetimeScope; private readonly IChatChannelService _chatChannelService; private readonly IChatPermissionService _chatPermissionService; private readonly IUserProfileService _userProfileService; @@ -44,7 +46,7 @@ public ChatMessageService(IChatChannelRepository chatChannelRepository, IChatMes IChatMessageReactionRepository chatMessageReactionRepository, IChatMessageMentionRepository chatMessageMentionRepository, IChatMessageAckRepository chatMessageAckRepository, IChatChannelMemberRepository chatChannelMemberRepository, IChatChannelService chatChannelService, IChatPermissionService chatPermissionService, IUserProfileService userProfileService, - IUnitsService unitsService, IEventAggregator eventAggregator) + IUnitsService unitsService, IEventAggregator eventAggregator, ILifetimeScope lifetimeScope = null) { _chatChannelRepository = chatChannelRepository; _chatMessageRepository = chatMessageRepository; @@ -59,6 +61,7 @@ public ChatMessageService(IChatChannelRepository chatChannelRepository, IChatMes _userProfileService = userProfileService; _unitsService = unitsService; _eventAggregator = eventAggregator; + _lifetimeScope = lifetimeScope; } public async Task SendMessageAsync(int departmentId, string senderUserId, ChatMessageSendRequest request, CancellationToken cancellationToken = default(CancellationToken)) @@ -807,16 +810,23 @@ await _chatMessageEditRepository.InsertAsync(new ChatMessageEdit /// /// Push fan-out off the request path: per-recipient Novu calls can be slow for large channels, - /// and a push failure must never fail the send. Fresh resolution inside the task keeps us off - /// the request's disposed lifetime scope (ChatProvisioningEventService pattern). + /// and a push failure must never fail the send. The task outlives the request, and Autofac will not + /// resolve from a scope whose parent is disposed, so it runs in its own child of the ROOT scope: never + /// the request's scope, and never a root-resolved notifier sharing the root unit of work + /// (ChatProvisioningEventService pattern). Skipped when constructed outside the container. /// private void FireAndForgetNotify(ChatChannel channel, ChatMessage message, List mentions) { + var root = (_lifetimeScope as ISharingLifetimeScope)?.RootLifetimeScope; + if (root == null) + return; + _ = Task.Run(async () => { try { - var notifier = ServiceLocator.Current.GetInstance(); + using var scope = root.BeginLifetimeScope(); + var notifier = scope.Resolve(); await notifier.NotifyMessageSentAsync(channel, message, mentions); } catch (Exception ex) diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index f328aebaa..d0742f67f 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -21,6 +21,13 @@ public class ChatPermissionService : IChatPermissionService private static readonly TimeSpan CacheLength = TimeSpan.FromSeconds(60); private static readonly TimeSpan VersionCacheLength = TimeSpan.FromDays(1); + /// + /// Sliding lifetime of a channel's access epoch. Every read refreshes it, so an epoch only lapses + /// on a channel with no joins or fan-out for this long; the lapse mints a new epoch, which strands + /// any still-joined connection until it rejoins, so keep this well beyond a connection's lifetime. + /// + private static readonly TimeSpan AccessVersionSlidingExpiration = TimeSpan.FromDays(30); + /// Shared version key rolled into every per-user channel-list cache key; bumped by InvalidateChannelCacheAsync. internal const string ChannelListVersionCacheKey = "chatchannellistver"; @@ -299,7 +306,9 @@ public async Task InvalidateChannelCacheAsync(string chatChannelId) if (string.IsNullOrWhiteSpace(chatChannelId)) return; - await _cacheProvider.IncrementAsync(GetVersionKey(chatChannelId), VersionCacheLength); + // A fresh random epoch rather than an increment: once a key lapses, a counter restarts at a + // value an obsolete group (still holding a revoked connection) may already carry. + await _cacheProvider.SetStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration); // Roll every per-user channel-list cache key forward too (channel set/visibility changed). await _cacheProvider.IncrementAsync(ChannelListVersionCacheKey, VersionCacheLength); @@ -312,17 +321,24 @@ public async Task GetChannelAccessVersionAsync(string chatChannelId) try { - return await _cacheProvider.GetStringAsync(GetVersionKey(chatChannelId)); + // A channel that has never been invalidated (or sat idle past the sliding window) has no + // epoch yet — mint one rather than treating the absence as an outage. + return await _cacheProvider.GetOrAddStringAsync(GetVersionKey(chatChannelId), NewAccessVersion(), AccessVersionSlidingExpiration); } catch (Exception ex) { - // A missing authorization epoch must stop realtime fan-out. Falling back to the old - // group during a cache outage could reconnect a user whose access was just revoked. + // Null (cache unavailable) must stop realtime fan-out. Falling back to a fixed group during + // a cache outage could reconnect a user whose access was just revoked. Resgrid.Framework.Logging.LogException(ex); return null; } } + private static string NewAccessVersion() + { + return Guid.NewGuid().ToString("N"); + } + private async Task EvaluateAccessAsync(ChatChannel channel, string userId, int? activeUnitId) { if (!await HasValidDepartmentScopeAsync(channel)) diff --git a/Core/Resgrid.Services/CoreEventService.cs b/Core/Resgrid.Services/CoreEventService.cs index 80256f221..a36b2358a 100644 --- a/Core/Resgrid.Services/CoreEventService.cs +++ b/Core/Resgrid.Services/CoreEventService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; -using CommonServiceLocator; +using Autofac; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Services; @@ -8,22 +9,39 @@ namespace Resgrid.Services { + /// + /// Registered as a singleton, so the scoped department settings service is NOT resolved once and kept: from + /// the root scope it would share one unit of work, and one DB connection, with everything else resolved there, + /// and the timestamp save runs inside an audited configuration transaction. Each event instead runs in its own + /// child scope, as does. + /// public class CoreEventService : ICoreEventService { private readonly IEventAggregator _eventAggregator; + private readonly ILifetimeScope _lifetimeScope; - public CoreEventService(IEventAggregator eventAggregator) + public CoreEventService(IEventAggregator eventAggregator, ILifetimeScope lifetimeScope) { _eventAggregator = eventAggregator; + _lifetimeScope = lifetimeScope; - _eventAggregator.AddListener(departmentSettingsUpdateHandler); + // Fire-and-forget as before: the publisher (a unit, department or custom state save) is not held up. + _eventAggregator.AddListener(message => _ = UpdateDepartmentTimestampAsync(message)); } - private Action departmentSettingsUpdateHandler = async delegate(DepartmentSettingsUpdateEvent message) + private async Task UpdateDepartmentTimestampAsync(DepartmentSettingsUpdateEvent message) { - var departmentSettingsService = ServiceLocator.Current.GetInstance(); - var result = await departmentSettingsService.SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp); - }; + try + { + using var scope = _lifetimeScope.BeginLifetimeScope(); + await scope.Resolve().SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp); + } + catch (Exception ex) + { + // Nothing awaits this, so an escaping exception would go unobserved. + Logging.LogException(ex, $"Department update timestamp could not be saved for department {message?.DepartmentId}."); + } + } public Task IncidentCommandUpdatedAsync(int departmentId, int callId) { diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 5af62a087..098234678 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -6,7 +6,6 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; -using CommonServiceLocator; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -58,6 +57,16 @@ public class IncidentCommandService : IIncidentCommandService private readonly ICallDispatchStatusService _callDispatchStatusService; private readonly IQueueService _queueService; + // The chat and command-access sides depend on this service, so constructor-injecting them directly would close a + // DI cycle. Lazy resolves them on first use from this service's own lifetime scope; the service locator they + // replace resolved from the root scope, sharing its unit of work with everything else resolved there. + private readonly Lazy _commandAccessService; + private readonly Lazy _chatChannelService; + private readonly Lazy _chatChannelRepository; + private ICommandAccessService CommandAccess => _commandAccessService?.Value ?? throw new InvalidOperationException("Command access is unavailable."); + private IChatChannelService ChatChannels => _chatChannelService?.Value ?? throw new InvalidOperationException("Chat channels are unavailable."); + private IChatChannelRepository ChatChannelRepository => _chatChannelRepository?.Value ?? throw new InvalidOperationException("Chat channels are unavailable."); + public IncidentCommandService( IIncidentCommandRepository incidentCommandRepository, ICommandStructureNodeRepository commandStructureNodeRepository, @@ -90,7 +99,10 @@ public IncidentCommandService( IIncidentMapRepository incidentMapRepository, IIncidentNeedEntityRepository incidentNeedEntityRepository, ICallDispatchStatusService callDispatchStatusService, - IQueueService queueService) + IQueueService queueService, + Lazy commandAccessService = null, + Lazy chatChannelService = null, + Lazy chatChannelRepository = null) { _incidentCommandRepository = incidentCommandRepository; _commandStructureNodeRepository = commandStructureNodeRepository; @@ -124,6 +136,9 @@ public IncidentCommandService( _incidentNeedEntityRepository = incidentNeedEntityRepository; _callDispatchStatusService = callDispatchStatusService; _queueService = queueService; + _commandAccessService = commandAccessService; + _chatChannelService = chatChannelService; + _chatChannelRepository = chatChannelRepository; } #region Command lifecycle @@ -475,11 +490,10 @@ public async Task GetCapabilitiesForUserAsync(int departme // Dispatch app. CanAssistWithCommandAsync (not CanUseCommandAsync) is the right question: the // permission is open by default, and granting board authority off that open default would hand // every member rights nobody asked for. - // Resolved through the service locator (matching this file's other cross-cutting lookups) so the - // permission side, which has no dependency on this service, does not close a DI cycle. + // Lazy (see the field) so the permission side does not close a DI cycle. try { - if (await ServiceLocator.Current.GetInstance().CanAssistWithCommandAsync(departmentId, userId)) + if (await CommandAccess.CanAssistWithCommandAsync(departmentId, userId)) caps |= IncidentRoleCapabilityMap.CommandAssistCapabilities; } catch (Exception ex) @@ -1074,7 +1088,7 @@ private async Task BackfillIncidentChatChannelsAsync(IncidentCommand command, in try { var nodes = knownNodes ?? await GetNodesForCallAsync(departmentId, callId); - await ServiceLocator.Current.GetInstance().EnsureIncidentChannelsAsync(command, nodes); + await ChatChannels.EnsureIncidentChannelsAsync(command, nodes); } catch (Exception ex) { @@ -1113,9 +1127,9 @@ private async Task PopulateResourceViewContactsAndChatAsync(ResourceIncidentView try { - // Resolved through the service locator, matching DeleteNodeAsync: the chat side depends on - // this service, so constructor-injecting it back would close a DI cycle. - var channels = (await ServiceLocator.Current.GetInstance() + // Lazy (see the field): the chat side depends on this service, so constructor-injecting it back + // directly would close a DI cycle. + var channels = (await ChatChannelRepository .GetByCallIdAsync(callId))?.ToList() ?? new List(); view.Chat.IncidentChannelId = channels.FirstOrDefault(c => c.ChannelType == (int)ChatChannelType.Incident)?.ChatChannelId; @@ -1595,7 +1609,7 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, // service's constructor graph, and a chat failure must never fail the lane save. try { - var chatChannelService = ServiceLocator.Current.GetInstance(); + var chatChannelService = ChatChannels; await chatChannelService.EnsureLaneChannelAsync(node, cancellationToken); } catch (Exception ex) @@ -1624,8 +1638,8 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, // Best-effort: archive the lane's chat channel alongside the tombstoned node. try { - var chatChannelService = ServiceLocator.Current.GetInstance(); - var laneChannel = (await ServiceLocator.Current.GetInstance().GetByCommandStructureNodeIdAsync(commandStructureNodeId)); + var chatChannelService = ChatChannels; + var laneChannel = (await ChatChannelRepository.GetByCommandStructureNodeIdAsync(commandStructureNodeId)); if (laneChannel != null && !laneChannel.IsArchived) await chatChannelService.SetChannelArchivedAsync(laneChannel.DepartmentId, laneChannel.ChatChannelId, true, userId, cancellationToken); } diff --git a/Core/Resgrid.Services/PermissionsService.cs b/Core/Resgrid.Services/PermissionsService.cs index 8fd2db087..d2c5c536b 100644 --- a/Core/Resgrid.Services/PermissionsService.cs +++ b/Core/Resgrid.Services/PermissionsService.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using CommonServiceLocator; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -20,11 +19,23 @@ public class PermissionsService : IPermissionsService private readonly IPermissionsRepository _permissionsRepository; private readonly IDepartmentGroupsService _departmentGroupsService; - public PermissionsService(IPermissionsRepository permissionsRepository, IUsersService usersService, IDepartmentGroupsService departmentGroupsService) + // The chat realtime refresh after a dispatch-login permission change. Chat permissions depend on this service, + // so they come in lazily (a direct dependency would close a DI cycle) and resolve from this service's own + // lifetime scope; the service locator they replace resolved from the root scope. Left null outside the + // container, where the refresh is skipped. + private readonly Lazy _chatChannelRepository; + private readonly Lazy _chatPermissionService; + private readonly IEventAggregator _eventAggregator; + + public PermissionsService(IPermissionsRepository permissionsRepository, IUsersService usersService, IDepartmentGroupsService departmentGroupsService, + Lazy chatChannelRepository = null, Lazy chatPermissionService = null, IEventAggregator eventAggregator = null) { _permissionsRepository = permissionsRepository; _usersService = usersService; _departmentGroupsService = departmentGroupsService; + _chatChannelRepository = chatChannelRepository; + _chatPermissionService = chatPermissionService; + _eventAggregator = eventAggregator; } public async Task> GetAllPermissionsForDepartmentAsync(int departmentId) @@ -64,15 +75,15 @@ public async Task SetPermissionForDepartmentAsync(int departmentId, return saved; } - private static async Task RotateDispatchChatAccessAsync(int departmentId) + private async Task RotateDispatchChatAccessAsync(int departmentId) { - if (departmentId <= 0 || !ServiceLocator.IsLocationProviderSet) + if (departmentId <= 0 || _chatChannelRepository == null || _chatPermissionService == null || _eventAggregator == null) return; try { - var channelRepository = ServiceLocator.Current.GetInstance(); - var permissionService = ServiceLocator.Current.GetInstance(); + var channelRepository = _chatChannelRepository.Value; + var permissionService = _chatPermissionService.Value; var channels = await channelRepository.GetAllByDepartmentIdAsync(departmentId, true); if (channels != null) @@ -81,7 +92,7 @@ private static async Task RotateDispatchChatAccessAsync(int departmentId) await permissionService.InvalidateChannelCacheAsync(channel.ChatChannelId); } - ServiceLocator.Current.GetInstance().SendMessage(new ChatEventRaised + _eventAggregator.SendMessage(new ChatEventRaised { DepartmentId = departmentId, Kind = ChatEventKinds.ChannelUpdated, diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs index b3e43a26d..24dc6033a 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -153,6 +153,8 @@ private async Task BeginAsync(int departmentId, string userId, RecordsA await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Analytics); await _gate.RequireViewerAsync(departmentId, userId); query ??= new RecordsAnalyticsQuery(); + RecordsPreventionGate.RequireStorableDate(query.Start, "The start date is not valid."); + RecordsPreventionGate.RequireStorableDate(query.End, "The end date is not valid."); var now = DateTime.UtcNow; var end = query.End ?? now; var start = query.Start ?? end.AddDays(-RecordsAnalyticsLimits.DefaultWindowDays); diff --git a/Core/Resgrid.Services/Records/RecordsCrrService.cs b/Core/Resgrid.Services/Records/RecordsCrrService.cs index 4895fba30..c38a5e0e9 100644 --- a/Core/Resgrid.Services/Records/RecordsCrrService.cs +++ b/Core/Resgrid.Services/Records/RecordsCrrService.cs @@ -24,10 +24,12 @@ public RecordsCrrService(RecordsPreventionGate gate, IRmsCrrActivitiesRepository public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Crr); private async Task RequireViewAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Crr); await _gate.RequireViewerAsync(departmentId, userId); } private async Task RequireAdminAsync(int departmentId, string userId) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Crr); await _gate.RequireAdminAsync(departmentId, userId); } + private static void RequireStorableWindow(DateTime startUtc, DateTime endUtc) { RecordsPreventionGate.RequireStorableDate(startUtc, "The start date is not valid."); RecordsPreventionGate.RequireStorableDate(endUtc, "The end date is not valid."); } public async Task> ListAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc, int take) { await RequireViewAsync(departmentId, userId); + RequireStorableWindow(startUtc, endUtc); return (await _activities.GetForRangeAsync(departmentId, startUtc, endUtc, take))?.ToList() ?? new List(); } @@ -53,7 +55,7 @@ public async Task SaveAsync(int departmentId, string userId, Rms var isNew = entity == null; if (isNew) entity = new RmsCrrActivity { RmsCrrActivityId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 0 }; entity.Kind = input.Kind == 0 ? (int)RmsCrrActivityKind.PublicEducation : input.Kind; - entity.OccurredOn = input.OccurredOn == default ? now : input.OccurredOn; + entity.OccurredOn = RecordsPreventionGate.RequireStorableDate(input.OccurredOn == default ? now : input.OccurredOn, "The activity date is not valid."); entity.Title = RecordsPreventionGate.Require(input.Title, 250, "An activity needs a title."); entity.Description = RecordsPreventionGate.Trim(input.Description, 4000); entity.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); entity.LocationText = RecordsPreventionGate.Trim(input.LocationText, 500); entity.Latitude = input.Latitude; entity.Longitude = input.Longitude; @@ -78,6 +80,7 @@ public async Task DeleteAsync(int departmentId, string userId, string activityId public async Task GetSummaryAsync(int departmentId, string userId, DateTime startUtc, DateTime endUtc) { await RequireViewAsync(departmentId, userId); + RequireStorableWindow(startUtc, endUtc); var rows = (await _activities.GetForRangeAsync(departmentId, startUtc, endUtc, 2000))?.ToList() ?? new List(); var summary = new CrrSummary { Start = startUtc, End = endUtc, Activities = rows.Count, Audience = rows.Sum(r => r.AudienceCount), SmokeAlarmsInstalled = rows.Sum(r => r.SmokeAlarmsInstalled), Hours = rows.Sum(r => r.HoursSpent) }; foreach (var group in rows.GroupBy(r => r.Kind)) summary.ByKind[group.Key] = group.Count(); diff --git a/Core/Resgrid.Services/Records/RecordsHydrantsService.cs b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs index 94d49b3db..a0b2fd250 100644 --- a/Core/Resgrid.Services/Records/RecordsHydrantsService.cs +++ b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs @@ -125,7 +125,7 @@ public async Task RecordFlowTestAsync(int departmentId, stri var test = new RmsHydrantFlowTest { RmsHydrantFlowTestId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsHydrantId = hydrant.RmsHydrantId, - TestedOn = input.TestedOn == default ? now : input.TestedOn, TestedByUserId = userId, StaticPressurePsi = input.StaticPressurePsi, ResidualPressurePsi = input.ResidualPressurePsi, + TestedOn = RecordsPreventionGate.RequireStorableDate(input.TestedOn == default ? now : input.TestedOn, "The test date is not valid."), TestedByUserId = userId, StaticPressurePsi = input.StaticPressurePsi, ResidualPressurePsi = input.ResidualPressurePsi, PitotPressurePsi = input.PitotPressurePsi, OutletDiameterInches = input.OutletDiameterInches, Coefficient = coefficient, Notes = RecordsPreventionGate.Trim(input.Notes, 2000), CreatedOn = now }; test.FlowGpm = HydrantFlowCalculator.FlowGpm(coefficient, input.OutletDiameterInches, input.PitotPressurePsi); @@ -150,7 +150,7 @@ public async Task RecordMaintenanceAsync(int departmentId var row = new RmsHydrantMaintenance { RmsHydrantMaintenanceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsHydrantId = hydrant.RmsHydrantId, - PerformedOn = input.PerformedOn == default ? now : input.PerformedOn, PerformedByUserId = userId, Kind = input.Kind == 0 ? (int)RmsHydrantMaintenanceKind.Inspection : input.Kind, + PerformedOn = RecordsPreventionGate.RequireStorableDate(input.PerformedOn == default ? now : input.PerformedOn, "The maintenance date is not valid."), PerformedByUserId = userId, Kind = input.Kind == 0 ? (int)RmsHydrantMaintenanceKind.Inspection : input.Kind, Notes = RecordsPreventionGate.Trim(input.Notes, 2000), ReturnedToService = input.ReturnedToService, CreatedOn = now }; await _maintenance.InsertAsync(row, cancellationToken, true); diff --git a/Core/Resgrid.Services/Records/RecordsInspectionsService.cs b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs index 80e273b8c..1c4c48df3 100644 --- a/Core/Resgrid.Services/Records/RecordsInspectionsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs @@ -215,6 +215,7 @@ public static List ParseItems(string json) public async Task> ListAsync(int departmentId, string userId, RmsInspectionQuery query) { await RequireViewAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(query?.ScheduledBefore, "The scheduled-before date is not valid."); var rows = (await _inspections.QueryAsync(departmentId, query ?? new RmsInspectionQuery()))?.ToList() ?? new List(); await _protection.RevealInspectionsAsync(departmentId, rows); return rows; @@ -223,6 +224,7 @@ public async Task> ListAsync(int departmentId, string userId public async Task CountAsync(int departmentId, string userId, RmsInspectionQuery query) { await RequireViewAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(query?.ScheduledBefore, "The scheduled-before date is not valid."); return await _inspections.CountAsync(departmentId, query ?? new RmsInspectionQuery()); } @@ -268,11 +270,13 @@ public async Task ScheduleAsync(int departmentId, string userId, private async Task CreateScheduledAsync(int departmentId, string userId, RmsOccupancy occupancy, RmsInspectionProgram program, DateTime scheduledOn, string inspectorUserId, string parentInspectionId, CancellationToken cancellationToken) { var now = DateTime.UtcNow; + // Checked before the initializer, which draws the next inspection number. + scheduledOn = RecordsPreventionGate.RequireStorableDate(scheduledOn == default ? now : scheduledOn, "The scheduled date is not valid."); var inspection = new RmsInspection { RmsInspectionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsOccupancyId = occupancy.RmsOccupancyId, RmsInspectionProgramId = program?.RmsInspectionProgramId, InspectionNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Inspection, now, cancellationToken), State = (int)RmsInspectionState.Scheduled, Result = (int)RmsInspectionResult.NotRecorded, - ScheduledOn = scheduledOn == default ? now : scheduledOn, InspectorUserId = RecordsPreventionGate.Trim(inspectorUserId, 128), ParentInspectionId = parentInspectionId, + ScheduledOn = scheduledOn, InspectorUserId = RecordsPreventionGate.Trim(inspectorUserId, 128), ParentInspectionId = parentInspectionId, CreatedOn = now, CreatedByUserId = userId, ModifiedOn = now, RowVersion = 1 }; await _inspections.InsertAsync(inspection, cancellationToken, true); @@ -447,7 +451,7 @@ public async Task SaveViolationAsync(int departmentId, string user entity.RmsCodeSetId = RecordsPreventionGate.Trim(input.RmsCodeSetId, 36); entity.RmsCodeSectionId = RecordsPreventionGate.Trim(input.RmsCodeSectionId, 36); entity.Description = RecordsPreventionGate.Require(input.Description, 4000, "A violation needs a description."); entity.Severity = Math.Clamp(input.Severity == 0 ? 2 : input.Severity, 1, 4); entity.CorrectiveAction = RecordsPreventionGate.Trim(input.CorrectiveAction, 4000); - entity.DueOn = input.DueOn ?? entity.DueOn ?? now.AddDays(30); entity.ModifiedOn = now; entity.RowVersion++; + entity.DueOn = RecordsPreventionGate.RequireStorableDate(input.DueOn, "The due date is not valid.") ?? entity.DueOn ?? now.AddDays(30); entity.ModifiedOn = now; entity.RowVersion++; var plaintext = PlaintextSnapshot.Take(entity, RmsProtectedFields.Violations); await _protection.ProtectViolationAsync(departmentId, entity, existing, userId, cancellationToken); if (existing == null) await _violations.InsertAsync(entity, cancellationToken, true); else await _violations.UpdateAsync(entity, cancellationToken, true); diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs index 6cf337174..a30eae242 100644 --- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs @@ -259,7 +259,7 @@ public async Task AddNoteAsync(int departmentId, string us var note = new RmsInvestigationNote { RmsInvestigationNoteId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, Kind = (int)kind, - OccurredOn = occurredOn == default ? now : occurredOn, AuthorUserId = userId, Subject = RecordsPreventionGate.Trim(subject, 250), Body = RecordsPreventionGate.Require(body, 32000, "A note needs content."), CreatedOn = now, ModifiedOn = now, RowVersion = 1 + OccurredOn = RecordsPreventionGate.RequireStorableDate(occurredOn == default ? now : occurredOn, "The note date is not valid."), AuthorUserId = userId, Subject = RecordsPreventionGate.Trim(subject, 250), Body = RecordsPreventionGate.Require(body, 32000, "A note needs content."), CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; var plaintext = PlaintextSnapshot.Take(note, RmsProtectedFields.InvestigationNotes); await _protection.ProtectInvestigationNoteAsync(departmentId, note, null, userId, cancellationToken); @@ -294,11 +294,13 @@ public async Task AddEvidenceAsync(int departmentId, s var (investigation, _) = await RequireMemberAsync(departmentId, userId, caseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); RequireOpen(investigation); var now = DateTime.UtcNow; + // Checked before the initializer, which draws the next evidence number. + var collectedOn = RecordsPreventionGate.RequireStorableDate(input.CollectedOn == default ? now : input.CollectedOn, "The collection date is not valid."); var item = new RmsInvestigationEvidence { RmsInvestigationEvidenceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RmsInvestigationCaseId = caseId, EvidenceNumber = await _gate.NextNumberAsync(departmentId, RmsPreventionNumberKinds.Evidence, now, cancellationToken), Kind = input.Kind == 0 ? (int)RmsInvestigationEvidenceKind.Physical : input.Kind, - Description = RecordsPreventionGate.Require(input.Description, 4000, "Evidence needs a description."), CollectedOn = input.CollectedOn == default ? now : input.CollectedOn, + Description = RecordsPreventionGate.Require(input.Description, 4000, "Evidence needs a description."), CollectedOn = collectedOn, CollectedByUserId = string.IsNullOrWhiteSpace(input.CollectedByUserId) ? userId : input.CollectedByUserId.Trim(), CollectedFrom = RecordsPreventionGate.Trim(input.CollectedFrom, 1000), State = (int)RmsEvidenceState.Collected, StorageLocation = RecordsPreventionGate.Trim(input.StorageLocation, 250), CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; diff --git a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs index aa8c662b7..0f4323764 100644 --- a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs +++ b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs @@ -122,6 +122,7 @@ public async Task SaveAsync(int departmentId, string userId, RmsOc await _gate.RequireAdminAsync(departmentId, userId); if (input == null) throw new ArgumentNullException(nameof(input)); input.Name = RecordsPreventionGate.Require(input.Name, 250, "An occupancy needs a name."); + input.NextReviewDue = RecordsPreventionGate.RequireStorableDate(input.NextReviewDue, "The next review date is not valid."); var now = DateTime.UtcNow; RmsOccupancy entity; diff --git a/Core/Resgrid.Services/Records/RecordsPermitsService.cs b/Core/Resgrid.Services/Records/RecordsPermitsService.cs index 2fcfaa90e..6dbbd9a49 100644 --- a/Core/Resgrid.Services/Records/RecordsPermitsService.cs +++ b/Core/Resgrid.Services/Records/RecordsPermitsService.cs @@ -64,6 +64,7 @@ public async Task SaveTypeAsync(int departmentId, string userId, public async Task> ListAsync(int departmentId, string userId, RmsPermitQuery query) { await RequireViewAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(query?.ExpiresBefore, "The expires-before date is not valid."); var rows = (await _permits.QueryAsync(departmentId, query ?? new RmsPermitQuery()))?.ToList() ?? new List(); await _protection.RevealPermitsAsync(departmentId, rows); return rows; @@ -72,6 +73,7 @@ public async Task> ListAsync(int departmentId, string userId, Rm public async Task CountAsync(int departmentId, string userId, RmsPermitQuery query) { await RequireViewAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(query?.ExpiresBefore, "The expires-before date is not valid."); return await _permits.CountAsync(departmentId, query ?? new RmsPermitQuery()); } @@ -134,7 +136,7 @@ public async Task UpdateAsync(int departmentId, string userId, RmsPer permit.ApplicantContactId = RecordsPreventionGate.Trim(input.ApplicantContactId, 128); permit.ApplicantName = RecordsPreventionGate.Trim(input.ApplicantName, 250); permit.ApplicantPhone = RecordsPreventionGate.Trim(input.ApplicantPhone, 50); permit.ApplicantEmail = RecordsPreventionGate.Trim(input.ApplicantEmail, 250); permit.Description = RecordsPreventionGate.Trim(input.Description, 4000); permit.Conditions = RecordsPreventionGate.Trim(input.Conditions, 8000); permit.ReviewNotes = RecordsPreventionGate.Trim(input.ReviewNotes, 8000); permit.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); permit.FeeAmount = input.FeeAmount; - if (input.ExpiresOn.HasValue) permit.ExpiresOn = input.ExpiresOn; if (input.EffectiveOn.HasValue) permit.EffectiveOn = input.EffectiveOn; + if (input.ExpiresOn.HasValue) permit.ExpiresOn = RecordsPreventionGate.RequireStorableDate(input.ExpiresOn, "The expiry date is not valid."); if (input.EffectiveOn.HasValue) permit.EffectiveOn = RecordsPreventionGate.RequireStorableDate(input.EffectiveOn, "The effective date is not valid."); permit.ModifiedOn = DateTime.UtcNow; permit.RowVersion++; var plaintext = PlaintextSnapshot.Take(permit, RmsProtectedFields.Permits); await _protection.ProtectPermitAsync(departmentId, permit, existing, userId, cancellationToken); @@ -175,8 +177,8 @@ public async Task TransitionAsync(int departmentId, string userId, st case RmsPermitState.Approved: permit.ReviewedOn = now; permit.ReviewedByUserId = userId; break; case RmsPermitState.Denied: permit.ReviewedOn = now; permit.ReviewedByUserId = userId; permit.DecisionReason = RecordsPreventionGate.Trim(reason, 1000); break; case RmsPermitState.Issued: - permit.IssuedOn = now; permit.IssuedByUserId = userId; permit.EffectiveOn = effectiveOn ?? permit.EffectiveOn ?? now; - permit.ExpiresOn = expiresOn ?? permit.ExpiresOn ?? permit.EffectiveOn.Value.AddDays(type?.DefaultValidityDays ?? 365); + permit.IssuedOn = now; permit.IssuedByUserId = userId; permit.EffectiveOn = RecordsPreventionGate.RequireStorableDate(effectiveOn ?? permit.EffectiveOn ?? now, "The effective date is not valid."); + permit.ExpiresOn = RecordsPreventionGate.RequireStorableDate(expiresOn ?? permit.ExpiresOn ?? permit.EffectiveOn.Value.AddDays(type?.DefaultValidityDays ?? 365), "The expiry date is not valid."); if (permit.ExpiresOn <= permit.EffectiveOn) throw new ArgumentException("The expiry must fall after the effective date."); break; case RmsPermitState.Revoked: permit.DecisionReason = RecordsPreventionGate.Trim(reason, 1000); break; diff --git a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs index 31db47995..eb3b9ca2d 100644 --- a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs +++ b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs @@ -1,4 +1,5 @@ using System; +using System.Data.SqlTypes; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; @@ -128,5 +129,18 @@ public static string Require(string value, int max, string message) throw new ArgumentException($"{message} (at most {max} characters)."); return trimmed; } + + /// + /// Dapper binds DateTime as SQL datetime, which starts at 1753, so a client date outside it (a two-digit year sent as + /// 0026) failed the whole statement with SqlDateTime overflow. Refuse it here so the web and API both get a message. + /// + public static DateTime RequireStorableDate(DateTime value, string message) + { + if (value < (DateTime)SqlDateTime.MinValue || value > (DateTime)SqlDateTime.MaxValue) + throw new ArgumentException(message); + return value; + } + + public static DateTime? RequireStorableDate(DateTime? value, string message) => value.HasValue ? RequireStorableDate(value.Value, message) : null; } } diff --git a/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs b/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs index e2dc2f7ea..45bfdfe8c 100644 --- a/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs +++ b/Core/Resgrid.Services/Records/RecordsQualityReviewService.cs @@ -108,6 +108,7 @@ public static List ParseFindings(string json) public async Task> SampleAsync(int departmentId, string userId, string rubricId, DateTime sinceUtc, CancellationToken cancellationToken = default) { await RequireReviewerAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(sinceUtc, "The sample start date is not valid."); var rubric = await _rubrics.GetByIdForDepartmentAsync(departmentId, rubricId); if (rubric == null || rubric.DeletedOn != null || !rubric.IsActive) throw new ArgumentException("Choose an active rubric."); var criteria = ParseCriteria(rubric.CriteriaJson); @@ -204,6 +205,7 @@ public async Task> GetForRecordAsync(int departmentId, st public async Task GetTrendsAsync(int departmentId, string userId, DateTime sinceUtc) { await RequireReviewerAsync(departmentId, userId); + RecordsPreventionGate.RequireStorableDate(sinceUtc, "The start date is not valid."); var scored = ((await _reviews.GetScoredSinceAsync(departmentId, sinceUtc, 5000)) ?? Enumerable.Empty()).Where(r => r.Score.HasValue).ToList(); var trends = new RecordsQualityTrends { Since = sinceUtc, Scored = scored.Count, Sampled = scored.Count + ((await _reviews.GetPendingAsync(departmentId, 1000))?.Count() ?? 0), AverageScore = scored.Count == 0 ? 0 : Math.Round(scored.Average(r => r.Score.Value), 1) }; List Rows(Func key) => scored.Where(r => key(r) != null).GroupBy(key).Select(g => new RecordsQualityTrendRow { Key = g.Key, Label = g.Key, Reviews = g.Count(), AverageScore = Math.Round(g.Average(r => r.Score.Value), 1), AmendmentsRecommended = g.Count(r => r.AmendmentRecommended) }).OrderBy(r => r.AverageScore).ToList(); diff --git a/Core/Resgrid.Services/Search/SystemActionCatalog.cs b/Core/Resgrid.Services/Search/SystemActionCatalog.cs index a2d3e46fd..0621f02a3 100644 --- a/Core/Resgrid.Services/Search/SystemActionCatalog.cs +++ b/Core/Resgrid.Services/Search/SystemActionCatalog.cs @@ -51,7 +51,7 @@ public static class SystemActionCatalog private const string Log = "Log"; private static SystemActionDefinition Nav(string key, string title, string description, string path, string[] keywords = null, - string claimResource = null, string claimAction = null, string module = null, string flag = null, bool adminOnly = false, bool hiddenWhenRecords = false) + string claimResource = null, string claimAction = null, string module = null, string flag = null, bool adminOnly = false, bool hiddenAfterCutover = false) { return new SystemActionDefinition { @@ -66,14 +66,14 @@ private static SystemActionDefinition Nav(string key, string title, string descr Module = module, FeatureFlag = flag, DepartmentAdminOnly = adminOnly, - HiddenWhenRecordsEnabled = hiddenWhenRecords + HiddenAfterRecordsCutover = hiddenAfterCutover }; } private static SystemActionDefinition Act(string key, string title, string description, string path, string category, string[] keywords = null, - string claimResource = null, string claimAction = null, string module = null, string flag = null, bool adminOnly = false, bool hiddenWhenRecords = false) + string claimResource = null, string claimAction = null, string module = null, string flag = null, bool adminOnly = false, bool hiddenAfterCutover = false) { - var d = Nav(key, title, description, path, keywords, claimResource, claimAction, module, flag, adminOnly, hiddenWhenRecords); + var d = Nav(key, title, description, path, keywords, claimResource, claimAction, module, flag, adminOnly, hiddenAfterCutover); d.Category = category; return d; } @@ -120,8 +120,9 @@ private static SystemActionDefinition Act(string key, string title, string descr Act("new-calendar-item", "New Calendar Event", "Create a calendar event", "/User/Calendar/New", SystemActionCategories.Create, new[] { "event", "meeting", "schedule" }, Schedule, Create, SystemActionModules.Calendar), // ---- Logs (legacy) / Records - Nav("logs", "Logs", "Run, training, work and meeting logs", "/User/Logs", new[] { "run log", "activity", "reports", "training log", "work log" }, Log, View, SystemActionModules.Logs, hiddenWhenRecords: true), - Act("new-log", "New Log", "Create a run report, training log or work log", "/User/Logs/NewLog", SystemActionCategories.Create, new[] { "run report", "training log", "work log" }, Log, Create, SystemActionModules.Logs, hiddenWhenRecords: true), + // Logs stay findable before and after the Records cutover (read-only after it); only creating one goes away. + Nav("logs", "Logs", "Run, training, work and meeting logs", "/User/Logs", new[] { "run log", "activity", "reports", "training log", "work log", "legacy logs", "existing logs" }, Log, View, SystemActionModules.Logs), + Act("new-log", "New Log", "Create a run report, training log or work log", "/User/Logs/NewLog", SystemActionCategories.Create, new[] { "run report", "training log", "work log" }, Log, Create, SystemActionModules.Logs, hiddenAfterCutover: true), Nav("records", "Records", "Records queue: run reports, training and operational records", "/User/Records", new[] { "rms", "run reports", "incident reports", "neris", "logs" }, Record, View, SystemActionModules.Logs, FeatureFlagKeys.RecordsSystem), Nav("records-dashboard", "Records Dashboard", "Records due, submissions and quality at a glance", "/User/Records/Dashboard", new[] { "rms", "overview", "due" }, Record, View, SystemActionModules.Logs, FeatureFlagKeys.RecordsSystem), Act("records-settings", "Records Settings", "Lifecycle, numbering, search, retention and visibility settings for Records", "/User/Records/Settings", SystemActionCategories.Manage, new[] { "rms settings", "retention", "numbering" }, Record, View, SystemActionModules.Logs, FeatureFlagKeys.RecordsSystem, adminOnly: true), diff --git a/Core/Resgrid.Services/Search/SystemActionsService.cs b/Core/Resgrid.Services/Search/SystemActionsService.cs index c11da0ebc..b1a5864fe 100644 --- a/Core/Resgrid.Services/Search/SystemActionsService.cs +++ b/Core/Resgrid.Services/Search/SystemActionsService.cs @@ -20,10 +20,12 @@ namespace Resgrid.Services.Search public class SystemActionsService : ISystemActionsService { private readonly IFeatureToggleService _featureToggles; + private readonly IRecordsCutoverService _recordsCutover; - public SystemActionsService(IFeatureToggleService featureToggles) + public SystemActionsService(IFeatureToggleService featureToggles, IRecordsCutoverService recordsCutover) { _featureToggles = featureToggles ?? throw new ArgumentNullException(nameof(featureToggles)); + _recordsCutover = recordsCutover ?? throw new ArgumentNullException(nameof(recordsCutover)); } public async Task> SearchAsync(string text, SearchPrincipal principal, int max = 8, CancellationToken cancellationToken = default) @@ -76,7 +78,18 @@ async Task FlagAsync(string key) return value; } - var recordsOn = await FlagAsync(FeatureFlagKeys.RecordsSystem); + // The cutover, not the Records.System flag, is what makes legacy Logs read-only: a department with the flag + // on but Records not yet activated is still writing Logs. + bool? legacyWritesBlocked = null; + async Task LegacyWritesBlockedAsync() + { + if (legacyWritesBlocked.HasValue) + return legacyWritesBlocked.Value; + try { legacyWritesBlocked = await _recordsCutover.AreLegacyWritesBlockedAsync(principal.DepartmentId); } + catch (Exception ex) { Logging.LogException(ex, "Records cutover state could not be evaluated for the command palette; hiding legacy Logs writes."); legacyWritesBlocked = true; } + return legacyWritesBlocked.Value; + } + var allowed = new List(); foreach (var def in SystemActionCatalog.All) { @@ -87,7 +100,7 @@ async Task FlagAsync(string key) continue; if (!principal.ModuleEnabled(def.Module)) continue; - if (def.HiddenWhenRecordsEnabled && recordsOn) + if (def.HiddenAfterRecordsCutover && await LegacyWritesBlockedAsync()) continue; if (!await FlagAsync(def.FeatureFlag)) continue; diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs index c7766a13d..54463c2e2 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs @@ -401,7 +401,9 @@ private async Task EnsureStateAsync(int departmentId, CancellationToken cancella if (existing != null) return; var now = DateTime.UtcNow; - await _states.SaveOrUpdateAsync(new SearchIndexState + // A department's first searches arrive together (typeahead sends one per keystroke) and all see no row + // above, so the create has to be conditional or every request but one fails on the unique index. + await _states.InsertIfMissingAsync(new SearchIndexState { IndexName = SearchIndexNames.Global, DepartmentId = departmentId, @@ -411,7 +413,7 @@ await _states.SaveOrUpdateAsync(new SearchIndexState RebuildRequestedOn = now, CreatedOn = now, ModifiedOn = now - }, cancellationToken, true); + }, cancellationToken); } catch (Exception ex) { diff --git a/Providers/Resgrid.Providers.Bus/NotificationProvider.cs b/Providers/Resgrid.Providers.Bus/NotificationProvider.cs index c268e7998..539c206e0 100644 --- a/Providers/Resgrid.Providers.Bus/NotificationProvider.cs +++ b/Providers/Resgrid.Providers.Bus/NotificationProvider.cs @@ -16,9 +16,20 @@ namespace Resgrid.Providers.Bus { public class NotificationProvider : INotificationProvider { + /// + /// The Azure user hub is legacy (Novu delivers user pushes) and is left unconfigured in newer + /// deployments. The client factory throws on an empty connection string, and there is nothing to + /// register with or remove from a hub that was never set up, so the methods that would otherwise + /// throw to their callers return early instead. + /// + private static bool IsHubConfigured() + { + return !String.IsNullOrWhiteSpace(Config.ServiceBusConfig.AzureNotificationHub_FullConnectionString); + } + public async Task RegisterPush(PushUri pushUri) { - if (String.IsNullOrWhiteSpace(pushUri.DeviceId)) + if (String.IsNullOrWhiteSpace(pushUri.DeviceId) || !IsHubConfigured()) return; var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureNotificationHub_PushUrl); @@ -98,6 +109,9 @@ public async Task RegisterPush(PushUri pushUri) public async Task UnRegisterPush(PushUri pushUri) { + if (!IsHubConfigured()) + return; + var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureNotificationHub_PushUrl); var registrations = await hubClient.GetRegistrationsByTagAsync(string.Format("userId:{0}", pushUri.UserId), 50); @@ -121,6 +135,9 @@ public async Task UnRegisterPush(PushUri pushUri) public async Task UnRegisterPushByUserDeviceId(PushUri pushUri) { + if (!IsHubConfigured()) + return; + var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureNotificationHub_PushUrl); var registrations = await hubClient.GetRegistrationsByTagAsync(string.Format("userId:{0}", pushUri.UserId), 50); diff --git a/Providers/Resgrid.Providers.Bus/UnitNotificationProvider.cs b/Providers/Resgrid.Providers.Bus/UnitNotificationProvider.cs index f210ca3fd..83235e0bd 100644 --- a/Providers/Resgrid.Providers.Bus/UnitNotificationProvider.cs +++ b/Providers/Resgrid.Providers.Bus/UnitNotificationProvider.cs @@ -15,9 +15,20 @@ namespace Resgrid.Providers.Bus { public class UnitNotificationProvider : IUnitNotificationProvider { + /// + /// The Azure unit hub is legacy (Novu delivers unit pushes) and is left unconfigured in newer + /// deployments. The client factory throws on an empty connection string, and there is nothing to + /// register with or remove from a hub that was never set up, so the methods that would otherwise + /// throw to their callers return early instead. + /// + private static bool IsHubConfigured() + { + return !String.IsNullOrWhiteSpace(Config.ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString); + } + public async Task RegisterPush(PushUri pushUri) { - if (String.IsNullOrWhiteSpace(pushUri.DeviceId)) + if (String.IsNullOrWhiteSpace(pushUri.DeviceId) || !IsHubConfigured()) return; if (pushUri.UnitId.HasValue) @@ -98,6 +109,9 @@ public async Task RegisterPush(PushUri pushUri) public async Task UnRegisterPush(PushUri pushUri) { + if (!IsHubConfigured()) + return; + var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureUnitNotificationHub_PushUrl); var registrations = await hubClient.GetRegistrationsByTagAsync(string.Format("deviceId:{0}", pushUri.DeviceId), 50); @@ -121,6 +135,9 @@ public async Task UnRegisterPush(PushUri pushUri) public async Task UnRegisterPushByUserDeviceId(PushUri pushUri) { + if (!IsHubConfigured()) + return; + var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureUnitNotificationHub_PushUrl); var registrations = await hubClient.GetRegistrationsByTagAsync(string.Format("userId:{0}", pushUri.UserId), 50); @@ -155,6 +172,9 @@ public async Task UnRegisterPushByUserDeviceId(PushUri pushUri) public async Task UnRegisterPushByUUID(string uuid) { + if (!IsHubConfigured()) + return; + var hubClient = NotificationHubClient.CreateClientFromConnectionString(Config.ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString, Config.ServiceBusConfig.AzureUnitNotificationHub_PushUrl); var registrations = await hubClient.GetRegistrationsByTagAsync(string.Format("uuid:{0}", uuid), 50); diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 371dd2286..b0321a6e1 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Autofac; using Newtonsoft.Json; using Resgrid.Config; using Resgrid.Model; @@ -18,18 +19,21 @@ namespace Resgrid.Providers.Bus /// Subscribes to all domain events and, for each active workflow whose trigger matches, /// creates a WorkflowRun (Pending) and enqueues a WorkflowQueueItem to RabbitMQ. /// Free-plan departments are subject to an aggressive, non-bypassable rate limit. + /// + /// The scoped repositories and services are NOT constructor-injected: this is a singleton, and capturing + /// InstancePerLifetimeScope dependencies in it would pin them to the root scope — one shared unit of work, + /// and one shared DB connection, for every event the process ever handles. Events arrive concurrently + /// (async listeners, and several worker jobs dispatching at once), and a Records run insert opens a + /// transaction on that unit of work, so the other handlers' queries landed on the same connection + /// ("The connection does not support MultipleActiveResultSets") and inside another event's transaction. + /// Each event instead runs in its own child scope, as ChatProvisioningEventService does. /// public class WorkflowEventProvider : IWorkflowEventProvider { private readonly IEventAggregator _eventAggregator; private static IOutboundQueueProvider _outboundQueueProvider; - private static IWorkflowRepository _workflowRepository; - private static IWorkflowRunRepository _runRepository; - private static IDepartmentsService _departmentsService; - private static ISubscriptionsService _subscriptionsService; - private static IProtectedProjectionService _protectedProjectionService; - private static Lazy _history; - private static Task ProtectChecklistRunAsync(WorkflowRun run) => (_history?.Value ?? throw new InvalidOperationException("Readiness history protection is unavailable.")) + private static ILifetimeScope _lifetimeScope; + private static Task ProtectChecklistRunAsync(ILifetimeScope scope, WorkflowRun run) => scope.Resolve() .ProtectAsync(run.DepartmentId, run.WorkflowRunId, run, ReadinessHistoryFields.Runs); // Per-minute rate limit tracker: departmentId → (window start, count) @@ -55,20 +59,11 @@ private static readonly System.Collections.Generic.HashSet history = null) + ILifetimeScope lifetimeScope) { _eventAggregator = eventAggregator; _outboundQueueProvider = outboundQueueProvider; - _workflowRepository = workflowRepository; - _runRepository = runRepository; - _departmentsService = departmentsService; - _subscriptionsService = subscriptionsService; - _protectedProjectionService = protectedProjectionService; - _history = history; + _lifetimeScope = lifetimeScope; RegisterListeners(); } @@ -206,24 +201,30 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven { try { + // Own scope per event: fresh repositories and services with their own unit of work (see the class summary). + using var scope = _lifetimeScope.BeginLifetimeScope(); + var workflowRepository = scope.Resolve(); + var runRepository = scope.Resolve(); + System.Collections.Generic.List workflows = null; string payloadJson = null; if (envelope != null) { // The skip rows below need the workflow list, so for Records events it is loaded before the limits. - workflows = (await _workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); + workflows = (await workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); if (workflows == null || workflows.Count == 0) return; + var protectedProjectionService = scope.Resolve(); payloadJson = ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem) - ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, eventObj, _protectedProjectionService, wrapped: true) - : await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + ? await ChecklistWorkflowPayload.ProjectAsync(departmentId, eventObj, protectedProjectionService, wrapped: true) + : await protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); if (ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem)) { // Retry a run whose database insert succeeded but whose queue send did not. // Its stable run ID is claimed atomically by the worker before executing actions. - var existingRuns = (await _runRepository.GetByWorkflowsAndEventAsync(departmentId, workflows.Select(w => w.WorkflowId).ToArray(), envelope.EventId)) + var existingRuns = (await runRepository.GetByWorkflowsAndEventAsync(departmentId, workflows.Select(w => w.WorkflowId).ToArray(), envelope.EventId)) .GroupBy(r => r.WorkflowId).ToDictionary(g => g.Key, g => g.OrderBy(r => r.StartedOn).First()); foreach (var workflow in workflows.ToList()) { @@ -237,7 +238,7 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven } // ── Plan-aware rate limiting ───────────────────────────────────────── - var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId); + var plan = await scope.Resolve().GetCurrentPlanForDepartmentAsync(departmentId); var isFreePlan = plan?.IsFree ?? false; if (isFreePlan) @@ -245,14 +246,14 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven // Free plan: aggressive per-minute limit with NO event-type exemptions if (!IsWithinRateLimit(departmentId, WorkflowConfig.FreePlanRateLimitPerDepartmentPerMinute)) { - await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); + await RecordSkippedAsync(scope, workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); return; } // Free plan: daily run cap if (!IsWithinDailyLimit(departmentId, WorkflowConfig.FreePlanDailyRunLimit)) { - await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.DailyLimit); + await RecordSkippedAsync(scope, workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.DailyLimit); return; } } @@ -262,14 +263,14 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven if (!_rateLimitExemptEventTypes.Contains(eventType) && !IsWithinRateLimit(departmentId, WorkflowConfig.RateLimitPerDepartmentPerMinute)) { - await RecordSkippedAsync(workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); + await RecordSkippedAsync(scope, workflows, departmentId, eventType, envelope, payloadJson, WorkflowRunSkipReasons.RateLimit); return; } } // ── End rate limiting ──────────────────────────────────────────────── if (workflows == null) - workflows = (await _workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); + workflows = (await workflowRepository.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)eventType))?.ToList(); if (workflows == null || workflows.Count == 0) return; @@ -277,13 +278,13 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven // redacted HERE, before it reaches WorkflowRun.InputPayload, the queue, retries, // dead letters, history, or designer previews. if (payloadJson == null) - payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); - var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + payloadJson = await scope.Resolve().BuildSafeWorkflowPayloadAsync(departmentId, eventObj); + var department = await scope.Resolve().GetDepartmentByIdAsync(departmentId); var deptCode = department?.Code ?? string.Empty; foreach (var workflow in workflows) { - if (!ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem) && await IsDuplicateAsync(workflow.WorkflowId, envelope)) continue; + if (!ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem) && await IsDuplicateAsync(runRepository, workflow.WorkflowId, envelope)) continue; var run = WorkflowRunEnvelope.Apply(new WorkflowRun { @@ -300,14 +301,14 @@ private static async Task HandleEventAsync(int departmentId, WorkflowTriggerEven try { - if (ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem)) await ProtectChecklistRunAsync(run); - run = await _runRepository.InsertAsync(run, CancellationToken.None); + if (ChecklistWorkflowPayload.IsReadinessProducer(envelope?.ProducerSubsystem)) await ProtectChecklistRunAsync(scope, run); + run = await runRepository.InsertAsync(run, CancellationToken.None); } catch (Exception ex) when (envelope != null && WorkflowRunEnvelope.IsDuplicateKeyViolation(ex)) { if (ChecklistWorkflowPayload.IsReadinessProducer(envelope.ProducerSubsystem)) { - var existing = await _runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); + var existing = await runRepository.GetByWorkflowAndEventAsync(workflow.WorkflowId, envelope.EventId); if (existing == null || existing.DepartmentId != departmentId) throw; if (existing.Status == (int)WorkflowRunStatus.Pending) await RequeueChecklistRunAsync(existing, payloadJson); } @@ -353,12 +354,12 @@ private static async Task RequeueChecklistRunAsync(WorkflowRun run, string safeP } /// One initial run per (WorkflowId, EventId): a retry or a second dispatcher reuses the existing run. - private static async Task IsDuplicateAsync(string workflowId, DomainEventDispatchedEvent envelope) + private static async Task IsDuplicateAsync(IWorkflowRunRepository runRepository, string workflowId, DomainEventDispatchedEvent envelope) { if (envelope == null || string.IsNullOrWhiteSpace(envelope.EventId)) return false; - var existing = await _runRepository.GetByWorkflowAndEventAsync(workflowId, envelope.EventId); + var existing = await runRepository.GetByWorkflowAndEventAsync(workflowId, envelope.EventId); if (existing == null) return false; @@ -371,16 +372,17 @@ private static async Task IsDuplicateAsync(string workflowId, DomainEventD /// reason, so it shows in run history and health instead of vanishing (plan section 5.6). Legacy events /// (no envelope) are still dropped silently, as before. /// - private static async Task RecordSkippedAsync(System.Collections.Generic.List workflows, int departmentId, WorkflowTriggerEventType eventType, + private static async Task RecordSkippedAsync(ILifetimeScope scope, System.Collections.Generic.List workflows, int departmentId, WorkflowTriggerEventType eventType, DomainEventDispatchedEvent envelope, string payloadJson, string reason) { if (envelope == null || workflows == null) return; + var runRepository = scope.Resolve(); var now = DateTime.UtcNow; foreach (var workflow in workflows) { - if (await IsDuplicateAsync(workflow.WorkflowId, envelope)) + if (await IsDuplicateAsync(runRepository, workflow.WorkflowId, envelope)) continue; var run = WorkflowRunEnvelope.MarkSkipped(WorkflowRunEnvelope.Apply(new WorkflowRun @@ -397,8 +399,8 @@ private static async Task RecordSkippedAsync(System.Collections.Generic.List IncrementAsync(string cacheKey, TimeSpan expiration) return 0; } + public async Task GetOrAddStringAsync(string cacheKey, string valueIfAbsent, TimeSpan slidingExpiration) + { + try + { + if (Config.SystemBehaviorConfig.CacheEnabled && _connection != null && _connection.IsConnected && valueIfAbsent != null) + { + IDatabase cache = _connection.GetDatabase(); + var key = SetCacheKeyForEnv(cacheKey); + + // GET-or-SET + PEXPIRE in a single server-side script so callers racing on a missing key + // all read back the one value that was stored, and a hit slides the TTL forward. + const string getOrAddScript = + "local current = redis.call('GET', KEYS[1])\n" + + "if current then\n" + + " redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" + + " return current\n" + + "end\n" + + "redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" + + "return ARGV[1]"; + + var result = await cache.ScriptEvaluateAsync( + getOrAddScript, + new RedisKey[] { key }, + new RedisValue[] { valueIfAbsent, (long)slidingExpiration.TotalMilliseconds }); + + return (string)result; + } + } + catch (TimeoutException) + { } + catch (RedisConnectionException ex) + { + Logging.LogError(ex); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + return null; + } + public bool IsConnected() { return _connection?.IsConnected ?? false; diff --git a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs index e33879fa7..cdf36b81d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs @@ -161,6 +161,49 @@ public Task> GetAllForIndexAsync(string indexName) $"SELECT * FROM {Tbl("SearchIndexStates")} WHERE {Col("IndexName")} = {P}IndexName ORDER BY {Col("DepartmentId")} ASC", new { IndexName = indexName }); } + + public async Task InsertIfMissingAsync(SearchIndexState state, CancellationToken cancellationToken = default) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + Utf8WriteGuard.Sanitize(state); + + // The race is absorbed in-statement rather than caught: RunAsync logs every exception before rethrowing, + // so a caught unique violation would still reach Sentry. PostgreSQL resolves it with ON CONFLICT DO NOTHING; + // on SQL Server UPDLOCK, HOLDLOCK takes a key-range lock on the unique index so a second inserter waits for + // the first to commit and then sees its row, instead of both passing NOT EXISTS. + var columns = Cols("IndexName", "DepartmentId", "SchemaVersion", "ProtectedCatalogVersion", "PolicyEpoch", "Generation", "State", + "DocumentCount", "LastRebuiltOn", "LastIndexedModifiedOn", "RebuildRequestedOn", "CreatedOn", "ModifiedOn"); + var values = $"{P}IndexName, {P}DepartmentId, {P}SchemaVersion, {P}ProtectedCatalogVersion, {P}PolicyEpoch, {P}Generation, {P}State, " + + $"{P}DocumentCount, {P}LastRebuiltOn, {P}LastIndexedModifiedOn, {P}RebuildRequestedOn, {P}CreatedOn, {P}ModifiedOn"; + var sql = IsPostgres + ? $"INSERT INTO {Tbl("SearchIndexStates")} ({columns}) VALUES ({values}) " + + $"ON CONFLICT ({Cols("IndexName", "DepartmentId")}) DO NOTHING RETURNING {Col("SearchIndexStateId")}" + : $"INSERT INTO {Tbl("SearchIndexStates")} ({columns}) OUTPUT INSERTED.{Col("SearchIndexStateId")} SELECT {values} " + + $"WHERE NOT EXISTS (SELECT 1 FROM {Tbl("SearchIndexStates")} WITH (UPDLOCK, HOLDLOCK) WHERE {Col("IndexName")} = {P}IndexName AND {Col("DepartmentId")} = {P}DepartmentId)"; + + DateTime? Timestamp(DateTime? value) => value.HasValue ? DatabaseTimestamp(value.Value) : (DateTime?)null; + var id = await ScalarAsync(sql, new + { + state.IndexName, + state.DepartmentId, + state.SchemaVersion, + state.ProtectedCatalogVersion, + state.PolicyEpoch, + state.Generation, + state.State, + state.DocumentCount, + LastRebuiltOn = Timestamp(state.LastRebuiltOn), + LastIndexedModifiedOn = Timestamp(state.LastIndexedModifiedOn), + RebuildRequestedOn = Timestamp(state.RebuildRequestedOn), + CreatedOn = DatabaseTimestamp(state.CreatedOn), + ModifiedOn = DatabaseTimestamp(state.ModifiedOn) + }, cancellationToken); + if (id == null) + return false; + + state.SearchIndexStateId = id.Value; + return true; + } } /// The single-writer publish lease (plan R7 writer sequence step 2). One row per index name, compare-and-set. diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index 95507b7c0..5e9ca24e7 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -64,7 +64,7 @@ public static void Initialize() .As() .InstancePerLifetimeScope(); - // IncidentCommandService resolves chat services lazily through the ServiceLocator for + // IncidentCommandService resolves chat services lazily (Lazy constructor parameters) for // its best-effort lane channel hooks. The real ChatChannelService can't activate in // this container (its repository graph isn't registered), which logged an activation // error on every lane save/delete test. Loose mocks turn the hooks into no-ops: diff --git a/Tests/Resgrid.Tests/Providers/NotificationProviderUnconfiguredHubTests.cs b/Tests/Resgrid.Tests/Providers/NotificationProviderUnconfiguredHubTests.cs new file mode 100644 index 000000000..855e4ca01 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NotificationProviderUnconfiguredHubTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Providers.Bus; + +namespace Resgrid.Tests.Providers +{ + /// + /// Deployments without the legacy Azure hubs used to throw ArgumentNullException('connectionString') + /// out of every register/unregister call, which in the unit worker skipped the Novu registration. + /// + [TestFixture] + public class NotificationProviderUnconfiguredHubTests + { + private (string, string) _savedConfig; + + [SetUp] + public void SetUp() + { + _savedConfig = (ServiceBusConfig.AzureNotificationHub_FullConnectionString, ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString); + } + + [TearDown] + public void TearDown() + { + (ServiceBusConfig.AzureNotificationHub_FullConnectionString, ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString) = _savedConfig; + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public async Task UnitProvider_register_and_unregister_should_no_op_when_hub_is_unconfigured(string connectionString) + { + ServiceBusConfig.AzureUnitNotificationHub_FullConnectionString = connectionString; + var provider = new UnitNotificationProvider(); + var pushUri = CreatePushUri(); + + Func act = async () => + { + await provider.UnRegisterPush(pushUri); + await provider.RegisterPush(pushUri); + await provider.UnRegisterPushByUserDeviceId(pushUri); + await provider.UnRegisterPushByUUID(pushUri.Uuid); + }; + + await act.Should().NotThrowAsync(); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public async Task UserProvider_register_and_unregister_should_no_op_when_hub_is_unconfigured(string connectionString) + { + ServiceBusConfig.AzureNotificationHub_FullConnectionString = connectionString; + var provider = new NotificationProvider(); + var pushUri = CreatePushUri(); + + Func act = async () => + { + await provider.UnRegisterPush(pushUri); + await provider.RegisterPush(pushUri); + await provider.UnRegisterPushByUserDeviceId(pushUri); + }; + + await act.Should().NotThrowAsync(); + } + + private static PushUri CreatePushUri() + { + return new PushUri + { + UserId = "D4813806-7923-4948-A219-439D6FDCE86A", + UnitId = 9, + DepartmentId = 7, + PlatformType = (int)Platforms.Android, + PushLocation = "DEPT", + DeviceId = "device-token", + Uuid = "device-uuid" + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs index 336cf23ac..e1f92c242 100644 --- a/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs +++ b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs @@ -154,6 +154,14 @@ public async Task The_list_json_and_training_chart_still_resolve() (await _logs.TrainingPerMonth()).Should().BeOfType(); } + [Test] + public async Task The_list_json_offers_view_but_no_delete_after_activation() + { + var rows = (await _logs.GetLogsList("2025")).Should().BeOfType().Which.Value.Should().BeAssignableTo>().Which.ToList(); + rows.Should().ContainSingle(r => r.LogId == 41); + rows.Should().OnlyContain(r => !r.CanDelete, "the list must not offer a Delete button that DeleteWorkLog refuses"); + } + [Test] public async Task Another_departments_log_never_renders_through_an_old_link() { @@ -210,6 +218,7 @@ public async Task Before_activation_the_same_links_still_write() await _logs.DeleteWorkLog(41, CancellationToken.None); _workLogs.Verify(w => w.DeleteLogAsync(41, It.IsAny()), Times.Once, "the guard is the cutover, not the controller"); ((ViewLogsView)((ViewResult)await _logs.View(41)).Model).CanDelete.Should().BeTrue("before activation an administrator may still delete"); + ((IEnumerable)((JsonResult)await _logs.GetLogsList("2025")).Value).Single(r => r.LogId == 41).CanDelete.Should().BeTrue("before activation the list still offers delete"); } #endregion diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs index 5aa4230de..47a3e39d6 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs @@ -239,6 +239,16 @@ public async Task Group_scoped_viewer_only_counts_records_they_could_open_and_pa captured.States.Should().Contain((int)RmsRecordState.Accepted); } + [Test] + public async Task A_window_bound_sql_cannot_store_is_refused_instead_of_overflowing_the_query() + { + // An API caller's two-digit year arrives as 0026; SQL datetime starts at 1753 (RESGRID-WEB-1MY). + Func start = () => _svc.GetWorkloadAsync(Dept, Member, new RecordsAnalyticsQuery { Start = new DateTime(26, 1, 1), End = End }); + await start.Should().ThrowAsync().WithMessage("The start date is not valid."); + Func end = () => _svc.GetResponsePerformanceAsync(Dept, Member, new RecordsAnalyticsQuery { End = DateTime.MaxValue }); + await end.Should().ThrowAsync().WithMessage("The end date is not valid."); + } + [Test] public async Task Gate_flag_viewer_window_clamp_and_row_cap_apply_to_every_dashboard() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsPreventionStorableDateTests.cs b/Tests/Resgrid.Tests/Rms/RecordsPreventionStorableDateTests.cs new file mode 100644 index 000000000..dc39b9e4a --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsPreventionStorableDateTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Data.SqlTypes; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services.Records; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// + /// A client date outside SQL datetime (a two-digit year sent as 0026) failed the insert with SqlDateTime overflow and a 500 + /// (Sentry RESGRID-WEB-1MY). The services now refuse it with an ArgumentException, which the web and v4 map to a message / 400, + /// before anything is written or a record number is drawn. + /// + [TestFixture] + public class RecordsPreventionStorableDateTests + { + private static readonly DateTime TwoDigitYear = new DateTime(26, 9, 25, 19, 0, 0, DateTimeKind.Utc); + private RmsPreventionHarness _h; + + [SetUp] + public void SetUp() => _h = new RmsPreventionHarness(); + + [Test] + public void The_gate_accepts_the_sql_datetime_range_and_refuses_dates_outside_it() + { + RecordsPreventionGate.RequireStorableDate((DateTime)SqlDateTime.MinValue, "bad").Should().Be((DateTime)SqlDateTime.MinValue); + RecordsPreventionGate.RequireStorableDate((DateTime)SqlDateTime.MaxValue, "bad").Should().Be((DateTime)SqlDateTime.MaxValue); + RecordsPreventionGate.RequireStorableDate((DateTime?)null, "bad").Should().BeNull(); + + foreach (var value in new[] { TwoDigitYear, ((DateTime)SqlDateTime.MinValue).AddTicks(-1), DateTime.MinValue, DateTime.MaxValue }) + FluentActions.Invoking(() => RecordsPreventionGate.RequireStorableDate(value, "bad")).Should().Throw().WithMessage("bad"); + } + + [Test] + public async Task Investigation_note_and_evidence_refuse_the_date_without_writing_or_drawing_an_evidence_number() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, 42, "Origin unknown."); + + Func note = () => _h.InvestigationsService.AddNoteAsync(Dept, Admin, investigation.RmsInvestigationCaseId, RmsInvestigationNoteKind.Interview, TwoDigitYear, "Interview", "Body"); + await note.Should().ThrowAsync().WithMessage("The note date is not valid."); + Func evidence = () => _h.InvestigationsService.AddEvidenceAsync(Dept, Admin, investigation.RmsInvestigationCaseId, new RmsInvestigationEvidence { Description = "Photo", CollectedOn = TwoDigitYear }); + await evidence.Should().ThrowAsync().WithMessage("The collection date is not valid."); + _h.CaseNotes.Rows.Should().BeEmpty(); + _h.Evidence.Rows.Should().BeEmpty(); + _h.Custody.Rows.Should().BeEmpty(); + + // Unset still means now, and the rejected call did not use up evidence number 1. + var saved = await _h.InvestigationsService.AddNoteAsync(Dept, Admin, investigation.RmsInvestigationCaseId, RmsInvestigationNoteKind.Interview, default, "Interview", "Body"); + saved.OccurredOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + var item = await _h.InvestigationsService.AddEvidenceAsync(Dept, Admin, investigation.RmsInvestigationCaseId, new RmsInvestigationEvidence { Description = "Photo" }); + item.EvidenceNumber.Should().EndWith("-0001"); + } + + [Test, NonParallelizable] + public async Task The_v4_note_endpoint_answers_400_with_the_message_instead_of_a_500() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, 42, "Origin unknown."); + var previous = ClaimsAuthorizationHelper._httpContextAccessor; + var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { + new Claim(ClaimTypes.PrimarySid, Admin), new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()) }, "Test")) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + try + { + var controller = new RecordInvestigationsController(_h.InvestigationsService, _h.Cutover.Object) { ControllerContext = new ControllerContext { HttpContext = http } }; + + var result = await controller.SaveNote(new CaseNoteInput { CaseId = investigation.RmsInvestigationCaseId, Kind = (int)RmsInvestigationNoteKind.Interview, OccurredOn = TwoDigitYear, Subject = "Interview", Body = "Body" }, default); + + var problem = result.Result.Should().BeOfType().Subject; + problem.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + problem.Value.Should().BeOfType().Which.Title.Should().Be("The note date is not valid."); + _h.CaseNotes.Rows.Should().BeEmpty(); + } + finally { ClaimsAuthorizationHelper._httpContextAccessor = previous; } + } + + [Test] + public async Task Hydrant_flow_test_and_maintenance_refuse_the_date_and_leave_the_hydrant_unchanged() + { + var hydrant = await _h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "H-101", Latitude = 45.5m, Longitude = -122.6m, Type = (int)RmsHydrantType.DryBarrel, MainSizeInches = 8 }); + + Func test = () => _h.HydrantsService.RecordFlowTestAsync(Dept, Admin, new RmsHydrantFlowTest { RmsHydrantId = hydrant.RmsHydrantId, TestedOn = TwoDigitYear, PitotPressurePsi = 64, OutletDiameterInches = 2.5m }); + await test.Should().ThrowAsync().WithMessage("The test date is not valid."); + Func maintenance = () => _h.HydrantsService.RecordMaintenanceAsync(Dept, Admin, new RmsHydrantMaintenance { RmsHydrantId = hydrant.RmsHydrantId, PerformedOn = TwoDigitYear }); + await maintenance.Should().ThrowAsync().WithMessage("The maintenance date is not valid."); + + _h.FlowTests.Rows.Should().BeEmpty(); + _h.Maintenance.Rows.Should().BeEmpty(); + hydrant.LastTestedOn.Should().BeNull(); + hydrant.LastMaintainedOn.Should().BeNull(); + } + + [Test] + public async Task Inspection_schedule_violation_and_list_refuse_the_date_without_drawing_an_inspection_number() + { + var occupancy = _h.SeedOccupancy(); + + Func schedule = () => _h.InspectionsService.ScheduleAsync(Dept, Admin, occupancy.RmsOccupancyId, null, TwoDigitYear, Admin); + await schedule.Should().ThrowAsync().WithMessage("The scheduled date is not valid."); + _h.Inspections.Rows.Should().BeEmpty(); + Func list = () => _h.InspectionsService.ListAsync(Dept, Admin, new RmsInspectionQuery { ScheduledBefore = TwoDigitYear }); + await list.Should().ThrowAsync().WithMessage("The scheduled-before date is not valid."); + Func count = () => _h.InspectionsService.CountAsync(Dept, Admin, new RmsInspectionQuery { ScheduledBefore = TwoDigitYear }); + await count.Should().ThrowAsync().WithMessage("The scheduled-before date is not valid."); + + var inspection = await _h.InspectionsService.ScheduleAsync(Dept, Admin, occupancy.RmsOccupancyId, null, DateTime.UtcNow, Admin); + inspection.InspectionNumber.Should().EndWith("-0001"); + Func violation = () => _h.InspectionsService.SaveViolationAsync(Dept, Admin, new RmsViolation { RmsInspectionId = inspection.RmsInspectionId, Description = "Blocked exit", DueOn = TwoDigitYear }); + await violation.Should().ThrowAsync().WithMessage("The due date is not valid."); + _h.Violations.Rows.Should().BeEmpty(); + } + + [Test] + public async Task Permit_update_issue_and_list_refuse_the_date() + { + var type = await _h.PermitsService.SaveTypeAsync(Dept, Admin, new RmsPermitType { Name = "Hot work", Code = "HW", DefaultValidityDays = 30, IsActive = true }); + var permit = await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = type.RmsPermitTypeId, ApplicantName = "Welder" }); + + Func update = () => _h.PermitsService.UpdateAsync(Dept, Admin, new RmsPermit { RmsPermitId = permit.RmsPermitId, ApplicantName = "Welder", ExpiresOn = TwoDigitYear }); + await update.Should().ThrowAsync().WithMessage("The expiry date is not valid."); + await _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Approved, null, null, null); + Func issue = () => _h.PermitsService.TransitionAsync(Dept, Admin, permit.RmsPermitId, RmsPermitState.Issued, null, TwoDigitYear, null); + await issue.Should().ThrowAsync().WithMessage("The effective date is not valid."); + Func list = () => _h.PermitsService.ListAsync(Dept, Admin, new RmsPermitQuery { ExpiresBefore = TwoDigitYear }); + await list.Should().ThrowAsync().WithMessage("The expires-before date is not valid."); + + var stored = await _h.PermitsService.GetAsync(Dept, Admin, permit.RmsPermitId); + stored.Permit.State.Should().Be((int)RmsPermitState.Approved, "the refused issue did not move the permit"); + } + + [Test] + public async Task Crr_activity_and_its_window_refuse_the_date() + { + Func save = () => _h.CrrService.SaveAsync(Dept, Admin, new RmsCrrActivity { Title = "School visit", OccurredOn = TwoDigitYear }); + await save.Should().ThrowAsync().WithMessage("The activity date is not valid."); + _h.Crr.Rows.Should().BeEmpty(); + + Func list = () => _h.CrrService.ListAsync(Dept, Admin, TwoDigitYear, DateTime.UtcNow, 10); + await list.Should().ThrowAsync().WithMessage("The start date is not valid."); + Func summary = () => _h.CrrService.GetSummaryAsync(Dept, Admin, DateTime.UtcNow.AddDays(-30), DateTime.MaxValue); + await summary.Should().ThrowAsync().WithMessage("The end date is not valid."); + } + + [Test] + public async Task Occupancy_next_review_date_is_refused_before_an_occupancy_number_is_drawn() + { + Func save = () => _h.OccupancyService.SaveAsync(Dept, Admin, new RmsOccupancy { Name = "Riverside Mill", NextReviewDue = TwoDigitYear }); + await save.Should().ThrowAsync().WithMessage("The next review date is not valid."); + _h.Occupancies.Rows.Should().BeEmpty(); + _h.Sequences.Rows.Should().BeEmpty(); + } + + [Test] + public async Task Quality_sampling_and_trends_refuse_the_date_before_looking_up_the_rubric() + { + Func sample = () => _h.QualityService.SampleAsync(Dept, Admin, "no-such-rubric", TwoDigitYear); + await sample.Should().ThrowAsync().WithMessage("The sample start date is not valid."); + Func trends = () => _h.QualityService.GetTrendsAsync(Dept, Admin, TwoDigitYear); + await trends.Should().ThrowAsync().WithMessage("The start date is not valid."); + } + } +} diff --git a/Tests/Resgrid.Tests/RootScopeResolutionTests.cs b/Tests/Resgrid.Tests/RootScopeResolutionTests.cs new file mode 100644 index 000000000..57efc7b28 --- /dev/null +++ b/Tests/Resgrid.Tests/RootScopeResolutionTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Resgrid.Tests +{ + /// + /// IUnitOfWork is InstancePerLifetimeScope, so anything resolved from the root container shares one unit of work, + /// and one DB connection, with everything else resolved there for the life of the process. When one of those + /// services opens a transaction, concurrent work lands on its connection ("The connection does not support + /// MultipleActiveResultSets") and inside its transaction. Bootstrapper.GetKernel().Resolve and + /// ServiceLocator.Current both resolve from the root. + /// + [TestFixture] + public sealed class RootScopeResolutionTests + { + // Process-lifetime singletons the worker hosts prime at startup so their event subscriptions exist. + private static readonly string[] AllowedWorkerRootResolutions = + { + "IEventAggregator", "IWorkflowEventProvider", "IOutboundEventProvider", "ICoreEventService" + }; + + /// + /// Workers resolve from a per-run child scope instead: + /// using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); scope.Resolve<T>(). + /// + [Test] + public void Worker_source_does_not_resolve_scoped_services_from_the_root_container() + { + var root = RepositoryRoot(); + if (root == null) + Assert.Ignore("Resgrid.sln not found above the test directory; the worker source is not available to scan."); + + var rootResolve = new Regex(@"GetKernel\(\)\s*\.\s*Resolve\s*[<(]"); + var allowed = new Regex(@"GetKernel\(\)\s*\.\s*Resolve<(" + string.Join("|", AllowedWorkerRootResolutions) + @")>\(\)"); + + var offenders = Offenders(root, new[] { "Workers" }, line => rootResolve.IsMatch(line) && !allowed.IsMatch(line)); + + Assert.That(offenders, Is.Empty, + "Resolve these from a per-run scope (Bootstrapper.GetKernel().BeginLifetimeScope()) instead of the root container"); + } + + /// + /// Scoped services take their dependencies through the constructor (Lazy<T> where the graph would otherwise + /// cycle); singletons inject ILifetimeScope and begin a child scope per operation. + /// + [Test] + public void Core_source_does_not_resolve_through_the_root_service_locator() + { + var root = RepositoryRoot(); + if (root == null) + Assert.Ignore("Resgrid.sln not found above the test directory; the source is not available to scan."); + + // Only ever resolves SqlConfiguration, the query classes' sole constructor parameter, which holds no + // connection or unit of work. + var allowedFiles = new[] { Path.Combine(root, "Repositories", "Resgrid.Repositories.DataRepository", "Queries", "QueryList.cs") }; + var locator = new Regex(@"ServiceLocator\s*\.\s*Current\s*\.\s*GetInstance\b|GetKernel\(\)\s*\.\s*Resolve\s*[<(]"); + + var offenders = Offenders(root, new[] { "Core", "Providers", "Repositories" }, locator.IsMatch, allowedFiles); + + Assert.That(offenders, Is.Empty, + "Inject these through the constructor (Lazy to break a cycle; ILifetimeScope with a child scope per operation in a singleton)"); + } + + private static List Offenders(string root, IEnumerable directories, System.Func isOffending, ICollection allowedFiles = null) + { + return directories + .SelectMany(directory => Directory.EnumerateFiles(Path.Combine(root, directory), "*.cs", SearchOption.AllDirectories)) + .Where(file => !IsBuildOutput(file) && (allowedFiles == null || !allowedFiles.Contains(file))) + .SelectMany(file => File.ReadLines(file) + .Select((line, index) => (line, index)) + .Where(x => !x.line.TrimStart().StartsWith("//") && isOffending(x.line)) + .Select(x => $"{Path.GetRelativePath(root, file)}:{x.index + 1}: {x.line.Trim()}")) + .ToList(); + } + + private static bool IsBuildOutput(string path) + { + var separator = Path.DirectorySeparatorChar; + return path.Contains($"{separator}obj{separator}") || path.Contains($"{separator}bin{separator}"); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + return directory?.FullName; + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SystemActionsServiceTests.cs b/Tests/Resgrid.Tests/Search/SystemActionsServiceTests.cs index 40b94137d..3c764d177 100644 --- a/Tests/Resgrid.Tests/Search/SystemActionsServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/SystemActionsServiceTests.cs @@ -16,10 +16,12 @@ namespace Resgrid.Tests.Search public class SystemActionsServiceTests { private Mock _flags; + private Mock _cutover; private SystemActionsService _service; private HashSet _enabledFlags; private HashSet _claims; private HashSet _disabledModules; + private bool _legacyWritesBlocked; [SetUp] public void SetUp() @@ -27,10 +29,13 @@ public void SetUp() _enabledFlags = new HashSet(); _claims = new HashSet(); _disabledModules = new HashSet(); + _legacyWritesBlocked = false; _flags = new Mock(); _flags.Setup(f => f.IsEnabledAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) .ReturnsAsync((string key, int dept, bool def, IDictionary ctx) => _enabledFlags.Contains(key)); - _service = new SystemActionsService(_flags.Object); + _cutover = new Mock(); + _cutover.Setup(c => c.AreLegacyWritesBlockedAsync(It.IsAny())).ReturnsAsync(() => _legacyWritesBlocked); + _service = new SystemActionsService(_flags.Object, _cutover.Object); } private SearchPrincipal Principal(bool admin = false) => new SearchPrincipal @@ -80,13 +85,38 @@ public async Task Feature_flag_and_module_gates_apply() } [Test] - public async Task Logs_disappear_when_records_is_on_and_admin_entries_need_admin() + public async Task Logs_stay_findable_through_the_records_cutover_and_only_new_log_goes_away() { _claims.Add("Log:View"); + _claims.Add("Log:Create"); (await _service.SearchAsync("logs", Principal())).Select(h => h.Key).Should().Contain("logs"); + (await _service.SearchAsync("new log", Principal())).Select(h => h.Key).Should().Contain("new-log"); + + // Records.System on but not yet activated: Logs is still the department's working log system. _enabledFlags.Add(FeatureFlagKeys.RecordsSystem); - (await _service.SearchAsync("logs", Principal())).Select(h => h.Key).Should().NotContain("logs"); + (await _service.SearchAsync("logs", Principal())).Select(h => h.Key).Should().Contain("logs"); + (await _service.SearchAsync("new log", Principal())).Select(h => h.Key).Should().Contain("new-log", "the flag alone does not make Logs read-only"); + + // Activated: old Logs stay readable, creating one is refused by the Logs pages, so it is not offered. + _legacyWritesBlocked = true; + (await _service.SearchAsync("logs", Principal())).Select(h => h.Key).Should().Contain("logs", "old Logs remain readable after activation"); + (await _service.SearchAsync("legacy logs", Principal())).Select(h => h.Key).Should().Contain("logs"); + (await _service.SearchAsync("new log", Principal())).Select(h => h.Key).Should().NotContain("new-log"); + } + + [Test] + public async Task An_unreadable_cutover_state_hides_the_legacy_write_but_not_the_read() + { + _claims.Add("Log:View"); + _claims.Add("Log:Create"); + _cutover.Setup(c => c.AreLegacyWritesBlockedAsync(It.IsAny())).ThrowsAsync(new System.InvalidOperationException("cache down")); + (await _service.SearchAsync("logs", Principal())).Select(h => h.Key).Should().Contain("logs"); + (await _service.SearchAsync("new log", Principal())).Select(h => h.Key).Should().NotContain("new-log"); + } + [Test] + public async Task Admin_entries_need_admin() + { (await _service.SearchAsync("department settings", Principal())).Should().BeEmpty(); (await _service.SearchAsync("department settings", Principal(admin: true))).First().Key.Should().Be("department-settings"); } diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs index 03b004e3e..415903ed9 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs @@ -229,8 +229,8 @@ public async Task Index_unavailable_degrades_and_activates_the_department_lazily _global.SetupGet(g => g.IsAvailable).Returns(false); _states.Setup(s => s.GetAsync(SearchIndexNames.Global, 7)).ReturnsAsync((SearchIndexState)null); SearchIndexState saved = null; - _states.Setup(s => s.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((SearchIndexState s, CancellationToken _, bool __) => saved = s).ReturnsAsync((SearchIndexState s, CancellationToken _, bool __) => s); + _states.Setup(s => s.InsertIfMissingAsync(It.IsAny(), It.IsAny())) + .Callback((SearchIndexState s, CancellationToken _) => saved = s).ReturnsAsync(true); var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "one" }, Principal("Call:View")); @@ -240,6 +240,21 @@ public async Task Index_unavailable_degrades_and_activates_the_department_lazily saved.Should().NotBeNull(); saved.State.Should().Be((int)SearchIndexBuildState.RebuildRequested); saved.IndexName.Should().Be(SearchIndexNames.Global); + saved.DepartmentId.Should().Be(7); + _states.Verify(s => s.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "an unconditional insert races concurrent first searches into the unique index"); + } + + [Test] + public async Task Index_unavailable_with_a_state_row_already_present_does_not_try_to_create_one() + { + _global.SetupGet(g => g.IsAvailable).Returns(false); + _states.Setup(s => s.GetAsync(SearchIndexNames.Global, 7)).ReturnsAsync(new SearchIndexState { IndexName = SearchIndexNames.Global, DepartmentId = 7 }); + + var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "one" }, Principal("Call:View")); + + result.Degraded.Should().BeTrue(); + _states.Verify(s => s.InsertIfMissingAsync(It.IsAny(), It.IsAny()), Times.Never); } [Test] public async Task A_records_only_page_past_the_first_twenty_still_returns_records() diff --git a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs index be1383351..7e801cc97 100644 --- a/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatPermissionServiceTests.cs @@ -131,12 +131,63 @@ protected static ChatChannelMember CreateUserMember(ChatChannel channel, string public class when_resolving_the_channel_access_version : with_the_chat_permission_service { [Test] - public async Task a_missing_cache_epoch_should_remain_null() + public async Task an_unavailable_cache_should_fail_closed() { + _cacheProviderMock.Setup(x => x.GetOrAddStringAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string)null); + var version = await _chatPermissionService.GetChannelAccessVersionAsync("channel-1"); version.Should().BeNull(); } + + [Test] + public async Task a_channel_without_an_epoch_should_get_one_minted() + { + // Most channels are never invalidated, so the epoch key is usually absent; treating that as + // an outage made JoinChannel throw and dropped every realtime fan-out for the channel. + _cacheProviderMock.Setup(x => x.GetOrAddStringAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string _, string valueIfAbsent, TimeSpan _) => valueIfAbsent); + + var version = await _chatPermissionService.GetChannelAccessVersionAsync("channel-1"); + + version.Should().NotBeNullOrWhiteSpace(); + _cacheProviderMock.Verify(x => x.GetOrAddStringAsync("chatpermver:channel-1", It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task an_existing_epoch_should_be_returned_unchanged() + { + _cacheProviderMock.Setup(x => x.GetOrAddStringAsync("chatpermver:channel-1", It.IsAny(), It.IsAny())) + .ReturnsAsync("existing-epoch"); + + var version = await _chatPermissionService.GetChannelAccessVersionAsync("channel-1"); + + version.Should().Be("existing-epoch"); + } + } + + [TestFixture] + public class when_invalidating_a_channel : with_the_chat_permission_service + { + [Test] + public async Task each_invalidation_should_rotate_to_a_never_used_epoch() + { + var written = new List(); + _cacheProviderMock.Setup(x => x.SetStringAsync("chatpermver:channel-1", It.IsAny(), It.IsAny())) + .Callback((string _, string value, TimeSpan _) => written.Add(value)) + .ReturnsAsync(true); + + await _chatPermissionService.InvalidateChannelCacheAsync("channel-1"); + await _chatPermissionService.InvalidateChannelCacheAsync("channel-1"); + + written.Should().HaveCount(2); + written.Should().OnlyContain(v => !string.IsNullOrWhiteSpace(v)); + written.Should().OnlyHaveUniqueItems(); + // A counter restarts at 1 once its key lapses, reviving an obsolete group a revoked + // connection may still sit in; epochs must never repeat. + _cacheProviderMock.Verify(x => x.IncrementAsync("chatpermver:channel-1", It.IsAny()), Times.Never); + } } [TestFixture] diff --git a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs index 8c9434452..2859c6d23 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs @@ -118,7 +118,7 @@ public async Task Queue_rejection_after_run_insert_retries_the_same_workflow_run runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRun r, CancellationToken ct, bool first) => stored = r); var attempts = new List(); queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync((WorkflowQueueItem item) => { attempts.Add(item); return attempts.Count > 1; }); - _ = new WorkflowEventProvider(_bus, queue.Object, workflows.Object, runs.Object, departments.Object, subscriptions.Object, _projection, _history.Lazy); + _ = new WorkflowEventProvider(_bus, queue.Object, WorkflowEventProviderScopeTests.Services(workflows.Object, runs.Object, departments.Object, subscriptions.Object, _projection, _history.Lazy)); var envelope = Event(); envelope.Trigger = (WorkflowTriggerEventType)trigger; envelope.EventName = envelope.Trigger.ToString(); var entry = await _outbox.EnqueueAsync(42, "Checklists", envelope); (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(0); stored.Should().NotBeNull(); diff --git a/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs b/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs index 8ceb53dc3..19f311c12 100644 --- a/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs @@ -1,8 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using Autofac; +using FluentAssertions; using Moq; using NUnit.Framework; +using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Bus; using Resgrid.Services; namespace Resgrid.Tests.Services @@ -19,7 +27,7 @@ public class CoreEventServiceTests public async Task IncidentCommandUpdatedAsync_RaisesIncidentCommandUpdatedEvent_WithDeptAndCall() { var eventAggregator = new Mock(); - var service = new CoreEventService(eventAggregator.Object); + var service = new CoreEventService(eventAggregator.Object, Mock.Of()); await service.IncidentCommandUpdatedAsync(42, 1001); @@ -27,5 +35,52 @@ public async Task IncidentCommandUpdatedAsync_RaisesIncidentCommandUpdatedEvent_ It.Is(e => e.DepartmentId == 42 && e.CallId == 1001)), Times.Once); } + + /// + /// The service is a singleton. The timestamp save runs inside an audited configuration transaction, so each + /// event must get its own settings service (and unit of work) from a child scope, never one shared root instance. + /// + [Test] + public void DepartmentSettingsUpdateEvent_SavesTheTimestamp_InItsOwnScopePerEvent() + { + var created = new List>(); + var builder = new ContainerBuilder(); + builder.Register(_ => + { + var settings = new Mock(); + settings.Setup(s => s.SaveOrUpdateSettingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DepartmentSetting()); + created.Add(settings); + return settings.Object; + }).As().InstancePerLifetimeScope(); + using var container = builder.Build(); + + var bus = new EventAggregator(); + _ = new CoreEventService(bus, container); + + bus.SendMessage(new DepartmentSettingsUpdateEvent { DepartmentId = 42 }); + bus.SendMessage(new DepartmentSettingsUpdateEvent { DepartmentId = 43 }); + + created.Should().HaveCount(2); + created[0].Verify(s => s.SaveOrUpdateSettingAsync(42, It.IsAny(), DepartmentSettingTypes.UpdateTimestamp, It.IsAny()), Times.Once); + created[1].Verify(s => s.SaveOrUpdateSettingAsync(43, It.IsAny(), DepartmentSettingTypes.UpdateTimestamp, It.IsAny()), Times.Once); + } + + [Test] + public void DepartmentSettingsUpdateEvent_FailedSave_DoesNotReachThePublisher() + { + var settings = new Mock(); + settings.Setup(s => s.SaveOrUpdateSettingAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("settings store unavailable")); + var builder = new ContainerBuilder(); + builder.RegisterInstance(settings.Object).As(); + using var container = builder.Build(); + + var bus = new EventAggregator(); + _ = new CoreEventService(bus, container); + + bus.Invoking(b => b.SendMessage(new DepartmentSettingsUpdateEvent { DepartmentId = 42 })).Should().NotThrow(); + settings.Verify(s => s.SaveOrUpdateSettingAsync(42, It.IsAny(), DepartmentSettingTypes.UpdateTimestamp, It.IsAny()), Times.Once); + } } } diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs index ff5dea56c..cfd754810 100644 --- a/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs @@ -259,7 +259,7 @@ public async Task Queue_rejection_retries_existing_inventory_workflow_run_with_s runs.Setup(s => s.GetByWorkflowsAndEventAsync(42, It.IsAny>(), It.IsAny())).ReturnsAsync(() => stored == null ? new List() : new List { stored }); runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRun run, CancellationToken c, bool f) => stored = run); var attempts = new List(); queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync((WorkflowQueueItem q) => { attempts.Add(q); return attempts.Count > 1; }); - _ = new WorkflowEventProvider(_bus, queue.Object, workflows.Object, runs.Object, Mock.Of(), subscriptions.Object, _projection, _history.Lazy); + _ = new WorkflowEventProvider(_bus, queue.Object, WorkflowEventProviderScopeTests.Services(workflows.Object, runs.Object, Mock.Of(), subscriptions.Object, _projection, _history.Lazy)); var entry = await _outbox.EnqueueAsync(42, "Inventory", Event(trigger)); (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(0); stored.Should().NotBeNull(); entry.DispatchedOn.Should().BeNull(); _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); diff --git a/Tests/Resgrid.Tests/Services/LazyChatDependencyCompositionTests.cs b/Tests/Resgrid.Tests/Services/LazyChatDependencyCompositionTests.cs new file mode 100644 index 000000000..f41f7dceb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/LazyChatDependencyCompositionTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Autofac; +using Autofac.Builder; +using Autofac.Core; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// IncidentCommandService and PermissionsService used to reach the chat and command-access services through + /// ServiceLocator.Current, which resolves from the ROOT scope and so shared the root unit of work with everything + /// else resolved there. They now take Lazy<T> constructor parameters, because the dependencies run both ways: + /// ChatChannelService and ChatPermissionService take IIncidentCommandService, and CommandAccessService takes + /// IPermissionsService. These tests compose the real classes, with every other dependency a loose mock, to prove + /// the cycles still resolve and that each Lazy resolves in the caller's own scope. + /// + [TestFixture] + public sealed class LazyChatDependencyCompositionTests + { + private IContainer _container; + + [SetUp] + public void SetUp() + { + var builder = new ContainerBuilder(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterSource(new LooseMockSource()); + _container = builder.Build(); + } + + [TearDown] + public void TearDown() => _container.Dispose(); + + [Test] + public void Incident_command_chat_dependencies_resolve_through_the_cycle_in_the_callers_scope() + { + using var first = _container.BeginLifetimeScope(); + using var second = _container.BeginLifetimeScope(); + + // Entering from the chat side constructs IncidentCommandService inside ChatChannelService's resolve. + var chat = first.Resolve(); + var incident = first.Resolve(); + + LazyValue(incident, "_chatChannelService").Should().BeSameAs(chat); + LazyValue(incident, "_commandAccessService").Should().BeSameAs(first.Resolve()); + LazyValue(incident, "_chatChannelRepository").Should().BeSameAs(first.Resolve()); + + LazyValue(second.Resolve(), "_chatChannelService") + .Should().BeSameAs(second.Resolve()).And.NotBeSameAs(chat); + } + + [Test] + public void Permissions_chat_refresh_dependencies_resolve_through_the_cycle_in_the_callers_scope() + { + using var scope = _container.BeginLifetimeScope(); + + var permissions = scope.Resolve(); + + LazyValue(permissions, "_chatPermissionService").Should().BeSameAs(scope.Resolve()); + LazyValue(permissions, "_chatChannelRepository").Should().BeSameAs(scope.Resolve()); + } + + [Test] + public void Chat_message_service_can_reach_the_root_for_its_background_push_fan_out() + { + using var scope = _container.BeginLifetimeScope(); + + var lifetimeScope = typeof(ChatMessageService).GetField("_lifetimeScope", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(scope.Resolve()); + + lifetimeScope.Should().BeSameAs(scope); + ((ISharingLifetimeScope)lifetimeScope).RootLifetimeScope.Should().BeSameAs(((ISharingLifetimeScope)scope).RootLifetimeScope); + } + + private static T LazyValue(object service, string field) => + ((Lazy)service.GetType().GetField(field, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(service)).Value; + + /// A loose Moq mock, one per scope, for any interface nothing else registers. + private sealed class LooseMockSource : IRegistrationSource + { + private static readonly Type[] CollectionTypes = + { typeof(IEnumerable<>), typeof(ICollection<>), typeof(IList<>), typeof(IReadOnlyCollection<>), typeof(IReadOnlyList<>) }; + + public bool IsAdapterForIndividualComponents => false; + + public IEnumerable RegistrationsFor(Service service, Func> registrationAccessor) + { + if (service is not IServiceWithType typed || !typed.ServiceType.IsInterface || registrationAccessor(service).Any()) + return Enumerable.Empty(); + + var type = typed.ServiceType; + if (type.IsGenericType && CollectionTypes.Contains(type.GetGenericTypeDefinition())) + return Enumerable.Empty(); + + return new[] + { + RegistrationBuilder.ForDelegate(type, (c, p) => ((Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(type))).Object) + .As(service).InstancePerLifetimeScope().CreateRegistration() + }; + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkflowEventProviderScopeTests.cs b/Tests/Resgrid.Tests/Services/WorkflowEventProviderScopeTests.cs new file mode 100644 index 000000000..a12a6749e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkflowEventProviderScopeTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Bus; + +namespace Resgrid.Tests.Services +{ + /// + /// WorkflowEventProvider is a singleton, so its scoped dependencies must come from a child scope per event. + /// Captured at construction they lived in the root scope, where every concurrent event shared one unit of work: + /// a Records run insert opened a transaction on it and the other handlers' queries collided on its connection + /// ("The connection does not support MultipleActiveResultSets"). + /// + [TestFixture, NonParallelizable] + public sealed class WorkflowEventProviderScopeTests + { + /// A container carrying the provider's scoped dependencies, for tests that construct it directly. + internal static IContainer Services(IWorkflowRepository workflows, IWorkflowRunRepository runs, IDepartmentsService departments, + ISubscriptionsService subscriptions, IProtectedProjectionService projection, Lazy history = null) + { + var builder = new ContainerBuilder(); + builder.RegisterInstance(workflows).As(); + builder.RegisterInstance(runs).As(); + builder.RegisterInstance(departments).As(); + builder.RegisterInstance(subscriptions).As(); + builder.RegisterInstance(projection).As(); + if (history != null) + builder.Register(_ => history.Value).As(); + return builder.Build(); + } + + [Test] + public async Task Concurrent_events_each_get_their_own_run_repository_scope() + { + // A department no other test touches, so the static per-minute rate limiter starts empty. + var departmentId = 900000 + new Random().Next(99999); + var trigger = WorkflowTriggerEventType.RecordCreated; + + var workflows = new Mock(); + workflows.Setup(s => s.GetAllActiveByDepartmentAndEventTypeAsync(departmentId, (int)trigger)) + .ReturnsAsync(new[] { new Workflow { WorkflowId = Guid.NewGuid().ToString(), DepartmentId = departmentId, TriggerEventType = (int)trigger } }); + var subscriptions = new Mock(); + subscriptions.Setup(s => s.GetCurrentPlanForDepartmentAsync(departmentId, It.IsAny())).ReturnsAsync(new Plan { PlanId = 999999 }); + var projection = new Mock(); + projection.Setup(s => s.BuildSafeWorkflowPayloadAsync(departmentId, It.IsAny())).ReturnsAsync("{}"); + var queue = new Mock(); + queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync(true); + + var created = new List>(); + var builder = new ContainerBuilder(); + builder.RegisterInstance(workflows.Object).As(); + builder.RegisterInstance(Mock.Of()).As(); + builder.RegisterInstance(subscriptions.Object).As(); + builder.RegisterInstance(projection.Object).As(); + builder.Register(_ => + { + var runs = new Mock(); + runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((WorkflowRun run, CancellationToken ct, bool first) => run); + lock (created) created.Add(runs); + return runs.Object; + }).As().InstancePerLifetimeScope(); + using var container = builder.Build(); + + var bus = new EventAggregator(); + _ = new WorkflowEventProvider(bus, queue.Object, container); + + await Task.WhenAll(bus.SendMessageAsync(Dispatched(departmentId, trigger)), bus.SendMessageAsync(Dispatched(departmentId, trigger))); + + created.Should().HaveCount(2, "each event resolves its repositories (and unit of work) in its own child scope"); + created.Should().OnlyContain(runs => runs.Invocations.Count(i => i.Method.Name == nameof(IWorkflowRunRepository.InsertAsync)) == 1); + queue.Verify(s => s.EnqueueWorkflow(It.IsAny()), Times.Exactly(2)); + } + + private static DomainEventDispatchedEvent Dispatched(int departmentId, WorkflowTriggerEventType trigger) => new DomainEventDispatchedEvent + { + DepartmentId = departmentId, + EventId = Guid.NewGuid().ToString(), + ProducerSubsystem = DomainEventProducers.Records, + EventName = trigger.ToString(), + SchemaVersion = 1, + AggregateType = "OperationalRecord", + AggregateId = Guid.NewGuid().ToString(), + TriggerEventType = (int)trigger, + PayloadJson = "{}", + OccurredOn = DateTime.UtcNow + }; + } +} diff --git a/Tests/Resgrid.Tests/Web/User/LegacyLogsNavigationRenderingTests.cs b/Tests/Resgrid.Tests/Web/User/LegacyLogsNavigationRenderingTests.cs new file mode 100644 index 000000000..df41871b0 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/LegacyLogsNavigationRenderingTests.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using File = System.IO.File; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web.User +{ + [Area("User")] + public class LegacyLogsNavigationRenderingController : Controller + { + public IActionResult Sidebar() => PartialView("/Areas/User/Views/Shared/_Navigation.cshtml"); + } + + /// + /// The production sidebar across the Records cutover. The Records.System flag only shows the Records group; the + /// department's cutover decides where Logs lives. Before activation Logs is still the working log system and + /// keeps its own link; after activation the old Logs stay reachable from inside the Records group. + /// + [TestFixture, NonParallelizable] + public class LegacyLogsNavigationRenderingTests + { + private const string TopLevelLogs = "data-i18n=\"nav.logs\""; + + [Test] + public async Task Logs_follow_the_cutover_not_the_records_flag() + { + var state = new RecordsModuleState { DepartmentId = 77 }; + var cutover = new Mock(); + cutover.Setup(c => c.GetModuleStateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => state); + var settings = new Mock(); + settings.Setup(s => s.GetDepartmentModuleSettingsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new DepartmentModuleSettings()); + + var root = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !File.Exists(Path.Combine(root.FullName, "Resgrid.sln"))) root = root.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(root!.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); builder.Services.AddAdminAssistFieldHelpStubs(); + builder.Services.AddWebOptimizer(); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + // The flag service agrees with the module state, as it does in production (the state reads the flag). + var flags = new Mock(); + flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.RecordsSystem, It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(() => state.FlagEnabled); + builder.Services.AddSingleton(cutover.Object); + builder.Services.AddSingleton(flags.Object); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddControllersWithViews() + .AddApplicationPart(typeof(Resgrid.Web.Areas.User.Controllers.LogsController).Assembly) + .AddApplicationPart(typeof(LegacyLogsNavigationRenderingController).Assembly); + await using var app = builder.Build(); + + // SettingsHelper resolves the module settings through the service locator and caches the instance statically. + var settingsField = typeof(SettingsHelper).GetField("_departmentSettingsService", BindingFlags.NonPublic | BindingFlags.Static); + var previousSettings = settingsField!.GetValue(null); + settingsField.SetValue(null, settings.Object); + var previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.Use(async (context, next) => { + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, "member"), new Claim(ClaimTypes.PrimaryGroupSid, "77"), + new Claim(ResgridClaimTypes.Resources.Log, ResgridClaimTypes.Actions.View), new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) }, "Test")); + try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } + }); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("en").AddSupportedCultures("en").AddSupportedUICultures("en")); + app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); + using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; + async Task Render() + { + var response = await client.GetAsync("/User/LegacyLogsNavigationRendering/Sidebar"); + var html = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, html); + return html; + } + + // Records not offered: the plain Logs link, as always. + var html = await Render(); + html.Should().Contain(TopLevelLogs).And.Contain("href=\"/User/Logs\"").And.NotContain("id=\"records-menu\""); + + // Flag on, not yet activated: Records shows up (an administrator activates from there), but Logs is + // still the department's working log system and must keep its link. + state = new RecordsModuleState { DepartmentId = 77, FlagEnabled = true }; + html = await Render(); + html.Should().Contain("id=\"records-menu\""); + html.Should().Contain(TopLevelLogs, "a department that has not activated Records still uses Logs"); + RecordsMenu(html).Should().NotContain("href=\"/User/Logs\""); + + // A clean revert puts the department back on Logs the same way. + state = new RecordsModuleState { DepartmentId = 77, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Reverted }; + (await Render()).Should().Contain(TopLevelLogs); + + // Activated: Records replaces the Logs entry, and the old Logs are reachable from inside the Records group. + state = new RecordsModuleState { DepartmentId = 77, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }; + html = await Render(); + html.Should().NotContain(TopLevelLogs); + RecordsMenu(html).Should().Contain("href=\"/User/Logs\"").And.Contain("Existing logs"); + + // Activated and then the flag turned off: no Records group, so the (read-only) Logs link comes back. + state = new RecordsModuleState { DepartmentId = 77, FlagEnabled = false, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }; + html = await Render(); + html.Should().Contain(TopLevelLogs).And.NotContain("id=\"records-menu\""); + } + finally + { + settingsField.SetValue(null, previousSettings); + ClaimsAuthorizationHelper._httpContextAccessor = previousAccessor; + await app.StopAsync(); + } + } + + private static string RecordsMenu(string html) + { + var start = html.IndexOf("id=\"records-menu\"", StringComparison.Ordinal); + start.Should().BeGreaterThan(0, "the Records group should render"); + var end = html.IndexOf("", start, StringComparison.Ordinal); + return html.Substring(start, end - start); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/RecordsDateInputTests.cs b/Tests/Resgrid.Tests/Web/User/RecordsDateInputTests.cs new file mode 100644 index 000000000..bc1f68c0e --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/RecordsDateInputTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; +using RecordsStrings = Resgrid.Localization.Areas.User.Records.Records; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Browsers accept any year in a date input, so a two-digit year posts as 0026. That reached the investigation-note insert + /// and failed with a SqlDateTime overflow (Sentry RESGRID-WEB-1MY); recorded dates are now refused and filter dates ignored. + /// + [TestFixture, NonParallelizable] + public class RecordsDateInputTests + { + private IHttpContextAccessor _previous; + private DefaultHttpContext _http; + private Mock _cutover; + private Mock _toggles; + private Mock> _localizer; + private Mock _investigations; + private RecordInvestigationsController _controller; + + private sealed class Probe : RecordsPreventionMvcControllerBase + { + public Probe(IRecordsCutoverService cutover, IFeatureToggleService toggles, IStringLocalizer localizer) : base(cutover, toggles, localizer) { } + public DateTime? Filter(string value) => ParseUtc(value); + public DateTime? Entered(string value) => ParseEnteredUtc(value); + } + + [SetUp] + public void SetUp() + { + _previous = ClaimsAuthorizationHelper._httpContextAccessor; + _http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { + new Claim(ClaimTypes.PrimarySid, "investigator"), new Claim(ClaimTypes.PrimaryGroupSid, "77") }, "Test")) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + _cutover = new Mock(); + _cutover.Setup(c => c.GetModuleStateAsync(77, false)).ReturnsAsync(new RecordsModuleState { FlagEnabled = true }); + _toggles = new Mock(); + _toggles.Setup(t => t.IsEnabledAsync(It.IsAny(), 77, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + _localizer = new Mock>(); + _localizer.Setup(l => l[It.IsAny()]).Returns((string key) => new LocalizedString(key, key)); + _investigations = new Mock(); + _controller = new RecordInvestigationsController(_investigations.Object, Mock.Of(), Mock.Of(), + _cutover.Object, _toggles.Object, _localizer.Object, Mock.Of(), Mock.Of(), Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = _http }, + TempData = new TempDataDictionary(_http, Mock.Of()) + }; + _controller.ViewData[nameof(DepartmentTime)] = new DepartmentTime(new Department { TimeZone = "America/Los_Angeles" }); + } + + [TearDown] public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previous; + + private Probe NewProbe(string timeZone) + { + var probe = new Probe(_cutover.Object, _toggles.Object, _localizer.Object); + probe.ViewData[nameof(DepartmentTime)] = new DepartmentTime(new Department { TimeZone = timeZone }); + return probe; + } + + [Test] + public async Task Note_with_a_two_digit_year_is_refused_with_a_message_instead_of_reaching_the_insert() + { + var result = await _controller.SaveNote("case", null, 1, "0026-09-25T19:00", "Interview", "Body", default); + + result.Should().BeOfType().Which.ActionName.Should().Be(nameof(RecordInvestigationsController.Details)); + _controller.TempData["RecordsError"].Should().Be("InvalidDate"); + _investigations.Verify(i => i.AddNoteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Note_with_a_valid_local_time_is_saved_as_department_utc() + { + var result = await _controller.SaveNote("case", null, 1, "2026-09-25T19:00", "Interview", "Body", default); + + result.Should().BeOfType(); + _controller.TempData["RecordsError"].Should().BeNull(); + _investigations.Verify(i => i.AddNoteAsync(77, "investigator", "case", (RmsInvestigationNoteKind)1, + new DateTime(2026, 9, 26, 2, 0, 0, DateTimeKind.Utc), "Interview", "Body", It.IsAny()), Times.Once); + } + + [Test] + public async Task Evidence_with_a_two_digit_year_is_refused() + { + await _controller.AddEvidence("case", 1, "Photo", "0026-09-25T19:00", null, null, default); + + _controller.TempData["RecordsError"].Should().Be("InvalidDate"); + _investigations.Verify(i => i.AddEvidenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase("0026-09-25")] + [TestCase("1752-12-31T23:00")] + [TestCase("9999-12-31T23:00")] + [TestCase("not a date")] + public void Unstorable_or_unreadable_dates_are_null_for_filters_and_refused_when_entered(string value) + { + var probe = NewProbe("America/Los_Angeles"); + + probe.Filter(value).Should().BeNull(); + probe.Invoking(p => p.Entered(value)).Should().Throw().WithMessage("InvalidDate"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Blank_dates_are_null_so_the_callers_default_applies(string value) + { + var probe = NewProbe("America/Los_Angeles"); + + probe.Filter(value).Should().BeNull(); + probe.Entered(value).Should().BeNull(); + } + + [Test] + public void Year_one_in_a_zone_ahead_of_utc_is_rejected_rather_than_overflowing_the_zone_conversion() + { + var probe = NewProbe("Pacific/Kiritimati"); + + probe.Filter("0001-01-01T00:00").Should().BeNull(); + probe.Filter("2026-01-01T00:00").Should().Be(new DateTime(2025, 12, 31, 10, 0, 0, DateTimeKind.Utc)); + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs index b0c6917e4..d1c558dd2 100644 --- a/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs +++ b/Web/Resgrid.Web.Eventing/Hubs/ChatHub.cs @@ -195,10 +195,13 @@ public async Task JoinChannel(string channelId, int? asUnitId = null) public async Task LeaveChannel(string channelId) { + // JoinChannel is the only way into a channel group and always records the group it used, so an + // untracked channel has nothing to leave. Don't resolve the current epoch here: that would mint + // one for any channelId a client sends, before any access check. var contextKey = GetJoinedChannelGroupContextKey(channelId); var groupName = Context.Items.TryGetValue(contextKey, out var trackedGroupName) ? trackedGroupName as string - : await GetCurrentChannelGroupNameAsync(channelId); + : null; if (groupName != null) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index bbdbcdbd5..a6663e648 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -381,6 +381,8 @@ public async Task GetLogsList(string year) var personnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); var unitLookup = units.ToDictionary(u => u.UnitId, u => u.Name); + // After Records activation the list offers View only; DeleteWorkLog refuses anyway (RMS plan section 4.1). + var legacyReadOnly = await _recordsCutoverService.AreLegacyWritesBlockedAsync(DepartmentId); List logs; if (String.IsNullOrWhiteSpace(year)) @@ -482,7 +484,7 @@ public async Task GetLogsList(string year) logJson.SearchTerms = String.Join(" ", terms); - if (ClaimsAuthorizationHelper.CanDeleteLog() && + if (!legacyReadOnly && ClaimsAuthorizationHelper.CanDeleteLog() && (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || log.LoggedByUserId == UserId || (log.StationGroupId.HasValue && ClaimsAuthorizationHelper.IsUserGroupAdmin(log.StationGroupId.Value)))) logJson.CanDelete = true; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs index 88c8d624e..84590284c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordHydrantsController.cs @@ -125,7 +125,7 @@ public async Task FlowTest(string id, RmsHydrantFlowTest test, st try { test.RmsHydrantId = id; - test.TestedOn = ParseUtc(testedOn) ?? DateTime.UtcNow; + test.TestedOn = ParseEnteredUtc(testedOn) ?? DateTime.UtcNow; await _hydrants.RecordFlowTestAsync(DepartmentId, UserId, test, cancellationToken); Notify("FlowTestRecorded"); } @@ -141,7 +141,7 @@ public async Task Maintenance(string id, RmsHydrantMaintenance ro try { row.RmsHydrantId = id; - row.PerformedOn = ParseUtc(performedOn) ?? DateTime.UtcNow; + row.PerformedOn = ParseEnteredUtc(performedOn) ?? DateTime.UtcNow; await _hydrants.RecordMaintenanceAsync(DepartmentId, UserId, row, cancellationToken); Notify("MaintenanceRecorded"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs index 72172712f..ef1c8edc4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs @@ -71,7 +71,7 @@ public async Task Schedule(string occupancyId, string programId, if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var when = ParseUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(7)); + var when = ParseEnteredUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(7)); var inspection = await _inspections.ScheduleAsync(DepartmentId, UserId, occupancyId, programId, when, string.IsNullOrWhiteSpace(inspectorUserId) ? UserId : inspectorUserId, cancellationToken); Notify("InspectionScheduled"); return RedirectToAction(nameof(Details), new { id = inspection.RmsInspectionId }); @@ -119,7 +119,7 @@ public async Task Reinspect(string id, string scheduledOn, Cancel if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var next = await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, ParseUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(30)), cancellationToken); + var next = await _inspections.ScheduleReinspectionAsync(DepartmentId, UserId, id, ParseEnteredUtc(scheduledOn) ?? Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Today.AddDays(30)), cancellationToken); Notify("InspectionScheduled"); return RedirectToAction(nameof(Details), new { id = next.RmsInspectionId }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs index 0523ac7ff..38dff4d32 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs @@ -173,7 +173,7 @@ public async Task SaveNote(string id, string noteId, int kind, st if (!await ModuleOnAsync(Flag)) return NotFound(); try { - if (string.IsNullOrWhiteSpace(noteId)) await _investigations.AddNoteAsync(DepartmentId, UserId, id, (RmsInvestigationNoteKind)kind, ParseUtc(occurredOn) ?? DateTime.UtcNow, subject, body, cancellationToken); + if (string.IsNullOrWhiteSpace(noteId)) await _investigations.AddNoteAsync(DepartmentId, UserId, id, (RmsInvestigationNoteKind)kind, ParseEnteredUtc(occurredOn) ?? DateTime.UtcNow, subject, body, cancellationToken); else await _investigations.UpdateNoteAsync(DepartmentId, UserId, noteId, subject, body, cancellationToken); Notify("NoteSaved"); } @@ -187,7 +187,7 @@ public async Task AddEvidence(string id, int kind, string descrip if (!await ModuleOnAsync(Flag)) return NotFound(); try { - await _investigations.AddEvidenceAsync(DepartmentId, UserId, id, new RmsInvestigationEvidence { Kind = kind, Description = description, CollectedOn = ParseUtc(collectedOn) ?? DateTime.UtcNow, CollectedByUserId = UserId, CollectedFrom = collectedFrom, StorageLocation = storageLocation }, cancellationToken); + await _investigations.AddEvidenceAsync(DepartmentId, UserId, id, new RmsInvestigationEvidence { Kind = kind, Description = description, CollectedOn = ParseEnteredUtc(collectedOn) ?? DateTime.UtcNow, CollectedByUserId = UserId, CollectedFrom = collectedFrom, StorageLocation = storageLocation }, cancellationToken); Notify("EvidenceAdded"); } catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs index eed0b2577..bdfa8230b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs @@ -244,7 +244,7 @@ public async Task Edit(RecordCrrEditView model, string occurredOn if (!await ModuleOnAsync(Flag)) return NotFound(); try { - model.Activity.OccurredOn = ParseUtc(occurredOn) ?? model.Activity.OccurredOn; + model.Activity.OccurredOn = ParseEnteredUtc(occurredOn) ?? model.Activity.OccurredOn; await _crr.SaveAsync(DepartmentId, UserId, model.Activity, cancellationToken); Notify("CrrSaved"); return RedirectToAction(nameof(Index)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs index b590bb0e8..cc485a59f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsPreventionMvcControllerBase.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Data.SqlTypes; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -79,10 +81,25 @@ protected static Dictionary NameMap(IEnumerable items, Fun return map; } + // SQL Server datetime starts at 1753, and browsers accept any year in a date input, so a two-digit year arrives as 0026 and + // failed the insert with a SqlDateTime overflow. The day of margin keeps the department-zone shift inside SQL's and DateTime's range. + private static readonly DateTime EarliestInput = ((DateTime)SqlDateTime.MinValue).AddDays(1); + private static readonly DateTime LatestInput = ((DateTime)SqlDateTime.MaxValue).AddDays(-1); + + /// Filter dates: blank, unreadable or unstorable input is null, so the caller's default window applies. protected DateTime? ParseUtc(string value) + { + if (string.IsNullOrWhiteSpace(value) || !DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) return null; + if (parsed < EarliestInput || parsed > LatestInput) return null; + return Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ToUtc(parsed); + } + + /// Dates the user is recording: blank is null so the caller's default applies, but a value that is not a storable date + /// is an ArgumentException (shown by ) rather than silently replaced with the default. + protected DateTime? ParseEnteredUtc(string value) { if (string.IsNullOrWhiteSpace(value)) return null; - return Resgrid.Web.Helpers.DepartmentTime.From(ViewData).Parse(value); + return ParseUtc(value) ?? throw new ArgumentException(Localizer["InvalidDate"].Value); } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs index 697b60fa3..23b5507c7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs @@ -90,7 +90,7 @@ public async Task Rubric(RecordsQualityRubricView model, Cancella public async Task Sample(string rubricId, string since, CancellationToken cancellationToken) { if (!await ModuleOnAsync(Flag)) return NotFound(); - try { var reviews = await _quality.SampleAsync(DepartmentId, UserId, rubricId, ParseUtc(since) ?? DateTime.UtcNow.AddDays(-30), cancellationToken); Notify("SampleCreated", reviews.Count); } + try { var reviews = await _quality.SampleAsync(DepartmentId, UserId, rubricId, ParseEnteredUtc(since) ?? DateTime.UtcNow.AddDays(-30), cancellationToken); Notify("SampleCreated", reviews.Count); } catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } return RedirectToAction(nameof(Index)); } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml index 6c0f2af05..14b7a4dc9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml @@ -129,7 +129,7 @@ {
@Html.AntiForgeryToken() -
+
@@ -152,7 +152,7 @@ {
@Html.AntiForgeryToken() -
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index 6f8ff4d85..86dc1dc45 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -3,6 +3,7 @@ @inject IStringLocalizer workOrderLocalizer @inject IStringLocalizer checklistLocalizer @inject Resgrid.Model.Services.IRecordsAuthorizationService recordsAuthorization +@inject Resgrid.Model.Services.IRecordsCutoverService recordsCutover @inject IStringLocalizer recordsLocalizer @inject IStringLocalizer invoicingLocalizer @inject IStringLocalizer certificationLocalizer @@ -15,9 +16,13 @@ // Chat.System flag gates every chat surface (chat, assistant, moderation). When it is off // the nav items are hidden entirely; the API 404s the endpoints regardless. var chatSystemEnabled = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, ClaimsAuthorizationHelper.GetDepartmentId()); - // Records.System flag: Records takes the Logs sidebar position and the Logs link is absent. The - // existing LogsDisabled module setting still controls whether that position is visible at all. - var recordsEnabled = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsSystem, ClaimsAuthorizationHelper.GetDepartmentId()); + // Records.System flag shows the Records group; the department's cutover, not the flag, decides where Logs + // goes. Until an administrator activates Records, Logs is still the working log system and keeps its own + // sidebar link. After activation the old Logs stay readable (never writable) from inside the Records group. + // The existing LogsDisabled module setting still controls whether either is visible at all. + var recordsState = await recordsCutover.GetModuleStateAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var recordsEnabled = recordsState.FlagEnabled; + var recordsUsable = recordsState.RecordsUsable; // Keep legacy modules flat; only new feature areas get collapsible groups. var showCertifications = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewCertifications(); var showInvoicing = ClaimsAuthorizationHelper.CanViewInvoicing() && SettingsHelper.IsBusinessOperationsEnabled() && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.CustomerInvoicing, ClaimsAuthorizationHelper.GetDepartmentId()); @@ -126,7 +131,7 @@ "RecordDeployments", "RecordDocuments", "RecordEvidence", "RecordHydrants", "RecordInspections", "RecordInvestigations", "RecordLegalHolds", "RecordOccupancies", "RecordPermits", "RecordSavedReports", "RecordSubmissions", "RecordsAnalytics", "RecordsExportTemplates", "RecordsHealth", "RecordsInventory", - "RecordsQuality"); + "RecordsQuality") || (recordsUsable && Html.IsController("Logs")); // The dashboard and the queue share the Records controller, so they are told apart by // action. Drilling into a record (Details, Edit, New) keeps the queue highlighted. @@ -184,10 +189,15 @@ {
  • @recordsLocalizer["AnalyticsHeader"]
  • } + @if (recordsUsable) + { + @* Logs created before activation: read-only, same Log_View gate as the pages themselves (LogsDeepLinkTests). *@ +
  • @recordsLocalizer["LegacyLogs"]
  • + } } - else if (SettingsHelper.IsLogsEnabled()) + @if (SettingsHelper.IsLogsEnabled() && !recordsUsable) {
  • @commonLocalizer["LogsModule"] diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 7592bd535..7f75cd70f 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -727,7 +727,8 @@ private static async Task UpdateOidcDatabaseAsync(ILogger logger, IServ try { - var oidcRepository = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var oidcRepository = scope.Resolve(); bool result = await oidcRepository.UpdateOidcDatabaseAsync(); if (result) @@ -754,7 +755,8 @@ private static async Task UpdateDocumentDatabaseAsync(ILogger logger, I try { - var documentDbRepository = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var documentDbRepository = scope.Resolve(); bool result = await documentDbRepository.UpdateDocumentDatabaseAsync(); if (result) diff --git a/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs index fc74f3e40..f566f84a6 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs @@ -1,10 +1,12 @@ using System; using System.Threading; using System.Threading.Tasks; +using Autofac; using Microsoft.Extensions.Logging; using Quidjibo.Handlers; using Quidjibo.Misc; using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; using Resgrid.Workers.Framework.Logic; namespace Resgrid.Workers.Console.Tasks @@ -34,7 +36,8 @@ public async Task ProcessAsync(AdpMigrationCommand command, IQuidjiboProgress pr try { - var logic = new AdpMigrationLogic(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var logic = new AdpMigrationLogic(scope); var result = await logic.Process(cancellationToken); if (!result.Item1) diff --git a/Workers/Resgrid.Workers.Console/Tasks/CalendarNotificationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CalendarNotificationTask.cs index d0066dccf..a145249ee 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CalendarNotificationTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CalendarNotificationTask.cs @@ -32,7 +32,8 @@ public async Task ProcessAsync(CalendarNotificationCommand command, IQuidjiboPro //await Task.Run(async () => //{ - var _calendarService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _calendarService = scope.Resolve(); var logic = new CalendarNotifierLogic(); var calendarItems = await _calendarService.GetCalendarItemsToNotifyAsync(DateTime.UtcNow); diff --git a/Workers/Resgrid.Workers.Console/Tasks/CallEmailImportTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CallEmailImportTask.cs index 62029d987..dd07282b9 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CallEmailImportTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CallEmailImportTask.cs @@ -33,7 +33,8 @@ public async Task ProcessAsync(CallEmailImportCommand command, IQuidjiboProgress { //await Task.Run(async () => //{ - var _departmentsService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _departmentsService = scope.Resolve(); var logic = new CallEmailImporterLogic(); //var items = await _departmentsService.GetAllDepartmentEmailSettingsAsync(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs index e9608fff3..32d5472d0 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CallPruneTask.cs @@ -31,7 +31,8 @@ public async Task ProcessAsync(CallPruneCommand command, IQuidjiboProgress progr //await Task.Run(async () => //{ - var _departmentsService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _departmentsService = scope.Resolve(); var logic = new CallPruneLogic(); var items = await _departmentsService.GetAllDepartmentCallPruningsAsync(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/CleanOIDCScheduleTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CleanOIDCScheduleTask.cs index 7863c7c8e..62496f046 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CleanOIDCScheduleTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CleanOIDCScheduleTask.cs @@ -30,9 +30,10 @@ public async Task ProcessAsync(Commands.CleanOIDCCommand command, IQuidjiboProgr { progress.Report(1, $"Starting the {Name} Task"); - var identityRepository = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var identityRepository = scope.Resolve(); var tokensCleaned = await identityRepository.CleanUpOIDCTokensAsync(DateTime.UtcNow); - var sessionsRepository = Bootstrapper.GetKernel().Resolve(); + var sessionsRepository = scope.Resolve(); var retentionDays = Math.Max(1, SessionSecurityConfig.RevokedSessionRetentionDays); var purgeBefore = DateTime.UtcNow.AddDays(-retentionDays); var sessionsPurged = await sessionsRepository.PurgeInactiveBeforeAsync(purgeBefore, cancellationToken); @@ -40,7 +41,7 @@ public async Task ProcessAsync(Commands.CleanOIDCCommand command, IQuidjiboProgr // UserSessions rows are the access record for every sign-in, so deleting them on retention // is itself an accountable event. Record what ran, the window it covered and how much it // removed; without this the history simply shrinks with nothing explaining why. - var systemAuditsService = Bootstrapper.GetKernel().Resolve(); + var systemAuditsService = scope.Resolve(); await systemAuditsService.SaveSystemAuditAsync(new SystemAudit { System = (int)SystemAuditSystems.Worker, diff --git a/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs b/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs index 9d2af9f18..1a164f96e 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/CommunicationTestTask.cs @@ -28,7 +28,8 @@ public async Task ProcessAsync(CommunicationTestCommand command, IQuidjiboProgre { progress.Report(1, $"Starting the {Name} Task"); - var communicationTestService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var communicationTestService = scope.Resolve(); _logger.LogInformation("CommunicationTest::Processing scheduled tests"); await communicationTestService.ProcessScheduledTestsAsync(cancellationToken); diff --git a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs index 57ff984c6..6cd792079 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs @@ -30,12 +30,13 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP { progress.Report(1, $"Starting the {Name} Task"); - var userProfileService = Bootstrapper.GetKernel().Resolve(); - var callsService = Bootstrapper.GetKernel().Resolve(); - var queueService = Bootstrapper.GetKernel().Resolve(); - var callDispatchStatusService = Bootstrapper.GetKernel().Resolve(); - var featureToggleService = Bootstrapper.GetKernel().Resolve(); - var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var userProfileService = scope.Resolve(); + var callsService = scope.Resolve(); + var queueService = scope.Resolve(); + var callDispatchStatusService = scope.Resolve(); + var featureToggleService = scope.Resolve(); + var dispatchRecommendationService = scope.Resolve(); var pendingCalls = await callsService.GetAllNonDispatchedScheduledCallsWithinDateRange(DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(5)); diff --git a/Workers/Resgrid.Workers.Console/Tasks/GdprExportTask.cs b/Workers/Resgrid.Workers.Console/Tasks/GdprExportTask.cs index 3efe848e0..acb6e3017 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/GdprExportTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/GdprExportTask.cs @@ -28,7 +28,8 @@ public async Task ProcessAsync(GdprExportCommand command, IQuidjiboProgress prog { progress.Report(1, $"Starting the {Name} Task"); - var gdprService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var gdprService = scope.Resolve(); _logger.LogInformation("GdprExport::Expiring old requests"); await gdprService.ExpireOldRequestsAsync(cancellationToken); diff --git a/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs index 286477dba..23fd59b10 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs @@ -1,10 +1,12 @@ using System; using System.Threading; using System.Threading.Tasks; +using Autofac; using Microsoft.Extensions.Logging; using Quidjibo.Handlers; using Quidjibo.Misc; using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; using Resgrid.Workers.Framework.Logic; namespace Resgrid.Workers.Console.Tasks @@ -34,7 +36,8 @@ public async Task ProcessAsync(MemberProfileRelocationCommand command, IQuidjibo try { - var logic = new MemberProfileRelocationLogic(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var logic = new MemberProfileRelocationLogic(scope); var result = await logic.Process(cancellationToken); if (!result.Item1) diff --git a/Workers/Resgrid.Workers.Console/Tasks/ReportDeliveryTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ReportDeliveryTask.cs index 349cd0f78..c1fbf8123 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/ReportDeliveryTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/ReportDeliveryTask.cs @@ -38,8 +38,9 @@ public async Task ProcessAsync(ReportDeliveryTaskCommand command, IQuidjiboProgr { progress.Report(1, $"Starting the {Name} Task"); - var _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - var logic = new ReportDeliveryLogic(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _scheduledTasksService = scope.Resolve(); + var logic = new ReportDeliveryLogic(scope); /* Ok, I legit don't know what happened here. It was working and now it's kinda not. * So I'm replacing the Linq query with this loop, not as fancy but it works. Also diff --git a/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs index 4e17fcf76..ed87d6c4f 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/ReportingRollupTask.cs @@ -35,8 +35,9 @@ public async Task ProcessAsync(ReportingRollupCommand command, IQuidjiboProgress { progress.Report(1, $"Starting the {Name} Task"); - var departmentsService = Bootstrapper.GetKernel().Resolve(); - var rollupProcessor = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var departmentsService = scope.Resolve(); + var rollupProcessor = scope.Resolve(); var departments = await departmentsService.GetAllAsync(); var departmentIds = departments?.Select(d => d.DepartmentId).ToList() ?? new List(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/ShiftNotiferTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ShiftNotiferTask.cs index c7b6d915c..f4b559e96 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/ShiftNotiferTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/ShiftNotiferTask.cs @@ -37,7 +37,8 @@ public async Task ProcessAsync(ShiftNotiferCommand command, IQuidjiboProgress pr //{ IUserProfileService _userProfileService = null; ILogService _logsService = null; - var _shiftsService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _shiftsService = scope.Resolve(); var logic = new ShiftNotifierLogic(); @@ -49,8 +50,8 @@ public async Task ProcessAsync(ShiftNotiferCommand command, IQuidjiboProgress pr { _logger.LogInformation("ShiftNotifer::Shift days to Notify: " + days.Count); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _logsService = Bootstrapper.GetKernel().Resolve(); + _userProfileService = scope.Resolve(); + _logsService = scope.Resolve(); foreach (var schedule in days) { diff --git a/Workers/Resgrid.Workers.Console/Tasks/StaffingScheduleTask.cs b/Workers/Resgrid.Workers.Console/Tasks/StaffingScheduleTask.cs index 048064ff8..a841e1e25 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/StaffingScheduleTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/StaffingScheduleTask.cs @@ -32,8 +32,9 @@ public async Task ProcessAsync(Commands.StaffingScheduleCommand command, IQuidji //await Task.Run(async () => //{ - var _departmentsService = Bootstrapper.GetKernel().Resolve(); - var _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _departmentsService = scope.Resolve(); + var _scheduledTasksService = scope.Resolve(); var logic = new StaffingScheduleLogic(); var allDepartments = await _departmentsService.GetAllAsync(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/StatusScheduleTask.cs b/Workers/Resgrid.Workers.Console/Tasks/StatusScheduleTask.cs index 19e85b287..9894f65b8 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/StatusScheduleTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/StatusScheduleTask.cs @@ -32,8 +32,9 @@ public async Task ProcessAsync(Commands.StatusScheduleCommand command, IQuidjibo //await Task.Run(async () => //{ - var _departmentsService = Bootstrapper.GetKernel().Resolve(); - var _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _departmentsService = scope.Resolve(); + var _scheduledTasksService = scope.Resolve(); var logic = new StatusScheduleLogic(); var allDepartments = await _departmentsService.GetAllAsync(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/SystemSqlQueueTask.cs b/Workers/Resgrid.Workers.Console/Tasks/SystemSqlQueueTask.cs index a0d08d2c0..60f8ad497 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/SystemSqlQueueTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/SystemSqlQueueTask.cs @@ -31,8 +31,9 @@ public async Task ProcessAsync(SystemSqlQueueCommand command, IQuidjiboProgress { progress.Report(1, $"Starting the {Name} Task"); - var queueService = Bootstrapper.GetKernel().Resolve(); - var deleteService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var queueService = scope.Resolve(); + var deleteService = scope.Resolve(); var pendingDepartmentDeleteRequests = await queueService.GetAllPendingDeleteDepartmentQueueItemsAsync(); diff --git a/Workers/Resgrid.Workers.Console/Tasks/TrainingNotiferTask.cs b/Workers/Resgrid.Workers.Console/Tasks/TrainingNotiferTask.cs index 93931cf95..753eb5a85 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/TrainingNotiferTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/TrainingNotiferTask.cs @@ -33,7 +33,8 @@ public async Task ProcessAsync(TrainingNotiferCommand command, IQuidjiboProgress //await Task.Run(async () => //{ - var _trainingService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _trainingService = scope.Resolve(); var logic = new TrainingNotifierLogic(); var trainings = await _trainingService.GetTrainingsToNotifyAsync(DateTime.UtcNow); diff --git a/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs index a7682cc01..776fe7e47 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs @@ -40,7 +40,8 @@ public async Task ProcessAsync(TtsStaticPromptRefreshCommand command, IQuidjiboP return; } - var ttsAudioService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var ttsAudioService = scope.Resolve(); var prompts = TwilioVoicePromptCatalog.GetStaticPrompts(); Exception lastException = null; diff --git a/Workers/Resgrid.Workers.Console/Tasks/Utf8CleanupTask.cs b/Workers/Resgrid.Workers.Console/Tasks/Utf8CleanupTask.cs index aae287a39..1e79943a4 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/Utf8CleanupTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/Utf8CleanupTask.cs @@ -1,7 +1,9 @@ +using Autofac; using Microsoft.Extensions.Logging; using Quidjibo.Handlers; using Quidjibo.Misc; using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; using Resgrid.Workers.Framework.Logic; using System; using System.Threading; @@ -26,7 +28,8 @@ public async Task ProcessAsync(Utf8CleanupCommand command, IQuidjiboProgress pro { progress.Report(1, $"Starting the {Name} Task"); - var logic = new Utf8CleanupLogic(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var logic = new Utf8CleanupLogic(scope); var result = await logic.Process(cancellationToken); if (result.Item1) diff --git a/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs b/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs index 25f73da73..f2797fc54 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/WeatherAlertImportTask.cs @@ -28,7 +28,8 @@ public async Task ProcessAsync(WeatherAlertImportCommand command, IQuidjiboProgr { progress.Report(1, $"Starting the {Name} Task"); - var weatherAlertService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var weatherAlertService = scope.Resolve(); _logger.LogInformation("WeatherAlertImport::Processing all active sources"); await weatherAlertService.ProcessAllActiveSourcesAsync(cancellationToken); diff --git a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs index 4b5644b63..caf60c7d4 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs @@ -40,17 +40,18 @@ public sealed class AdpMigrationLogic private readonly IEmailService _emailService; private readonly IMemberProfileRelocationService _relocationService; - public AdpMigrationLogic() + /// Resolves from the caller's per-sweep scope; the root scope would share one unit of work process-wide. + public AdpMigrationLogic(ILifetimeScope scope) : this( - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve()) + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve()) { } diff --git a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs index b0a4667ef..315fe96eb 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AuditQueueLogic.cs @@ -27,9 +27,11 @@ public class AuditQueueLogic { try { - var auditLogsRepository = Bootstrapper.GetKernel().Resolve(); - var userProfileService = Bootstrapper.GetKernel().Resolve(); - var auditService = Bootstrapper.GetKernel().Resolve(); + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var auditLogsRepository = scope.Resolve(); + var userProfileService = scope.Resolve(); + var auditService = scope.Resolve(); var auditLog = await BuildAuditLogAsync(auditEvent, userProfileService, auditService); await auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); diff --git a/Workers/Resgrid.Workers.Framework/Logic/BroadcastMessageLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/BroadcastMessageLogic.cs index fd19df325..252e8c7fb 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/BroadcastMessageLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/BroadcastMessageLogic.cs @@ -12,11 +12,13 @@ public class BroadcastMessageLogic { public static async Task ProcessMessageQueueItem(MessageQueueItem mqi) { - var _communicationService = Bootstrapper.GetKernel().Resolve(); + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _communicationService = scope.Resolve(); if (mqi != null && mqi.Message == null && mqi.MessageId != 0) { - var messageService = Bootstrapper.GetKernel().Resolve(); + var messageService = scope.Resolve(); mqi.Message = await messageService.GetMessageByIdAsync(mqi.MessageId); } @@ -24,14 +26,14 @@ public static async Task ProcessMessageQueueItem(MessageQueueItem mqi) { if (mqi.Message.MessageRecipients == null || mqi.Message.MessageRecipients.Count <= 0) { - var messageService = Bootstrapper.GetKernel().Resolve(); + var messageService = scope.Resolve(); mqi.Message = await messageService.GetMessageByIdAsync(mqi.Message.MessageId); } // If we didn't get any profiles chances are the message size was too big for Azure, get selected profiles now. if (mqi.Profiles == null) { - var userProfileService = Bootstrapper.GetKernel().Resolve(); + var userProfileService = scope.Resolve(); if (mqi.Message.MessageRecipients != null && mqi.Message.MessageRecipients.Any()) { @@ -60,7 +62,7 @@ public static async Task ProcessMessageQueueItem(MessageQueueItem mqi) if (mqi.Profiles != null) { var sendingToProfile = mqi.Profiles.FirstOrDefault(x => x.UserId == mqi.Message.ReceivingUserId); - var departmentService = Bootstrapper.GetKernel().Resolve(); + var departmentService = scope.Resolve(); var department = await departmentService.GetDepartmentByIdAsync(mqi.DepartmentId); if (sendingToProfile != null) @@ -69,7 +71,7 @@ public static async Task ProcessMessageQueueItem(MessageQueueItem mqi) } else { - var userProfileService = Bootstrapper.GetKernel().Resolve(); + var userProfileService = scope.Resolve(); var sender = await userProfileService.GetProfileByUserIdAsync(mqi.Message.SendingUserId); if (sender != null) @@ -81,7 +83,7 @@ public static async Task ProcessMessageQueueItem(MessageQueueItem mqi) } else if (mqi.Message.MessageRecipients != null && mqi.Message.MessageRecipients.Any()) { - var departmentService = Bootstrapper.GetKernel().Resolve(); + var departmentService = scope.Resolve(); var department = await departmentService.GetDepartmentByIdAsync(mqi.DepartmentId); foreach (var recipient in mqi.Message.MessageRecipients) diff --git a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs index 050fd2de0..5656821cc 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs @@ -13,39 +13,30 @@ namespace Resgrid.Workers.Framework.Logic { public class CalendarNotifierLogic { - private ICalendarService _calendarService; - private ICommunicationService _communicationService; - private IUserProfileService _userProfileService; - private IDepartmentSettingsService _departmentSettingsService; - private IDepartmentGroupsService _departmentGroupsService; - private IDepartmentsService _departmentsService; - private ITextResponsePromptService _textResponsePromptService; - - public CalendarNotifierLogic() - { - _communicationService = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - _calendarService = Bootstrapper.GetKernel().Resolve(); - _departmentGroupsService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _textResponsePromptService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(CalendarNotifierQueueItem item) { bool success = true; string result = ""; + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var communicationService = scope.Resolve(); + var userProfileService = scope.Resolve(); + var departmentSettingsService = scope.Resolve(); + var calendarService = scope.Resolve(); + var departmentGroupsService = scope.Resolve(); + var departmentsService = scope.Resolve(); + var textResponsePromptService = scope.Resolve(); + if (item?.CalendarItem?.Attendees != null && item.CalendarItem.Attendees.Any()) { try { var message = String.Empty; var title = String.Empty; - var profiles = await _userProfileService.GetSelectedUserProfilesAsync(item.CalendarItem.Attendees.Select(x => x.UserId).ToList()); - var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.CalendarItem.DepartmentId); - var department = await _departmentsService.GetDepartmentByIdAsync(item.CalendarItem.DepartmentId, false); + var profiles = await userProfileService.GetSelectedUserProfilesAsync(item.CalendarItem.Attendees.Select(x => x.UserId).ToList()); + var departmentNumber = await departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.CalendarItem.DepartmentId); + var department = await departmentsService.GetDepartmentByIdAsync(item.CalendarItem.DepartmentId, false); var adjustedDateTime = item.CalendarItem.Start.TimeConverter(department); @@ -65,7 +56,7 @@ public async Task> Process(CalendarNotifierQueueItem item) foreach (var person in item.CalendarItem.Attendees) { var profile = profiles.FirstOrDefault(x => x.UserId == person.UserId); - await SendCalendarReminderAsync(item.CalendarItem, person.UserId, message, departmentNumber, department, title, profile); + await SendCalendarReminderAsync(communicationService, textResponsePromptService, item.CalendarItem, person.UserId, message, departmentNumber, department, title, profile); } } } @@ -77,7 +68,7 @@ public async Task> Process(CalendarNotifierQueueItem item) Logging.LogException(ex); } - await _calendarService.MarkAsNotifiedAsync(item.CalendarItem.CalendarItemId); + await calendarService.MarkAsNotifiedAsync(item.CalendarItem.CalendarItemId); } else if (!String.IsNullOrWhiteSpace(item?.CalendarItem?.Entities)) { @@ -85,9 +76,9 @@ public async Task> Process(CalendarNotifierQueueItem item) var message = String.Empty; var title = String.Empty; - var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(item.CalendarItem.DepartmentId); - var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.CalendarItem.DepartmentId); - var department = await _departmentsService.GetDepartmentByIdAsync(item.CalendarItem.DepartmentId, false); + var profiles = await userProfileService.GetAllProfilesForDepartmentAsync(item.CalendarItem.DepartmentId); + var departmentNumber = await departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.CalendarItem.DepartmentId); + var department = await departmentsService.GetDepartmentByIdAsync(item.CalendarItem.DepartmentId, false); var adjustedDateTime = item.CalendarItem.Start.TimeConverter(department); title = $"Upcoming: {SafeCalendarText(item.CalendarItem.Title, "AdpProtectedCalendarTitle")}"; @@ -107,12 +98,12 @@ public async Task> Process(CalendarNotifierQueueItem item) // Notify the entire department foreach (var profile in profiles) { - await SendCalendarReminderAsync(item.CalendarItem, profile.Key, message, departmentNumber, department, title, profile.Value); + await SendCalendarReminderAsync(communicationService, textResponsePromptService, item.CalendarItem, profile.Key, message, departmentNumber, department, title, profile.Value); } } else { - var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(item.CalendarItem.DepartmentId); + var groups = await departmentGroupsService.GetAllGroupsForDepartmentAsync(item.CalendarItem.DepartmentId); foreach (var val in items) { int groupId = 0; @@ -125,9 +116,9 @@ public async Task> Process(CalendarNotifierQueueItem item) foreach (var member in group.Members) { if (profiles.ContainsKey(member.UserId)) - await SendCalendarReminderAsync(item.CalendarItem, member.UserId, message, departmentNumber, department, title, profiles[member.UserId]); + await SendCalendarReminderAsync(communicationService, textResponsePromptService, item.CalendarItem, member.UserId, message, departmentNumber, department, title, profiles[member.UserId]); else - await SendCalendarReminderAsync(item.CalendarItem, member.UserId, message, departmentNumber, department, title, null); + await SendCalendarReminderAsync(communicationService, textResponsePromptService, item.CalendarItem, member.UserId, message, departmentNumber, department, title, null); } } } @@ -135,23 +126,23 @@ public async Task> Process(CalendarNotifierQueueItem item) } } - await _calendarService.MarkAsNotifiedAsync(item.CalendarItem.CalendarItemId); + await calendarService.MarkAsNotifiedAsync(item.CalendarItem.CalendarItemId); } return new Tuple(success, result); } - private async Task SendCalendarReminderAsync(CalendarItem calendarItem, string userId, string message, - string departmentNumber, Department department, string title, UserProfile profile) + private static async Task SendCalendarReminderAsync(ICommunicationService communicationService, ITextResponsePromptService textResponsePromptService, + CalendarItem calendarItem, string userId, string message, string departmentNumber, Department department, string title, UserProfile profile) { - var sent = await _communicationService.SendNotificationAsync(userId, calendarItem.DepartmentId, message, + var sent = await communicationService.SendNotificationAsync(userId, calendarItem.DepartmentId, message, departmentNumber, department, title, profile); if (sent && calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) { try { - await _textResponsePromptService.RecordCalendarRsvpPromptAsync(calendarItem, userId); + await textResponsePromptService.RecordCalendarRsvpPromptAsync(calendarItem, userId); } catch (Exception ex) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs index 1c83d3dc1..cca8ac449 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs @@ -18,22 +18,14 @@ namespace Resgrid.Workers.Framework.Logic { public class BroadcastCallLogic { - private static ICommunicationService _communicationService; - private static ICallsService _callsService; - private static IUserProfileService _userProfilesService; - private static IDepartmentGroupsService _departmentGroupsService; - private static IUnitsService _unitsService; - private static IPersonnelRolesService _rolesService; - private static IPrinterProvider _printerProvider; - private static IDepartmentSettingsService _departmentSettingsService; - private static IShiftsService _shiftsService; - private static IDepartmentsService _departmentsService; - public static async Task ProcessCallQueueItem(CallQueueItem cqi) { - _communicationService = Bootstrapper.GetKernel().Resolve(); - _callsService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); + // Own scope per message. These services used to be static fields resolved from the root scope, + // shared by every call processed concurrently and by the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _communicationService = scope.Resolve(); + var _callsService = scope.Resolve(); + var _departmentSettingsService = scope.Resolve(); cqi?.ApplyBroadcastDispatchFilter(); if (cqi != null && cqi.Call != null && cqi.Call.HasAnyDispatches()) @@ -48,8 +40,7 @@ public static async Task ProcessCallQueueItem(CallQueueItem cqi) */ if (cqi.Profiles == null || !cqi.Profiles.Any()) { - if (_userProfilesService == null) - _userProfilesService = Bootstrapper.GetKernel().Resolve(); + var _userProfilesService = scope.Resolve(); cqi.Profiles = (await _userProfilesService.GetAllProfilesForDepartmentAsync(cqi.Call.DepartmentId)).Select(x => x.Value).ToList(); } @@ -70,8 +61,7 @@ public static async Task ProcessCallQueueItem(CallQueueItem cqi) var dispatchedUsers = new HashSet(); - if (_departmentsService == null) - _departmentsService = Bootstrapper.GetKernel().Resolve(); + var _departmentsService = scope.Resolve(); var department = await _departmentsService.GetDepartmentByIdAsync(cqi.Call.DepartmentId); cqi.Call.Department = department; @@ -122,12 +112,11 @@ async Task SendExpandedAsync(DispatchRoute route) catch (Exception ex) { Logging.LogException(ex); } } - if (_departmentGroupsService == null) - _departmentGroupsService = Bootstrapper.GetKernel().Resolve(); + var _departmentGroupsService = scope.Resolve(); if (cqi.Call.GroupDispatches != null && cqi.Call.GroupDispatches.Any()) { - if (_shiftsService == null) _shiftsService = Bootstrapper.GetKernel().Resolve(); + var _shiftsService = scope.Resolve(); var useShift = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(cqi.Call.DepartmentId); routingTimeUtc = DateTime.UtcNow; var onDuty = useShift @@ -148,7 +137,7 @@ async Task SendExpandedAsync(DispatchRoute route) if (cqi.Call.UnitDispatches != null && cqi.Call.UnitDispatches.Any()) { - if (_unitsService == null) _unitsService = Bootstrapper.GetKernel().Resolve(); + var _unitsService = scope.Resolve(); var crew = await _departmentSettingsService.GetUnitDispatchAlsoDispatchToAssignedPersonnelAsync(cqi.Call.DepartmentId); var group = await _departmentSettingsService.GetUnitDispatchAlsoDispatchToGroupAsync(cqi.Call.DepartmentId); foreach (var dispatch in cqi.Call.UnitDispatches) @@ -171,7 +160,7 @@ async Task SendExpandedAsync(DispatchRoute route) if (cqi.Call.RoleDispatches != null && cqi.Call.RoleDispatches.Any()) { - if (_rolesService == null) _rolesService = Bootstrapper.GetKernel().Resolve(); + var _rolesService = scope.Resolve(); foreach (var dispatch in cqi.Call.RoleDispatches) { var members = await _rolesService.GetAllMembersOfRoleAsync(dispatch.RoleId); @@ -180,7 +169,7 @@ async Task SendExpandedAsync(DispatchRoute route) } // Send Call Print to Printer - _printerProvider = Bootstrapper.GetKernel().Resolve(); + var _printerProvider = scope.Resolve(); Dictionary fetchedGroups = new Dictionary(); if (cqi.Call.Dispatches != null && cqi.Call.Dispatches.Any()) @@ -258,9 +247,11 @@ private static void StartDispatchVoicePreWarm(CallQueueItem cqi) { try { - var ttsAudioService = Bootstrapper.GetKernel().Resolve(); - var geoLocationProvider = Bootstrapper.GetKernel().Resolve(); - var departmentSettingsService = Bootstrapper.GetKernel().Resolve(); + // Fire-and-forget outlives the message's scope, so it takes its own. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var ttsAudioService = scope.Resolve(); + var geoLocationProvider = scope.Resolve(); + var departmentSettingsService = scope.Resolve(); // Text and chunking must match the Twilio voice webhook exactly — // the TTS cache key is a hash of the chunk text. CallPriority was diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs index a1857267d..554762d1e 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs @@ -15,19 +15,14 @@ namespace Resgrid.Workers.Framework.Logic { public class CallEmailImporterLogic { - private ICallsService _callsService; - private IQueueService _queueService; - private IDepartmentsService _departmentsService; - private ICallEmailProvider _callEmailProvider; - private IUserProfileService _userProfileService; - private IDepartmentSettingsService _departmentSettingsService; - private IUnitsService _unitsService; - public async Task> Process(CallEmailQueueItem item) { bool success = true; string result = ""; - _callEmailProvider = Bootstrapper.GetKernel().Resolve(); + + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _callEmailProvider = scope.Resolve(); if (!String.IsNullOrWhiteSpace(item?.EmailSettings?.Hostname)) { @@ -44,12 +39,12 @@ public async Task> Process(CallEmailQueueItem item) { var calls = new List(); - _callsService = Bootstrapper.GetKernel().Resolve(); - _queueService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - _unitsService = Bootstrapper.GetKernel().Resolve(); + var _callsService = scope.Resolve(); + var _queueService = scope.Resolve(); + var _departmentsService = scope.Resolve(); + var _userProfileService = scope.Resolve(); + var _departmentSettingsService = scope.Resolve(); + var _unitsService = scope.Resolve(); // Ran into an issue where the department users didn't come back. We can't put the email back in the POP // email box so just added some simple retry logic here. @@ -118,10 +113,10 @@ public async Task> Process(CallEmailQueueItem item) // Run card auto-dispatch for imported email calls. try { - var featureToggleService = Bootstrapper.GetKernel().Resolve(); + var featureToggleService = scope.Resolve(); if (await featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, savedCall.DepartmentId)) { - var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); + var dispatchRecommendationService = scope.Resolve(); var recommendation = await dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true); if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) @@ -150,7 +145,7 @@ public async Task> Process(CallEmailQueueItem item) { try { - var aiDispatchAdmin = Bootstrapper.GetKernel().Resolve(); + var aiDispatchAdmin = scope.Resolve(); await _queueService.EnqueueAiDispatchTriageAsync(new AiDispatchQueueItem { DepartmentId = savedCall.DepartmentId, CallId = savedCall.CallId, Channel = 3, QueuedOnUtc = DateTime.UtcNow, @@ -174,13 +169,6 @@ await _queueService.EnqueueAiDispatchTriageAsync(new AiDispatchQueueItem } await _departmentsService.SaveDepartmentEmailSettingsAsync(emailResult.EmailSettings); - - _callsService = null; - _queueService = null; - _departmentsService = null; - _callEmailProvider = null; - _userProfileService = null; - _departmentSettingsService = null; } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallPruneLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CallPruneLogic.cs index 65a7e0601..84e3a965b 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallPruneLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallPruneLogic.cs @@ -9,13 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class CallPruneLogic { - private readonly ICallsService _callsService; - - public CallPruneLogic() - { - _callsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(CallPruneQueueItem item) { bool success = true; @@ -25,7 +18,11 @@ public async Task> Process(CallPruneQueueItem item) { try { - var calls = await _callsService.GetActiveCallsByDepartmentAsync(item.PruneSettings.DepartmentId); + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var callsService = scope.Resolve(); + + var calls = await callsService.GetActiveCallsByDepartmentAsync(item.PruneSettings.DepartmentId); if (calls != null && calls.Count > 0) { @@ -46,7 +43,7 @@ public async Task> Process(CallPruneQueueItem item) call.CompletedNotes = "Call automatically closed by the system."; call.ClosedByUserId = item.PruneSettings.Department.ManagingUserId; - await _callsService.SaveCallAsync(call); + await callsService.SaveCallAsync(call); } } } @@ -66,7 +63,7 @@ public async Task> Process(CallPruneQueueItem item) call.CompletedNotes = "Call automatically closed by the system."; call.ClosedByUserId = item.PruneSettings.Department.ManagingUserId; - await _callsService.SaveCallAsync(call); + await callsService.SaveCallAsync(call); } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChatExportLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChatExportLogic.cs index 539f44161..62f035731 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChatExportLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChatExportLogic.cs @@ -32,8 +32,10 @@ public async Task> Process(CancellationToken cancellationTok { try { - var exportRepository = Bootstrapper.GetKernel().Resolve(); - var protectedWriteService = Bootstrapper.GetKernel().Resolve(); + // Own scope per run: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var exportRepository = scope.Resolve(); + var protectedWriteService = scope.Resolve(); // Recovery first: exports stranded in Running by a crashed worker go back to the queue // so they are picked up below (possibly by this run). @@ -57,7 +59,7 @@ public async Task> Process(CancellationToken cancellationTok try { - export.Data = await BuildExportAsync(export, cancellationToken); + export.Data = await BuildExportAsync(scope, export, cancellationToken); export.Status = (int)ChatExportStatus.Complete; export.CompletedOn = DateTime.UtcNow; export.Error = null; @@ -107,12 +109,12 @@ public async Task> Process(CancellationToken cancellationTok } } - private static async Task BuildExportAsync(ChatExport export, CancellationToken cancellationToken) + private static async Task BuildExportAsync(ILifetimeScope scope, ChatExport export, CancellationToken cancellationToken) { - var channelRepository = Bootstrapper.GetKernel().Resolve(); - var messageRepository = Bootstrapper.GetKernel().Resolve(); - var editRepository = Bootstrapper.GetKernel().Resolve(); - var moderationRepository = Bootstrapper.GetKernel().Resolve(); + var channelRepository = scope.Resolve(); + var messageRepository = scope.Resolve(); + var editRepository = scope.Resolve(); + var moderationRepository = scope.Resolve(); var messages = (await messageRepository.GetForExportAsync(export.DepartmentId, export.ChatChannelId, export.StartDate, export.EndDate, MaxMessagesPerExport))?.ToList() ?? new List(); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChatRetentionLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChatRetentionLogic.cs index 65c2576eb..5f44ef6cc 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChatRetentionLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChatRetentionLogic.cs @@ -26,10 +26,12 @@ public async Task> Process(CancellationToken cancellationTok { try { - var settingsRepository = Bootstrapper.GetKernel().Resolve(); - var channelRepository = Bootstrapper.GetKernel().Resolve(); - var messageRepository = Bootstrapper.GetKernel().Resolve(); - var exportRepository = Bootstrapper.GetKernel().Resolve(); + // Own scope per run: root-scope repositories would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var settingsRepository = scope.Resolve(); + var channelRepository = scope.Resolve(); + var messageRepository = scope.Resolve(); + var exportRepository = scope.Resolve(); var runUtc = DateTime.UtcNow; var totalDeleted = 0; diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs index d2b6dc101..1953c51c7 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs @@ -22,11 +22,11 @@ public class ChatbotMessageLogic /// classification as the webhook so every STOP variant it honors is honored here; a resolver /// fault falls back to a literal "STOP" compare rather than blocking an opt-out. /// - private static bool IsStopCommand(string body) + private static bool IsStopCommand(ILifetimeScope scope, string body) { try { - var textCommandService = Bootstrapper.GetKernel().Resolve(); + var textCommandService = scope.Resolve(); return textCommandService.DetermineType(body).Type == Model.TextCommandTypes.Stop; } catch (Exception ex) @@ -53,9 +53,11 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu try { - var chatbotIngressService = Bootstrapper.GetKernel().Resolve(); - var textMessageProvider = Bootstrapper.GetKernel().Resolve(); - var cacheProvider = Bootstrapper.GetKernel().Resolve(); + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var chatbotIngressService = scope.Resolve(); + var textMessageProvider = scope.Resolve(); + var cacheProvider = scope.Resolve(); // Idempotency: the bus is at-least-once, so a redelivered item must not produce a second // bot reply. Keyed on the platform/persisted message id with a 24h marker. A cache @@ -82,7 +84,7 @@ async Task SendReplyAsync(string text) // SignalR fan-out), never over SMS — From is a Resgrid user id here, not a phone // number. The ingress-resolved DepartmentId is passed through so the reply lands // in the department the message actually came from. - var notifier = Bootstrapper.GetKernel().Resolve(); + var notifier = scope.Resolve(); if (notifier != null) await notifier.PushToUserAsync(item.From, text, item.DepartmentId); } @@ -116,7 +118,7 @@ async Task SendReplyAsync(string text) // half an hour later would act on stale intent. Value-free text; fail-open guard. // EXCEPTION: STOP always works — opting out of messages is not a department-data // mutation and must never be blocked by a migration lock. - if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.DepartmentId) && !IsStopCommand(item.Body)) + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.DepartmentId) && !IsStopCommand(scope, item.Body)) { await SendReplyAsync("Resgrid is briefly paused for scheduled maintenance in your department. Please try again shortly."); return true; diff --git a/Workers/Resgrid.Workers.Framework/Logic/CommunicationTestLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CommunicationTestLogic.cs index 4e5a436c8..476e3497f 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CommunicationTestLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CommunicationTestLogic.cs @@ -30,7 +30,8 @@ public static async Task ProcessCommunicationTestQueueItem(CommunicationTe try { - var communicationTestService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var communicationTestService = scope.Resolve(); // Both halves are idempotent — building no-ops once the run has results and each result // is only ever sent once — so an at-least-once redelivery cannot double-send. diff --git a/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs b/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs index af9714e80..1ac3295f5 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs @@ -25,7 +25,9 @@ public static async Task IsDepartmentLockedAsync(int departmentId) try { - var lockService = Bootstrapper.GetKernel().Resolve(); + // Own scope: a root-scope resolve would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var lockService = scope.Resolve(); return await lockService.IsDepartmentLockedAsync(departmentId); } catch (Exception ex) diff --git a/Workers/Resgrid.Workers.Framework/Logic/DistributionListEmailImporterLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/DistributionListEmailImporterLogic.cs index d862b14a0..6f57ce488 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/DistributionListEmailImporterLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/DistributionListEmailImporterLogic.cs @@ -11,19 +11,6 @@ namespace Resgrid.Workers.Framework.Logic { public class DistributionListEmailImporterLogic { - private IDistributionListProvider _distributionListProvider; - private IEmailService _emailService; - private IUsersService _usersService; - private IDistributionListsService _distributionListsService; - - public DistributionListEmailImporterLogic() - { - _distributionListProvider = Bootstrapper.GetKernel().Resolve(); - _emailService = Bootstrapper.GetKernel().Resolve(); - _usersService = Bootstrapper.GetKernel().Resolve(); - _distributionListsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(DistributionListQueueItem item) { bool success = true; @@ -35,11 +22,18 @@ public async Task> Process(DistributionListQueueItem item) { try { - var emails = _distributionListProvider.GetNewMessagesFromMailbox(item.List); + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var distributionListProvider = scope.Resolve(); + var emailService = scope.Resolve(); + var usersService = scope.Resolve(); + var distributionListsService = scope.Resolve(); + + var emails = distributionListProvider.GetNewMessagesFromMailbox(item.List); if (emails != null && emails.Count > 0) { - var listMembers = await _distributionListsService.GetAllListMembersByListIdAsync(item.List.DistributionListId); + var listMembers = await distributionListsService.GetAllListMembersByListIdAsync(item.List.DistributionListId); foreach (var email in emails) { foreach (var member in listMembers) @@ -48,10 +42,10 @@ public async Task> Process(DistributionListQueueItem item) if (member.User != null && member.User != null) membership = member.User; else - membership = _usersService.GetMembershipByUserId(member.UserId); + membership = usersService.GetMembershipByUserId(member.UserId); if (membership != null && !String.IsNullOrWhiteSpace(membership.Email)) - await _emailService.SendDistributionListEmail(email, membership.Email, item.List.Name, $"Resgrid ({item.List.Name}) List", $"{item.List.EmailAddress}@{Config.InboundEmailConfig.ListsDomain}"); + await emailService.SendDistributionListEmail(email, membership.Email, item.List.Name, $"Resgrid ({item.List.Name}) List", $"{item.List.EmailAddress}@{Config.InboundEmailConfig.ListsDomain}"); } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/DistributionListLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/DistributionListLogic.cs index fe9898cb2..7cde348e6 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/DistributionListLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/DistributionListLogic.cs @@ -15,16 +15,18 @@ public class DistributionListLogic { public static async Task ProcessDistributionListQueueItem(DistributionListQueueItem dlqi) { - var emailService = Bootstrapper.GetKernel().Resolve(); - var distributionListsService = Bootstrapper.GetKernel().Resolve(); - var fileService = Bootstrapper.GetKernel().Resolve(); + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var emailService = scope.Resolve(); + var distributionListsService = scope.Resolve(); + var fileService = scope.Resolve(); if (dlqi != null && dlqi.List != null && dlqi.Message != null) { // If we didn't get any profiles chances are the message size was too big for Azure, get selected profiles now. if (dlqi.Users == null) { - var departmentsService = Bootstrapper.GetKernel().Resolve(); + var departmentsService = scope.Resolve(); dlqi.Users = await departmentsService.GetAllUsersForDepartmentAsync(dlqi.List.DepartmentId); } diff --git a/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs b/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs index bc0fe1c17..5fd4b6fcd 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/FeatureToggleUsageProcessor.cs @@ -23,7 +23,9 @@ public class FeatureToggleUsageProcessor { try { - var featureToggleService = Bootstrapper.GetKernel().Resolve(); + // The evaluation counters are static, so a per-run scope flushes the same buffer. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var featureToggleService = scope.Resolve(); var flushed = await featureToggleService.FlushEvaluationsAsync(cancellationToken); return new Tuple(true, $"Flushed {flushed} feature toggle usage record(s)."); diff --git a/Workers/Resgrid.Workers.Framework/Logic/GdprExportLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/GdprExportLogic.cs index 89f46cc59..434af5d60 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/GdprExportLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/GdprExportLogic.cs @@ -9,13 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class GdprExportLogic { - private readonly IGdprDataExportService _gdprDataExportService; - - public GdprExportLogic() - { - _gdprDataExportService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> ProcessAsync(CancellationToken cancellationToken = default) { bool success = true; @@ -23,8 +16,10 @@ public async Task> ProcessAsync(CancellationToken cancellati try { - await _gdprDataExportService.ExpireOldRequestsAsync(cancellationToken); - await _gdprDataExportService.ProcessPendingRequestsAsync(cancellationToken); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var gdprDataExportService = scope.Resolve(); + await gdprDataExportService.ExpireOldRequestsAsync(cancellationToken); + await gdprDataExportService.ProcessPendingRequestsAsync(cancellationToken); } catch (Exception ex) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceLogic.cs index 98ad8a061..0a035a96a 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceLogic.cs @@ -9,23 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class MaintenanceLogic { - private IDepartmentMembersRepository _departmentMembersRepository; - private IUserProfileService _userProfileService; - private IUsersService _usersService; - private IDepartmentsService _departmentsService; - private IScheduledTasksService _scheduledTasksService; - private ICallsRepository _callsRepository; - - public MaintenanceLogic() - { - _departmentMembersRepository = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _usersService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - _callsRepository = Bootstrapper.GetKernel().Resolve(); - } - public Tuple FixMissingUserProfiles() { bool success = true; diff --git a/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs index e49299e22..7f9f1596e 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs @@ -37,10 +37,11 @@ public sealed class MemberProfileRelocationLogic private readonly IMemberProfileRelocationService _relocationService; private readonly IDepartmentDataProtectionService _protectionService; - public MemberProfileRelocationLogic() + /// Resolves from the caller's per-run scope; the root scope would share one unit of work process-wide. + public MemberProfileRelocationLogic(ILifetimeScope scope) : this( - Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve()) + scope.Resolve(), + scope.Resolve()) { } diff --git a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs index 1d6cb0903..a45a2f7e6 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs @@ -17,9 +17,12 @@ public class NotificationBroadcastLogic public static async Task ProcessNotificationItem(NotificationItem ni, string messageId, string body, CancellationToken cancellationToken = default(CancellationToken)) { + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + if (ni?.Type == (int)EventTypes.ModerationRequestCompleted) { - var moderationService = Bootstrapper.GetKernel().Resolve(); + var moderationService = scope.Resolve(); await moderationService.NotifyReportersAsync(ni.Value, cancellationToken); return true; } @@ -28,7 +31,7 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri { // Records notification 31 is author-targeted (RMS plan section 4.7); it bypasses the department // notification settings pipeline below, which is why the type stays [NotMapped]. - var recordsNotificationService = Bootstrapper.GetKernel().Resolve(); + var recordsNotificationService = scope.Resolve(); await recordsNotificationService.NotifyReturnedForCorrectionAsync(ni.DepartmentId, ni.Value, cancellationToken); return true; } @@ -40,7 +43,7 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri // a record id never contains a pipe, so the split is unambiguous. var parts = (ni.Value ?? string.Empty).Split('|'); var obligation = parts.Length > 1 && int.TryParse(parts[1], out var parsed) ? (RmsRecordObligation)parsed : RmsRecordObligation.Review; - var recordsNotificationService = Bootstrapper.GetKernel().Resolve(); + var recordsNotificationService = scope.Resolve(); await recordsNotificationService.NotifyObligationOverdueAsync(ni.DepartmentId, parts[0], obligation, cancellationToken); return true; } @@ -48,20 +51,20 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri if (ni?.Type == (int)EventTypes.RecordSubmissionRejected) { // Records notification 33 (RMS-2): the destination rejected the author's incident report; author-targeted like 31. - var recordsNotificationService = Bootstrapper.GetKernel().Resolve(); + var recordsNotificationService = scope.Resolve(); await recordsNotificationService.NotifySubmissionRejectedAsync(ni.DepartmentId, ni.Value, cancellationToken); return true; } if (ni != null) { - var _notificationService = Bootstrapper.GetKernel().Resolve(); - var _communicationService = Bootstrapper.GetKernel().Resolve(); - var _departmentsService = Bootstrapper.GetKernel().Resolve(); - var _userProfileService = Bootstrapper.GetKernel().Resolve(); - var _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - var _calendarService = Bootstrapper.GetKernel().Resolve(); - var _textResponsePromptService = Bootstrapper.GetKernel().Resolve(); + var _notificationService = scope.Resolve(); + var _communicationService = scope.Resolve(); + var _departmentsService = scope.Resolve(); + var _userProfileService = scope.Resolve(); + var _departmentSettingsService = scope.Resolve(); + var _calendarService = scope.Resolve(); + var _textResponsePromptService = scope.Resolve(); var item = new ProcessedNotification(); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs index f2cb24296..76edddb8b 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs @@ -16,22 +16,17 @@ namespace Resgrid.Workers.Framework.Logic /// public class ParEvaluationLogic { - private readonly IDepartmentsService _departmentsService; - private readonly ICallsService _callsService; - private readonly IIncidentCommandService _incidentCommandService; - - public ParEvaluationLogic() - { - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _callsService = Bootstrapper.GetKernel().Resolve(); - _incidentCommandService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(CancellationToken cancellationToken = default) { try { - var departments = await _departmentsService.GetAllAsync(); + // Own scope per sweep: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var departmentsService = scope.Resolve(); + var callsService = scope.Resolve(); + var incidentCommandService = scope.Resolve(); + + var departments = await departmentsService.GetAllAsync(); if (departments == null) return new Tuple(true, "No departments to sweep."); @@ -43,7 +38,7 @@ public async Task> Process(CancellationToken cancellationTok if (cancellationToken.IsCancellationRequested) break; - var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId); + var activeCalls = await callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId); if (activeCalls == null) continue; @@ -51,7 +46,7 @@ public async Task> Process(CancellationToken cancellationTok { // EvaluateCriticalParAsync no-ops cheaply when the call has no active incident command, // so we can sweep every check-in-enabled active call without pre-filtering by command. - var flagged = await _incidentCommandService.EvaluateCriticalParAsync( + var flagged = await incidentCommandService.EvaluateCriticalParAsync( department.DepartmentId, call.CallId, cancellationToken); callsSwept++; diff --git a/Workers/Resgrid.Workers.Framework/Logic/PaymentQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/PaymentQueueLogic.cs index fcb36645b..9055819da 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/PaymentQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/PaymentQueueLogic.cs @@ -21,6 +21,9 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) { try { + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + switch ((CqrsEventTypes)qi.Type) { case CqrsEventTypes.None: @@ -30,7 +33,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (succeededCharge != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripePaymentAsync(succeededCharge); } @@ -40,7 +43,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (failedCharge != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeChargeFailedAsync(failedCharge); } @@ -50,7 +53,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (refundedCharge != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeSubscriptionRefundAsync(refundedCharge); } @@ -60,7 +63,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (updatedSubscription != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeSubscriptionUpdateAsync(updatedSubscription); } @@ -70,7 +73,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (deletedSubscription != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeSubscriptionCancellationAsync(deletedSubscription); } @@ -80,7 +83,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (stripeCheckoutSession != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeCheckoutCompletedAsync(stripeCheckoutSession); } @@ -90,7 +93,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (stripeCheckoutSessionUpdated != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeCheckoutUpdateAsync(stripeCheckoutSessionUpdated); } @@ -100,7 +103,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (invoicePaid != null) { - var paymentProviderService = Bootstrapper.GetKernel().Resolve(); + var paymentProviderService = scope.Resolve(); await paymentProviderService.ProcessStripeInvoicePaidAsync(invoicePaid); } @@ -117,7 +120,7 @@ public static async Task ProcessPaymentQueueItem(CqrsEvent qi) if (adpEvent != null) { - var dataProtectionService = Bootstrapper.GetKernel().Resolve(); + var dataProtectionService = scope.Resolve(); var adpResult = await dataProtectionService.ApplyAddonBillingEventAsync(adpEvent); diff --git a/Workers/Resgrid.Workers.Framework/Logic/PersonnelLocationQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/PersonnelLocationQueueLogic.cs index f0da13379..d43eb9d44 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/PersonnelLocationQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/PersonnelLocationQueueLogic.cs @@ -19,7 +19,8 @@ public class PersonnelLocationQueueLogic { try { - var usersService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var usersService = scope.Resolve(); var personnelLocation = new PersonnelLocation(); personnelLocation.UserId = personnelLocationEvent.UserId; diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index f1376995c..a4077f1ec 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -22,14 +22,11 @@ public class ReportDeliveryLogic private readonly IBusinessOperationsAccessService _businessOperationsAccess; private readonly IDepartmentsService _departments; - public ReportDeliveryLogic() + /// Resolves from the caller's per-run scope; the root scope would share one unit of work process-wide. + public ReportDeliveryLogic(ILifetimeScope scope) + : this(scope.Resolve(), scope.Resolve(), scope.Resolve(), + scope.Resolve(), scope.Resolve(), scope.Resolve()) { - _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - _emailService = Bootstrapper.GetKernel().Resolve(); - _pdfProvider = Bootstrapper.GetKernel().Resolve(); - _checklistReports = Bootstrapper.GetKernel().Resolve(); - _businessOperationsAccess = Bootstrapper.GetKernel().Resolve(); - _departments = Bootstrapper.GetKernel().Resolve(); } public ReportDeliveryLogic(IScheduledTasksService tasks, IEmailService email, IPdfProvider pdf, IChecklistScheduledReportService checklistReports, IBusinessOperationsAccessService businessOperationsAccess, IDepartmentsService departments = null) { _scheduledTasksService = tasks; _emailService = email; _pdfProvider = pdf; _checklistReports = checklistReports; _businessOperationsAccess = businessOperationsAccess; _departments = departments; } diff --git a/Workers/Resgrid.Workers.Framework/Logic/ResourceOrderNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ResourceOrderNotifierLogic.cs index 3c62685a6..99b54eb0d 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ResourceOrderNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ResourceOrderNotifierLogic.cs @@ -9,19 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class ResourceOrderNotifierLogic { - private ITrainingService _trainingService; - private ICommunicationService _communicationService; - private IUserProfileService _userProfileService; - private IDepartmentSettingsService _departmentSettingsService; - - public ResourceOrderNotifierLogic() - { - _trainingService = Bootstrapper.GetKernel().Resolve(); - _communicationService = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - } - public Tuple Process(ResourceOrderAddedEvent item) { bool success = true; diff --git a/Workers/Resgrid.Workers.Framework/Logic/SecurityLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/SecurityLogic.cs index 7766b5884..069004cfb 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/SecurityLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/SecurityLogic.cs @@ -24,35 +24,6 @@ public class SecurityLogic private static string WhoCanViewPersonnelCacheKey = "ViewUsersSecurityMaxtix_{0}"; private static string WhoCanViewPersonnelLocationsCacheKey = "ViewUserLocationsSecurityMaxtix_{0}"; - private IDepartmentMembersRepository _departmentMembersRepository; - private IUserProfileService _userProfileService; - private IUsersService _usersService; - private IDepartmentsService _departmentsService; - private IScheduledTasksService _scheduledTasksService; - private ICallsRepository _callsRepository; - private IPermissionsService _permissionsService; - private IUnitsService _unitsService; - private IDepartmentGroupsService _departmentGroupsService; - private IPersonnelRolesService _personnelRolesService; - private ICacheProvider _cacheProvider; - private IAuthorizationService _authorizationService; - - public SecurityLogic() - { - _departmentMembersRepository = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _usersService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - _callsRepository = Bootstrapper.GetKernel().Resolve(); - _permissionsService = Bootstrapper.GetKernel().Resolve(); - _unitsService = Bootstrapper.GetKernel().Resolve(); - _departmentGroupsService = Bootstrapper.GetKernel().Resolve(); - _personnelRolesService = Bootstrapper.GetKernel().Resolve(); - _cacheProvider = Bootstrapper.GetKernel().Resolve(); - _authorizationService = Bootstrapper.GetKernel().Resolve(); - } - /// /// A rebuild that throws must not take the caller down with it. The all-departments sweep would /// abandon every department after the failing one, and the queue handler has no catch of its @@ -77,6 +48,17 @@ private async Task> ProcessInternalAsync(SecurityQueueItem i bool success = true; string result = String.Empty; + // Own scope per rebuild. This logic is long-lived in the queue processor, and services cached from the + // root scope would share the process-wide root unit of work with every other job. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _departmentMembersRepository = scope.Resolve(); + var _departmentsService = scope.Resolve(); + var _permissionsService = scope.Resolve(); + var _unitsService = scope.Resolve(); + var _departmentGroupsService = scope.Resolve(); + var _personnelRolesService = scope.Resolve(); + var _cacheProvider = scope.Resolve(); + var department = await _departmentsService.GetDepartmentByIdAsync(item.DepartmentId); if (item.Type == SecurityCacheTypes.WhoCanViewUnits) @@ -667,7 +649,9 @@ async Task getWhoCanViewUserLocations() /// public async Task> UpdatedCachedSecurityForAllDepartments() { - var departments = await _departmentsService.GetAllAsync(); + List departments; + using (var scope = Bootstrapper.GetKernel().BeginLifetimeScope()) + departments = await scope.Resolve().GetAllAsync(); var failures = new List(); async Task rebuild(int departmentId, SecurityCacheTypes type) diff --git a/Workers/Resgrid.Workers.Framework/Logic/ShiftNotificationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ShiftNotificationLogic.cs index 123b0c3f5..40a9234bc 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ShiftNotificationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ShiftNotificationLogic.cs @@ -16,10 +16,12 @@ public static async Task ProcessShiftQueueItem(ShiftQueueItem sqi) { if (sqi != null) { - var _shiftsService = Bootstrapper.GetKernel().Resolve(); - var _communicationService = Bootstrapper.GetKernel().Resolve(); - var _userProfileService = Bootstrapper.GetKernel().Resolve(); - var _departmentService = Bootstrapper.GetKernel().Resolve(); + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var _shiftsService = scope.Resolve(); + var _communicationService = scope.Resolve(); + var _userProfileService = scope.Resolve(); + var _departmentService = scope.Resolve(); var department = await _departmentService.GetDepartmentByIdAsync(sqi.DepartmentId, false); @@ -104,7 +106,7 @@ await _communicationService.SendNotificationAsync(proposedUserProfile.UserId, tr { var text = $"{signupProfile?.FullName?.AsFirstNameLastName} signed up for {shift.Name} on {day} and needs approval"; - foreach (var supervisorId in await GetShiftSupervisorIdsAsync(sqi.DepartmentId, signup.DepartmentGroupId)) + foreach (var supervisorId in await GetShiftSupervisorIdsAsync(scope, sqi.DepartmentId, signup.DepartmentGroupId)) { if (supervisorId != signup.UserId) await _communicationService.SendNotificationAsync(supervisorId, sqi.DepartmentId, text, sqi.DepartmentNumber, department, shift.Name); @@ -143,7 +145,7 @@ await _communicationService.SendNotificationAsync(proposedUserProfile.UserId, tr { var text = $"{sourceProfile?.FullName?.AsFirstNameLastName} is trading {source.Shift.Name} on {day} to {takerProfile?.FullName?.AsFirstNameLastName} and needs approval"; - foreach (var supervisorId in await GetShiftSupervisorIdsAsync(sqi.DepartmentId, source.DepartmentGroupId)) + foreach (var supervisorId in await GetShiftSupervisorIdsAsync(scope, sqi.DepartmentId, source.DepartmentGroupId)) { if (supervisorId != source.UserId) await _communicationService.SendNotificationAsync(supervisorId, sqi.DepartmentId, text, sqi.DepartmentNumber, department, source.Shift.Name); @@ -220,10 +222,10 @@ await _communicationService.SendNotificationAsync(proposedUserProfile.UserId, tr /// /// Who approves shift changes for a group: department admins plus the admins of the group and any group above it. /// - private static async Task> GetShiftSupervisorIdsAsync(int departmentId, int? departmentGroupId) + private static async Task> GetShiftSupervisorIdsAsync(ILifetimeScope scope, int departmentId, int? departmentGroupId) { - var departmentsService = Bootstrapper.GetKernel().Resolve(); - var departmentGroupsService = Bootstrapper.GetKernel().Resolve(); + var departmentsService = scope.Resolve(); + var departmentGroupsService = scope.Resolve(); var ids = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ShiftNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ShiftNotifierLogic.cs index 6bcf1f452..dd25a67ba 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ShiftNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ShiftNotifierLogic.cs @@ -11,19 +11,6 @@ namespace Resgrid.Workers.Framework.Logic { public class ShiftNotifierLogic { - private IShiftsService _shiftsService; - private ICommunicationService _communicationService; - private IDepartmentSettingsService _departmentSettingsService; - private IDepartmentsService _departmentsService; - - public ShiftNotifierLogic() - { - _shiftsService = Bootstrapper.GetKernel().Resolve(); - _communicationService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(ShiftNotifierQueueItem item) { bool success = true; @@ -31,9 +18,16 @@ public async Task> Process(ShiftNotifierQueueItem item) if (item != null && item.Shift != null) { - var text = _shiftsService.GenerateShiftNotificationText(item.Shift); - string departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.Shift.DepartmentId); - var department = await _departmentsService.GetDepartmentByIdAsync(item.Shift.DepartmentId, false); + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var shiftsService = scope.Resolve(); + var communicationService = scope.Resolve(); + var departmentSettingsService = scope.Resolve(); + var departmentsService = scope.Resolve(); + + var text = shiftsService.GenerateShiftNotificationText(item.Shift); + string departmentNumber = await departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.Shift.DepartmentId); + var department = await departmentsService.GetDepartmentByIdAsync(item.Shift.DepartmentId, false); if (ConfigHelper.CanTransmit(item.Shift.DepartmentId) && item.UserIds != null) { @@ -44,7 +38,7 @@ public async Task> Process(ShiftNotifierQueueItem item) foreach (var userId in item.UserIds.Where(x => !String.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)) { UserProfile profile = item.Profiles?.FirstOrDefault(x => String.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase)); - await _communicationService.SendNotificationAsync(userId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(userId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } } @@ -55,7 +49,7 @@ await _communicationService.SendNotificationAsync(userId, item.Shift.DepartmentI foreach (var person in item.Shift.Personnel) { UserProfile profile = item.Profiles.FirstOrDefault(x => x.UserId == person.UserId); - await _communicationService.SendNotificationAsync(person.UserId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(person.UserId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } } @@ -69,26 +63,26 @@ await _communicationService.SendNotificationAsync(person.UserId, item.Shift.Depa if (!String.IsNullOrWhiteSpace(signup.Trade.UserId)) { UserProfile profile = item.Profiles.FirstOrDefault(x => x.UserId == signup.Trade.UserId); - await _communicationService.SendNotificationAsync(signup.Trade.UserId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(signup.Trade.UserId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } else if (signup.GetTradeType() == ShiftTradeTypes.Source) { UserProfile profile = item.Profiles.FirstOrDefault(x => x.UserId == signup.Trade.TargetShiftSignup.UserId); - await _communicationService.SendNotificationAsync(signup.Trade.TargetShiftSignup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(signup.Trade.TargetShiftSignup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } else if (signup.GetTradeType() == ShiftTradeTypes.Target) { UserProfile profile = item.Profiles.FirstOrDefault(x => x.UserId == signup.Trade.SourceShiftSignup.UserId); - await _communicationService.SendNotificationAsync(signup.Trade.SourceShiftSignup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(signup.Trade.SourceShiftSignup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } } else { UserProfile profile = item.Profiles.FirstOrDefault(x => x.UserId == signup.UserId); - await _communicationService.SendNotificationAsync(signup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, + await communicationService.SendNotificationAsync(signup.UserId, item.Shift.DepartmentId, text, departmentNumber, department, item.Shift.Name, profile); } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs index 2f94c1938..1201f180b 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs @@ -9,19 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class StaffingScheduleLogic { - private IUserStateService _userStateService; - private IScheduledTasksService _scheduledTasksService; - private IDepartmentsService _departmentsService; - private IActionLogsService _actionLogsService; - - public StaffingScheduleLogic() - { - _userStateService = Bootstrapper.GetKernel().Resolve(); - _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _actionLogsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(StaffingScheduleQueueItem item) { bool success = true; @@ -29,6 +16,12 @@ public async Task> Process(StaffingScheduleQueueItem item) if (item != null && item.ScheduledTask != null) { + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var userStateService = scope.Resolve(); + var departmentsService = scope.Resolve(); + var scheduledTasksService = scope.Resolve(); + // ADP department operation lock: staffing mutations are deferred, not dropped — the // occurrence is skipped WITHOUT a completion log so the scheduler re-picks it after // the lock releases (plan section 20.2). @@ -39,15 +32,15 @@ public async Task> Process(StaffingScheduleQueueItem item) { if (item.ScheduledTask.TaskType == (int)TaskTypes.UserStaffingLevel) { - await _userStateService.CreateUserState(item.ScheduledTask.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), item.ScheduledTask.Note, autoGenerated: true); + await userStateService.CreateUserState(item.ScheduledTask.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), item.ScheduledTask.Note, autoGenerated: true); } else if (item.ScheduledTask.TaskType == (int)TaskTypes.DepartmentStaffingReset) { - var users = await _departmentsService.GetAllUsersForDepartment(item.ScheduledTask.DepartmentId, true); + var users = await departmentsService.GetAllUsersForDepartment(item.ScheduledTask.DepartmentId, true); foreach (var user in users) { - await _userStateService.CreateUserState(user.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Staffing Reset {item.ScheduledTask.ScheduledTaskId}", autoGenerated: true); + await userStateService.CreateUserState(user.UserId, item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Staffing Reset {item.ScheduledTask.ScheduledTaskId}", autoGenerated: true); } } } @@ -59,7 +52,7 @@ public async Task> Process(StaffingScheduleQueueItem item) } if (success) - await _scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); + await scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); } return new Tuple(success, result); diff --git a/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs index 509a22d83..e92597ca6 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs @@ -9,19 +9,6 @@ namespace Resgrid.Workers.Framework.Logic { public class StatusScheduleLogic { - private IUserStateService _userStateService; - private IScheduledTasksService _scheduledTasksService; - private IDepartmentsService _departmentsService; - private IActionLogsService _actionLogsService; - - public StatusScheduleLogic() - { - _userStateService = Bootstrapper.GetKernel().Resolve(); - _scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - _actionLogsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(StatusScheduleQueueItem item) { bool success = true; @@ -29,6 +16,11 @@ public async Task> Process(StatusScheduleQueueItem item) if (item != null && item.ScheduledTask != null) { + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var actionLogsService = scope.Resolve(); + var scheduledTasksService = scope.Resolve(); + // ADP department operation lock: status mutations are deferred, not dropped — the // occurrence is skipped WITHOUT a completion log so the scheduler re-picks it after // the lock releases (plan section 20.2). @@ -39,7 +31,7 @@ public async Task> Process(StatusScheduleQueueItem item) { if (item.ScheduledTask.TaskType == (int)TaskTypes.DepartmentStatusReset) { - await _actionLogsService.SetActionForEntireDepartmentAsync(item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Status Reset {item.ScheduledTask.ScheduledTaskId}"); + await actionLogsService.SetActionForEntireDepartmentAsync(item.ScheduledTask.DepartmentId, int.Parse(item.ScheduledTask.Data), $"Department Status Reset {item.ScheduledTask.ScheduledTaskId}"); } } catch (Exception ex) @@ -50,7 +42,7 @@ public async Task> Process(StatusScheduleQueueItem item) } if (success) - await _scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); + await scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); } return new Tuple(success, result); diff --git a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs index 5d394ec2a..b81b09f0c 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs @@ -26,6 +26,9 @@ public class SystemQueueLogic if (qi != null) { + // Own scope per message: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + switch ((CqrsEventTypes)qi.Type) { case CqrsEventTypes.None: @@ -50,7 +53,7 @@ public class SystemQueueLogic } else { - var pushService = Bootstrapper.GetKernel().Resolve(); + var pushService = scope.Resolve(); var resgriterResult = await pushService.Register(data); if (!resgriterResult) @@ -77,9 +80,19 @@ public class SystemQueueLogic pushUri.DeviceId = unitData.DeviceId; pushUri.Uuid = unitData.Uuid; - var pushService = Bootstrapper.GetKernel().Resolve(); + var pushService = scope.Resolve(); + + // Clearing the legacy Azure registration is best-effort. When it threw here, the + // catch below skipped RegisterUnit, the Novu path that actually delivers unit pushes. + try + { + await pushService.UnRegisterUnit(pushUri); + } + catch (Exception ex) + { + Logging.LogException(ex, $"UnitPushRegistration: legacy unregister failed for unit {unitData.UnitId}, continuing with registration."); + } - await pushService.UnRegisterUnit(pushUri); var unitResult = await pushService.RegisterUnit(pushUri); if (!unitResult) @@ -99,14 +112,14 @@ public class SystemQueueLogic if (int.TryParse(qi.Data, out departmentId)) { - var userProfileService = Bootstrapper.GetKernel().Resolve(); - //var departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - var subscriptionService = Bootstrapper.GetKernel().Resolve(); - //var scheduledTasksService = Bootstrapper.GetKernel().Resolve(); - var departmentService = Bootstrapper.GetKernel().Resolve(); - var actionLogsService = Bootstrapper.GetKernel().Resolve(); - var customStatesService = Bootstrapper.GetKernel().Resolve(); - var usersService = Bootstrapper.GetKernel().Resolve(); + var userProfileService = scope.Resolve(); + //var departmentSettingsService = scope.Resolve(); + var subscriptionService = scope.Resolve(); + //var scheduledTasksService = scope.Resolve(); + var departmentService = scope.Resolve(); + var actionLogsService = scope.Resolve(); + var customStatesService = scope.Resolve(); + var usersService = scope.Resolve(); subscriptionService.ClearCacheForCurrentPayment(departmentId); departmentService.InvalidateDepartmentUsersInCache(departmentId); @@ -142,9 +155,9 @@ public class SystemQueueLogic if (newChatEvent != null) { - var userProfileService = Bootstrapper.GetKernel().Resolve(); - var communicationService = Bootstrapper.GetKernel().Resolve(); - var usersService = Bootstrapper.GetKernel().Resolve(); + var userProfileService = scope.Resolve(); + var communicationService = scope.Resolve(); + var usersService = scope.Resolve(); if (newChatEvent != null && newChatEvent.RecipientUserIds != null && newChatEvent.RecipientUserIds.Count > 0) @@ -183,15 +196,15 @@ public class SystemQueueLogic if (troubleAlertEvent != null && troubleAlertEvent.DepartmentId.HasValue) { - var userProfileService = Bootstrapper.GetKernel().Resolve(); - var communicationService = Bootstrapper.GetKernel().Resolve(); - var usersService = Bootstrapper.GetKernel().Resolve(); - var departmentService = Bootstrapper.GetKernel().Resolve(); - var unitsService = Bootstrapper.GetKernel().Resolve(); - var departmentGroupService = Bootstrapper.GetKernel().Resolve(); - var callsService = Bootstrapper.GetKernel().Resolve(); - var departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - var geoLocationProvider = Bootstrapper.GetKernel().Resolve(); + var userProfileService = scope.Resolve(); + var communicationService = scope.Resolve(); + var usersService = scope.Resolve(); + var departmentService = scope.Resolve(); + var unitsService = scope.Resolve(); + var departmentGroupService = scope.Resolve(); + var callsService = scope.Resolve(); + var departmentSettingsService = scope.Resolve(); + var geoLocationProvider = scope.Resolve(); var admins = await departmentService.GetAllAdminsForDepartmentAsync(troubleAlertEvent.DepartmentId.Value); var unit = await unitsService.GetUnitByIdAsync(troubleAlertEvent.UnitId); @@ -258,9 +271,9 @@ public class SystemQueueLogic { // Same row as the audit queue writes (actor, IP, user agent, subject, fallback message); the copy of its switch // that used to live here had drifted from it. - var auditLogsRepository = Bootstrapper.GetKernel().Resolve(); - var userProfileService = Bootstrapper.GetKernel().Resolve(); - var auditService = Bootstrapper.GetKernel().Resolve(); + var auditLogsRepository = scope.Resolve(); + var userProfileService = scope.Resolve(); + var auditService = scope.Resolve(); var auditLog = await AuditQueueLogic.BuildAuditLogAsync(auditEvent, userProfileService, auditService); await auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); diff --git a/Workers/Resgrid.Workers.Framework/Logic/TrainingNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/TrainingNotifierLogic.cs index 076cd24e2..d1c9e105d 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/TrainingNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/TrainingNotifierLogic.cs @@ -10,21 +10,6 @@ namespace Resgrid.Workers.Framework.Logic { public class TrainingNotifierLogic { - private ITrainingService _trainingService; - private ICommunicationService _communicationService; - private IUserProfileService _userProfileService; - private IDepartmentSettingsService _departmentSettingsService; - private IDepartmentsService _departmentsService; - - public TrainingNotifierLogic() - { - _trainingService = Bootstrapper.GetKernel().Resolve(); - _communicationService = Bootstrapper.GetKernel().Resolve(); - _userProfileService = Bootstrapper.GetKernel().Resolve(); - _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); - _departmentsService = Bootstrapper.GetKernel().Resolve(); - } - public async Task> Process(TrainingNotifierQueueItem item) { bool success = true; @@ -32,11 +17,19 @@ public async Task> Process(TrainingNotifierQueueItem item) if (item != null && item.Training != null && item.Training.Users != null && item.Training.Users.Count > 0) { + // Own scope per item: root-scope services would share the process-wide root unit of work. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var trainingService = scope.Resolve(); + var communicationService = scope.Resolve(); + var userProfileService = scope.Resolve(); + var departmentSettingsService = scope.Resolve(); + var departmentsService = scope.Resolve(); + var message = String.Empty; var title = String.Empty; - var profiles = await _userProfileService.GetSelectedUserProfilesAsync(item.Training.Users.Select(x => x.UserId).ToList()); - var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.Training.DepartmentId); - var department = await _departmentsService.GetDepartmentByIdAsync(item.Training.DepartmentId, false); + var profiles = await userProfileService.GetSelectedUserProfilesAsync(item.Training.Users.Select(x => x.UserId).ToList()); + var departmentNumber = await departmentSettingsService.GetTextToCallNumberForDepartmentAsync(item.Training.DepartmentId); + var department = await departmentsService.GetDepartmentByIdAsync(item.Training.DepartmentId, false); if (ConfigHelper.CanTransmit(item.Training.DepartmentId)) { @@ -60,13 +53,13 @@ public async Task> Process(TrainingNotifierQueueItem item) var profile = profiles.FirstOrDefault(x => x.UserId == person.UserId); if (!item.Training.Notified.HasValue || !person.Complete) - await _communicationService.SendNotificationAsync(person.UserId, item.Training.DepartmentId, message, departmentNumber, department, title, profile); + await communicationService.SendNotificationAsync(person.UserId, item.Training.DepartmentId, message, departmentNumber, department, title, profile); title = "Training Due Notice"; } } - await _trainingService.MarkAsNotifiedAsync(item.Training.TrainingId); + await trainingService.MarkAsNotifiedAsync(item.Training.TrainingId); } return new Tuple(success, result); diff --git a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs index 46b974189..dabd56ea1 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/UnitLocationQueueLogic.cs @@ -33,7 +33,8 @@ public class UnitLocationQueueLogic if (!unitLocationEvent.Latitude.HasValue || !unitLocationEvent.Longitude.HasValue) throw new InvalidOperationException("A valid Unit location queue event must contain latitude and longitude."); - var unitService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var unitService = scope.Resolve(); var timestamp = unitLocationEvent.Timestamp == default ? unitLocationEvent.ReceivedOn ?? DateTime.UtcNow : unitLocationEvent.Timestamp; diff --git a/Workers/Resgrid.Workers.Framework/Logic/Utf8CleanupLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/Utf8CleanupLogic.cs index 4b2f144a7..8d37776db 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/Utf8CleanupLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/Utf8CleanupLogic.cs @@ -20,8 +20,9 @@ public class Utf8CleanupLogic { private readonly IUtf8MaintenanceRepository _repository; - public Utf8CleanupLogic() - : this(Bootstrapper.GetKernel().Resolve()) + /// Resolves from the caller's per-run scope; the root scope would share one unit of work process-wide. + public Utf8CleanupLogic(ILifetimeScope scope) + : this(scope.Resolve()) { } diff --git a/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs index beb61167e..70c6c9283 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs @@ -18,6 +18,10 @@ public static async Task ProcessWorkflowQueueItem(WorkflowQueueItem item, try { + // Own scope per message: root-scope services would share the process-wide root unit of work, + // and workflow execution writes run logs inside their own transactions. + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + // ADP department operation lock: workflow executions can mutate department data, so a // locked department's items are requeued unchanged (same attempt number — deferral is // not a retry) rather than executed or dead-lettered (plan section 20.2). The short @@ -26,13 +30,13 @@ public static async Task ProcessWorkflowQueueItem(WorkflowQueueItem item, if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.DepartmentId)) { await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - var deferralQueue = Bootstrapper.GetKernel().Resolve(); + var deferralQueue = scope.Resolve(); await deferralQueue.EnqueueWorkflow(item); return true; } - var workflowService = Bootstrapper.GetKernel().Resolve(); - var departmentsService = Bootstrapper.GetKernel().Resolve(); + var workflowService = scope.Resolve(); + var departmentsService = scope.Resolve(); // Get the department code needed for credential decryption var department = await departmentsService.GetDepartmentByIdAsync(item.DepartmentId, false); @@ -60,7 +64,7 @@ public static async Task ProcessWorkflowQueueItem(WorkflowQueueItem item, var delaySeconds = (int)Math.Pow(2, item.AttemptNumber - 1) * backoffBase; await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken); - var outboundQueue = Bootstrapper.GetKernel().Resolve(); + var outboundQueue = scope.Resolve(); await outboundQueue.EnqueueWorkflow(new WorkflowQueueItem { WorkflowId = item.WorkflowId,