diff --git a/Apps/GatewayApi/src/Services/IUserProfileNotificationSettingService.cs b/Apps/GatewayApi/src/Services/IUserProfileNotificationSettingService.cs index 5791d3cbcf..7404ec19e7 100644 --- a/Apps/GatewayApi/src/Services/IUserProfileNotificationSettingService.cs +++ b/Apps/GatewayApi/src/Services/IUserProfileNotificationSettingService.cs @@ -38,11 +38,11 @@ public interface IUserProfileNotificationSettingService /// /// The user HDID. /// - /// The notification setting model containing the notification type and updated delivery channel - /// values. + /// The notification setting model containing the notification type and optional delivery channel values. + /// Only provided channel values are updated and included in the emitted notification preference event. /// /// The cancellation token. - /// A representing the result of the asynchronous operation. + /// A task representing the asynchronous operation. Task UpdateAsync( string hdid, UserProfileNotificationSettingModel model, @@ -53,8 +53,9 @@ Task UpdateAsync( /// /// The user HDID. /// - /// A collection of notification setting models containing the notification types - /// and updated delivery channel values. + /// A collection of notification setting models containing the notification types and optional delivery channel values. + /// Provided channel values are updated. When a channel value is not provided, an existing stored value may still be + /// included in the emitted notification preference event. /// /// Whether to commit changes immediately or defer to the caller's transaction. /// The cancellation token. diff --git a/Apps/GatewayApi/src/Services/UserProfileNotificationSettingService.cs b/Apps/GatewayApi/src/Services/UserProfileNotificationSettingService.cs index 20fb9bb30e..592bd9e190 100644 --- a/Apps/GatewayApi/src/Services/UserProfileNotificationSettingService.cs +++ b/Apps/GatewayApi/src/Services/UserProfileNotificationSettingService.cs @@ -56,7 +56,45 @@ public async Task UpdateAsync( UserProfileNotificationSettingModel model, CancellationToken ct = default) { - await this.UpdateAsync(hdid, [model], ct: ct); + UserProfile userProfile = await profileDelegate.GetUserProfileAsync(hdid, ct: ct) ?? throw new NotFoundException($"User profile not found for hdid {hdid}"); + + IReadOnlyList existing = + await notificationSettingDelegate.GetAsync(hdid, ct); + + UserProfileNotificationSetting setting = + existing.SingleOrDefault(x => x.NotificationType == model.Type) ?? new UserProfileNotificationSetting + { + Hdid = hdid, + NotificationType = model.Type, + }; + + // Update email preference only when an email value is provided. + if (model.EmailEnabled.HasValue) + { + setting.EmailEnabled = model.EmailEnabled.Value; + } + + // Update SMS preference only when an SMS value is provided. + if (model.SmsEnabled.HasValue) + { + setting.SmsEnabled = model.SmsEnabled.Value; + } + + IReadOnlyCollection emailNotificationTargets = + model.EmailEnabled.HasValue + ? GetTargets(model.Type, model.EmailEnabled.Value, !string.IsNullOrEmpty(userProfile.Email)) + : []; + + IReadOnlyCollection smsNotificationTargets = + model.SmsEnabled.HasValue + ? GetTargets(model.Type, model.SmsEnabled.Value, !string.IsNullOrEmpty(userProfile.SmsNumber)) + : []; + + MessageEnvelope[] events = + [new(new NotificationChannelPreferencesChangedEvent(hdid, userProfile.SmsNumber, smsNotificationTargets, userProfile.Email, emailNotificationTargets), hdid)]; + + await notificationSettingDelegate.UpdateAsync(setting, false, ct); + await outboxStore.StoreAsync(events, ct: ct); } /// @@ -84,6 +122,7 @@ public async Task UpdateAsync( NotificationType = model.Type, }; + // Use provided email value when present. if (model.EmailEnabled.HasValue) { setting.EmailEnabled = model.EmailEnabled.Value; @@ -92,6 +131,14 @@ public async Task UpdateAsync( GetTargets(model.Type, model.EmailEnabled.Value, !string.IsNullOrEmpty(userProfile.Email))); } + // Otherwise use existing email value when available. + if (!model.EmailEnabled.HasValue && setting.EmailEnabled.HasValue) + { + emailNotificationTargets.AddRange( + GetTargets(model.Type, setting.EmailEnabled.Value, !string.IsNullOrEmpty(userProfile.Email))); + } + + // Use provided SMS value when present. if (model.SmsEnabled.HasValue) { setting.SmsEnabled = model.SmsEnabled.Value; @@ -100,6 +147,13 @@ public async Task UpdateAsync( GetTargets(model.Type, model.SmsEnabled.Value, !string.IsNullOrEmpty(userProfile.SmsNumber))); } + // Otherwise use existing SMS value when available. + if (!model.SmsEnabled.HasValue && setting.SmsEnabled.HasValue) + { + smsNotificationTargets.AddRange( + GetTargets(model.Type, setting.SmsEnabled.Value, !string.IsNullOrEmpty(userProfile.SmsNumber))); + } + await notificationSettingDelegate.UpdateAsync(setting, false, ct); } diff --git a/Apps/GatewayApi/test/unit/Services.Test/UserProfileNotificationSettingServiceTests.cs b/Apps/GatewayApi/test/unit/Services.Test/UserProfileNotificationSettingServiceTests.cs index c8967b9103..850300e49a 100644 --- a/Apps/GatewayApi/test/unit/Services.Test/UserProfileNotificationSettingServiceTests.cs +++ b/Apps/GatewayApi/test/unit/Services.Test/UserProfileNotificationSettingServiceTests.cs @@ -421,22 +421,26 @@ public async Task UpdateAsyncSendsEmptyTargetsWhenNoValuesProvided() } [Theory] - [InlineData("test@healthgateway.gov.bc.ca", true, 1)] - [InlineData("test@healthgateway.gov.bc.ca", false, 0)] - [InlineData(null, false, 0)] - public async Task UpdateAsyncSetsEmailTargetsCorrectly( + [InlineData("test@healthgateway.gov.bc.ca", true, "6046715000", true, 1, 1)] + [InlineData("test@healthgateway.gov.bc.ca", false, "6046715000", true, 0, 1)] + [InlineData(null, true, "6046715000", true, 0, 1)] + [InlineData("test@healthgateway.gov.bc.ca", true, "6046715000", false, 1, 0)] + [InlineData("test@healthgateway.gov.bc.ca", true, null, true, 1, 0)] + [InlineData(null, false, null, false, 0, 0)] + public async Task UpdateAsyncSetsTargetsCorrectly( string? email, bool emailEnabled, - int expectedEmailTargetCount) + string? smsNumber, + bool smsEnabled, + int expectedEmailTargetCount, + int expectedSmsTargetCount) { // Arrange - const ProfileNotificationType expectedType = ProfileNotificationType.BcCancerScreening; - UserProfile userProfile = new() { HdId = Hdid, Email = email, - SmsNumber = SmsNumber, + SmsNumber = smsNumber, }; Mock profileDelegateMock = new(); @@ -465,9 +469,9 @@ public async Task UpdateAsyncSetsEmailTargetsCorrectly( UserProfileNotificationSettingModel model = new() { - Type = expectedType, + Type = ProfileNotificationType.BcCancerScreening, EmailEnabled = emailEnabled, - SmsEnabled = false, + SmsEnabled = smsEnabled, }; // Act @@ -476,74 +480,53 @@ public async Task UpdateAsyncSetsEmailTargetsCorrectly( // Assert capturedEvent.ShouldNotBeNull(); capturedEvent!.EmailNotificationTargets.Count.ShouldBe(expectedEmailTargetCount); + capturedEvent.SmsNotificationTargets.Count.ShouldBe(expectedSmsTargetCount); } - [Theory] - [InlineData("6046715000", true, 1)] - [InlineData("6046715000", false, 0)] - [InlineData(null, false, 0)] - public async Task UpdateAsyncSetsSmsTargetsCorrectly( - string? smsNumber, - bool smsEnabled, - int expectedSmsTargetCount) + + /// + /// UpdateAsync throws NotFoundException. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task UpdateAsyncThrowsNotFoundException() { // Arrange - const ProfileNotificationType expectedType = ProfileNotificationType.BcCancerScreening; - - UserProfile userProfile = new() + UserProfileNotificationSettingModel notificationSettingModel = new() { - HdId = Hdid, - Email = Email, - SmsNumber = smsNumber, + Type = ProfileNotificationType.BcCancerScreening, + EmailEnabled = true, + SmsEnabled = false, }; + UserProfile? userProfile = null; + Mock profileDelegateMock = new(); profileDelegateMock .Setup(s => s.GetUserProfileAsync(Hdid, It.IsAny(), It.IsAny())) .ReturnsAsync(userProfile); Mock notificationSettingDelegateMock = new(); - notificationSettingDelegateMock - .Setup(s => s.GetAsync(Hdid, It.IsAny())) - .ReturnsAsync([]); - - NotificationChannelPreferencesChangedEvent? capturedEvent = null; - Mock outboxStoreMock = new(); - outboxStoreMock - .Setup(m => m.StoreAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, bool, CancellationToken>((envelopes, _, _) => { capturedEvent = envelopes.First().Content as NotificationChannelPreferencesChangedEvent; }) - .Returns(Task.CompletedTask); IUserProfileNotificationSettingService service = GetNotificationSettingService(profileDelegateMock, notificationSettingDelegateMock, outboxStoreMock); - UserProfileNotificationSettingModel model = new() - { - Type = expectedType, - EmailEnabled = false, - SmsEnabled = smsEnabled, - }; - - // Act - await service.UpdateAsync(Hdid, model, CancellationToken.None); - - // Assert - capturedEvent.ShouldNotBeNull(); - capturedEvent!.SmsNotificationTargets.Count.ShouldBe(expectedSmsTargetCount); + // Act and Assert + await Assert.ThrowsAsync(async () => + await service.UpdateAsync(Hdid, notificationSettingModel, CancellationToken.None)); } /// - /// UpdateAsync sets empty email targets when email notifications are disabled. + /// UpdateAsync throws ArgumentOutOfRangeException when notification type is unsupported. /// /// A representing the asynchronous unit test. [Fact] - public async Task UpdateAsyncSetsEmptyEmailTargetsWhenEmailDisabled() + public async Task UpdateAsyncThrowsArgumentOutOfRangeExceptionWhenTypeUnsupported() { // Arrange + const ProfileNotificationType unsupportedType = (ProfileNotificationType)999; + UserProfile userProfile = new() { HdId = Hdid, @@ -561,50 +544,64 @@ public async Task UpdateAsyncSetsEmptyEmailTargetsWhenEmailDisabled() .Setup(s => s.GetAsync(Hdid, It.IsAny())) .ReturnsAsync([]); - NotificationChannelPreferencesChangedEvent? capturedEvent = null; - Mock outboxStoreMock = new(); - outboxStoreMock - .Setup(m => m.StoreAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, bool, CancellationToken>((envelopes, _, _) => { capturedEvent = envelopes.First().Content as NotificationChannelPreferencesChangedEvent; }) - .Returns(Task.CompletedTask); - IUserProfileNotificationSettingService service = GetNotificationSettingService(profileDelegateMock, notificationSettingDelegateMock, outboxStoreMock); UserProfileNotificationSettingModel model = new() { - Type = ProfileNotificationType.BcCancerScreening, - EmailEnabled = false, - SmsEnabled = false, + Type = unsupportedType, + EmailEnabled = true, + SmsEnabled = true, }; - // Act - await service.UpdateAsync(Hdid, model, CancellationToken.None); - - // Assert - capturedEvent.ShouldNotBeNull(); - capturedEvent!.EmailNotificationTargets.ShouldBeEmpty(); + // Act and Assert + await Assert.ThrowsAsync(async () => + await service.UpdateAsync(Hdid, model, CancellationToken.None)); } /// - /// UpdateAsync sets empty email targets when no email address is available. + /// UpdateAsync collection overload preserves existing channel values when a model value is not provided. /// + /// The existing email enabled value. + /// The existing SMS enabled value. + /// The model email enabled value. + /// The model SMS enabled value. + /// The expected email target count. + /// The expected SMS target count. /// A representing the asynchronous unit test. - [Fact] - public async Task UpdateAsyncSetsEmptyEmailTargetsWhenEmailMissing() + [Theory] + [InlineData(true, null, null, true, 1, 1)] + [InlineData(true, null, null, false, 1, 0)] + [InlineData(null, true, true, null, 1, 1)] + [InlineData(null, true, false, null, 0, 1)] + [InlineData(false, true, null, null, 0, 1)] + [InlineData(true, false, null, null, 1, 0)] + public async Task UpdateAsyncCollectionUsesExistingValuesWhenModelValuesAreNull( + bool? existingEmailEnabled, + bool? existingSmsEnabled, + bool? modelEmailEnabled, + bool? modelSmsEnabled, + int expectedEmailTargets, + int expectedSmsTargets) { // Arrange UserProfile userProfile = new() { HdId = Hdid, - Email = null, + Email = Email, SmsNumber = SmsNumber, }; + UserProfileNotificationSetting existingSetting = new() + { + Id = Guid.NewGuid(), + Hdid = Hdid, + NotificationType = ProfileNotificationType.BcCancerScreening, + EmailEnabled = existingEmailEnabled, + SmsEnabled = existingSmsEnabled, + }; + Mock profileDelegateMock = new(); profileDelegateMock .Setup(s => s.GetUserProfileAsync(Hdid, It.IsAny(), It.IsAny())) @@ -613,80 +610,123 @@ public async Task UpdateAsyncSetsEmptyEmailTargetsWhenEmailMissing() Mock notificationSettingDelegateMock = new(); notificationSettingDelegateMock .Setup(s => s.GetAsync(Hdid, It.IsAny())) - .ReturnsAsync([]); + .ReturnsAsync([existingSetting]); NotificationChannelPreferencesChangedEvent? capturedEvent = null; Mock outboxStoreMock = new(); outboxStoreMock - .Setup(m => m.StoreAsync( + .Setup(s => s.StoreAsync( It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, bool, CancellationToken>((envelopes, _, _) => { capturedEvent = envelopes.First().Content as NotificationChannelPreferencesChangedEvent; }) + .Callback, bool, CancellationToken>((events, _, _) => { capturedEvent = events.Single().Content as NotificationChannelPreferencesChangedEvent; }) .Returns(Task.CompletedTask); IUserProfileNotificationSettingService service = GetNotificationSettingService(profileDelegateMock, notificationSettingDelegateMock, outboxStoreMock); - UserProfileNotificationSettingModel model = new() - { - Type = ProfileNotificationType.BcCancerScreening, - EmailEnabled = true, - SmsEnabled = false, - }; + IReadOnlyCollection models = + [ + new() + { + Type = ProfileNotificationType.BcCancerScreening, + EmailEnabled = modelEmailEnabled, + SmsEnabled = modelSmsEnabled, + }, + ]; // Act - await service.UpdateAsync(Hdid, model, CancellationToken.None); + await service.UpdateAsync(Hdid, models, true, CancellationToken.None); // Assert + notificationSettingDelegateMock.Verify( + s => s.UpdateAsync( + It.Is(x => + x.Hdid == Hdid && + x.NotificationType == ProfileNotificationType.BcCancerScreening), + false, + It.IsAny()), + Times.Once); + + outboxStoreMock.Verify( + s => s.StoreAsync( + It.IsAny>(), + true, + It.IsAny()), + Times.Once); + capturedEvent.ShouldNotBeNull(); - capturedEvent!.EmailNotificationTargets.ShouldBeEmpty(); + capturedEvent!.EmailNotificationTargets.Count.ShouldBe(expectedEmailTargets); + capturedEvent.SmsNotificationTargets.Count.ShouldBe(expectedSmsTargets); } - /// - /// UpdateAsync throws NotFoundException. - /// - /// A representing the asynchronous unit test. [Fact] - public async Task UpdateAsyncThrowsNotFoundException() + public async Task UpdateAsyncCollectionPassesCommitToOutboxStore() { // Arrange - UserProfileNotificationSettingModel notificationSettingModel = new() + UserProfile userProfile = new() { - Type = ProfileNotificationType.BcCancerScreening, - EmailEnabled = true, - SmsEnabled = false, + HdId = Hdid, + Email = Email, + SmsNumber = SmsNumber, }; - UserProfile? userProfile = null; - Mock profileDelegateMock = new(); profileDelegateMock .Setup(s => s.GetUserProfileAsync(Hdid, It.IsAny(), It.IsAny())) .ReturnsAsync(userProfile); Mock notificationSettingDelegateMock = new(); + notificationSettingDelegateMock + .Setup(s => s.GetAsync(Hdid, It.IsAny())) + .ReturnsAsync([]); + Mock outboxStoreMock = new(); IUserProfileNotificationSettingService service = GetNotificationSettingService(profileDelegateMock, notificationSettingDelegateMock, outboxStoreMock); - // Act and Assert - await Assert.ThrowsAsync(async () => - await service.UpdateAsync(Hdid, notificationSettingModel, CancellationToken.None)); + IReadOnlyCollection models = + [ + new() + { + Type = ProfileNotificationType.BcCancerScreening, + EmailEnabled = true, + SmsEnabled = true, + }, + ]; + + // Act + await service.UpdateAsync(Hdid, models, false, CancellationToken.None); + + // Assert + outboxStoreMock.Verify( + s => s.StoreAsync( + It.IsAny>(), + false, + It.IsAny()), + Times.Once); } /// - /// UpdateAsync throws ArgumentOutOfRangeException when notification type is unsupported. + /// UpdateAsync collection overload preserves the existing channel value when only the other channel is provided. /// + /// The existing email enabled value. + /// The existing SMS enabled value. + /// The model email enabled value. + /// The model SMS enabled value. /// A representing the asynchronous unit test. - [Fact] - public async Task UpdateAsyncThrowsArgumentOutOfRangeExceptionWhenTypeUnsupported() + [Theory] + [InlineData(true, null, null, true)] + [InlineData(null, true, true, null)] + public async Task UpdateAsyncCollectionPreservesExistingChannelWhenOnlyOtherChannelProvided( + bool? existingEmailEnabled, + bool? existingSmsEnabled, + bool? modelEmailEnabled, + bool? modelSmsEnabled) { // Arrange - const ProfileNotificationType unsupportedType = (ProfileNotificationType)999; - UserProfile userProfile = new() { HdId = Hdid, @@ -694,6 +734,15 @@ public async Task UpdateAsyncThrowsArgumentOutOfRangeExceptionWhenTypeUnsupporte SmsNumber = SmsNumber, }; + UserProfileNotificationSetting existingSetting = new() + { + Id = Guid.NewGuid(), + Hdid = Hdid, + NotificationType = ProfileNotificationType.BcCancerScreening, + EmailEnabled = existingEmailEnabled, + SmsEnabled = existingSmsEnabled, + }; + Mock profileDelegateMock = new(); profileDelegateMock .Setup(s => s.GetUserProfileAsync(Hdid, It.IsAny(), It.IsAny())) @@ -702,22 +751,50 @@ public async Task UpdateAsyncThrowsArgumentOutOfRangeExceptionWhenTypeUnsupporte Mock notificationSettingDelegateMock = new(); notificationSettingDelegateMock .Setup(s => s.GetAsync(Hdid, It.IsAny())) - .ReturnsAsync([]); + .ReturnsAsync([existingSetting]); + + NotificationChannelPreferencesChangedEvent? capturedEvent = null; Mock outboxStoreMock = new(); + outboxStoreMock + .Setup(s => s.StoreAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, bool, CancellationToken>((events, _, _) => { capturedEvent = events.Single().Content as NotificationChannelPreferencesChangedEvent; }) + .Returns(Task.CompletedTask); + IUserProfileNotificationSettingService service = GetNotificationSettingService(profileDelegateMock, notificationSettingDelegateMock, outboxStoreMock); - UserProfileNotificationSettingModel model = new() - { - Type = unsupportedType, - EmailEnabled = true, - SmsEnabled = true, - }; + IReadOnlyCollection models = + [ + new() + { + Type = ProfileNotificationType.BcCancerScreening, + EmailEnabled = modelEmailEnabled, + SmsEnabled = modelSmsEnabled, + }, + ]; - // Act and Assert - await Assert.ThrowsAsync(async () => - await service.UpdateAsync(Hdid, model, CancellationToken.None)); + // Act + await service.UpdateAsync(Hdid, models, true, CancellationToken.None); + + // Assert + notificationSettingDelegateMock.Verify( + x => x.UpdateAsync( + It.Is(s => + s.Hdid == Hdid && + s.NotificationType == ProfileNotificationType.BcCancerScreening && + s.EmailEnabled == true && + s.SmsEnabled == true), + false, + It.IsAny()), + Times.Once); + + capturedEvent.ShouldNotBeNull(); + capturedEvent!.EmailNotificationTargets.ShouldContain(NotificationTargets.BcCancer); + capturedEvent.SmsNotificationTargets.ShouldContain(NotificationTargets.BcCancer); } private static IUserProfileNotificationSettingService GetNotificationSettingService( diff --git a/Apps/JobScheduler/src/Jobs/ClearSmsNumberJob.cs b/Apps/JobScheduler/src/Jobs/ClearSmsNumberJob.cs deleted file mode 100644 index 69e82b19e9..0000000000 --- a/Apps/JobScheduler/src/Jobs/ClearSmsNumberJob.cs +++ /dev/null @@ -1,243 +0,0 @@ -// ------------------------------------------------------------------------- -// Copyright © 2019 Province of British Columbia -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------- -namespace HealthGateway.JobScheduler.Jobs -{ - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Hangfire; - using HealthGateway.Database.Context; - using HealthGateway.Database.Models; - using HealthGateway.JobScheduler.Models; - using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore.Storage; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - - /// - /// Executes the SMS cleanup workflow by clearing SmsNumber for eligible user profiles. - /// - /// The database context. - /// The logger. - /// Provides access to the configured clear SMS number job options. - /// Provides access to the configured notification backfill job options. - public class ClearSmsNumberJob( - GatewayDbContext dbContext, - ILogger logger, - IOptionsMonitor clearSmsNumberOptionsMonitor, - IOptionsMonitor notificationBackfillOptionsMonitor) - { - private const int ConcurrencyTimeout = 5 * 60; // 5 Minutes - private const string ClearSmsNumberOptionsName = "ClearSmsNumber"; - private const string NotificationSmsBackfillOptionsName = "NotificationSmsBackfill"; - - private ClearSmsNumberOptions options = null!; - - /// - /// Executes the SMS cleanup workflow by clearing SmsNumber for eligible user profiles. - /// - /// Cancellation token used to stop processing gracefully. - /// A that represents the asynchronous operation. - [DisableConcurrentExecution(ConcurrencyTimeout)] - public async Task ProcessAsync(CancellationToken ct = default) - { - this.options = clearSmsNumberOptionsMonitor.Get(ClearSmsNumberOptionsName); - - // Skip execution if the job is disabled via configuration - if (!this.options.Enabled) - { - logger.LogInformation("Job {JobName} is disabled", this.options.JobName); - return; - } - - this.ValidateCutoffMatchesNotificationBackfill(); - await this.ClearSmsNumbersAsync(ct); - } - - /// - /// Creates a record for the specified user. - /// - /// The user profile HDID. - /// The preference key. - /// The preference value. - /// A new instance. - private static UserPreference CreateUserPreference(string hdid, string preference, string value) - { - return new() - { - HdId = hdid, - Preference = preference, - Value = value, - }; - } - - /// - /// Creates a record to track that a user profile - /// has been processed by the current job run. - /// - /// The user profile HDID. - /// Logical job identifier (must match configured job name). - /// The UTC date/time the user was processed. - /// - /// JobName is used as a logical identifier for the job in UserJobState tracking. - /// It must remain consistent for a given job to prevent reprocessing. - /// Changing JobName will cause all users to be treated as unprocessed. - /// - /// A new instance. - private static UserJobState CreateUserJobState(string hdid, string jobName, DateTime processedDateTime) - { - return new() - { - JobName = jobName, - Hdid = hdid, - ProcessedDateTime = processedDateTime, - }; - } - - /// - /// Validates that the cutoff date configured for the ClearSmsNumber job - /// matches the cutoff date configured for the NotificationSmsBackfill job. - /// - /// - /// These two jobs are designed to operate on mutually exclusive sets of users - /// based on LastLoginDateTime: - /// - ClearSmsNumber processes users with LastLoginDateTime less than the cutoff - /// - NotificationSmsBackfill processes users with LastLoginDateTime on or after the cutoff - /// If the cutoff dates differ, the partitioning becomes invalid and can result in: - /// - Overlap: the same user being processed by both jobs - /// - Gaps: some users not being processed by either job - /// This validation ensures configuration consistency and fails fast if the cutoff - /// dates are misaligned. - /// - /// - /// Thrown when the cutoff dates for the two jobs do not match. - /// - private void ValidateCutoffMatchesNotificationBackfill() - { - NotificationBackfillOptions notificationBackfillOptions = notificationBackfillOptionsMonitor.Get(NotificationSmsBackfillOptionsName); - - if (!this.options.LastLoginAfterDate.HasValue) - { - throw new InvalidOperationException( - $"{this.options.JobName}: LastLoginAfterDate must be configured."); - } - - if (!notificationBackfillOptions.LastLoginAfterDate.HasValue) - { - throw new InvalidOperationException( - $"{notificationBackfillOptions.JobName}: LastLoginAfterDate must be configured."); - } - - DateTime clearSmsNumberCutoff = this.options.LastLoginAfterDate.Value.ToUniversalTime(); - DateTime notificationBackfillCutoff = notificationBackfillOptions.LastLoginAfterDate.Value.ToUniversalTime(); - - if (clearSmsNumberCutoff != notificationBackfillCutoff) - { - throw new InvalidOperationException( - $"Cutoff mismatch: {this.options.JobName} ({clearSmsNumberCutoff:o}) " + - $"!= {notificationBackfillOptions.JobName} ({notificationBackfillCutoff:o})."); - } - } - - private async Task ClearSmsNumbersAsync(CancellationToken ct) - { - logger.LogInformation("Job {JobName} started", this.options.JobName); - Stopwatch stopwatch = Stopwatch.StartNew(); - - try - { - // LastLoginAfterDate is validated by ValidateCutoffMatchesNotificationBackfill(). - DateTime cutoffDate = this.options.LastLoginAfterDate!.Value.ToUniversalTime(); - - List userProfiles = await this.GetNextUserProfilesBeforeCutoffAsync( - cutoffDate, - this.options.JobName, - this.options.BatchSize, - ct); - - if (userProfiles.Count > 0) - { - // Begin transaction to ensure atomic batch processing - await using IDbContextTransaction transaction = - await dbContext.Database.BeginTransactionAsync(ct); - - DateTime processedDateTime = DateTime.UtcNow; - List jobStatesToInsert = []; - List preferencesToInsert = []; - - foreach (UserProfile userProfile in userProfiles) - { - userProfile.SmsNumber = null; - jobStatesToInsert.Add(CreateUserJobState(userProfile.HdId, this.options.JobName, processedDateTime)); - preferencesToInsert.Add(CreateUserPreference(userProfile.HdId, "showSmsRemoved", "true")); - } - - dbContext.UserJobState.AddRange(jobStatesToInsert); - dbContext.UserPreference.AddRange(preferencesToInsert); - - await dbContext.SaveChangesAsync(ct); - await transaction.CommitAsync(ct); - } - - stopwatch.Stop(); - - logger.LogInformation( - "Job {JobName} cleared SMS numbers for {Count} profiles in {ElapsedMs} ms", - this.options.JobName, - userProfiles.Count, - stopwatch.ElapsedMilliseconds); - } - catch (Exception ex) - { - stopwatch.Stop(); - throw new InvalidOperationException( - $"Job {this.options.JobName} run failed after {stopwatch.ElapsedMilliseconds} ms.", - ex); - } - } - - /// - /// Retrieves the next batch of user profiles with SmsNumber set and LastLoginDateTime before the configured cutoff. - /// - /// The UTC cutoff date used to filter users by LastLoginDateTime. - /// Logical job identifier (must match configured job name). - /// Maximum number of users to process in a single batch. - /// The cancellation token. - /// A list of user profiles to process in the next batch. - private async Task> GetNextUserProfilesBeforeCutoffAsync( - DateTime lastLoginCutoffDate, - string jobName, - int batchSize, - CancellationToken ct) - { - DateTime cutoffDate = lastLoginCutoffDate.ToUniversalTime(); - return await dbContext.UserProfile - .Where(x => !string.IsNullOrWhiteSpace(x.SmsNumber)) - .Where(x => x.LastLoginDateTime < cutoffDate) - .Where(x => - !dbContext.UserJobState.Any(js => - js.Hdid == x.HdId && - js.JobName == jobName)) - .OrderByDescending(x => x.LastLoginDateTime) - .ThenBy(x => x.HdId) - .Take(batchSize) - .ToListAsync(ct); - } - } -} diff --git a/Apps/JobScheduler/src/Jobs/NotificationBackfillJob.cs b/Apps/JobScheduler/src/Jobs/NotificationBackfillJob.cs deleted file mode 100644 index f49f53c0cc..0000000000 --- a/Apps/JobScheduler/src/Jobs/NotificationBackfillJob.cs +++ /dev/null @@ -1,577 +0,0 @@ -// ------------------------------------------------------------------------- -// Copyright © 2019 Province of British Columbia -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------- -namespace HealthGateway.JobScheduler.Jobs -{ - using System; - using System.Collections.Generic; - using System.Diagnostics; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; - using Hangfire; - using HealthGateway.Common.Data.Constants; - using HealthGateway.Common.Messaging; - using HealthGateway.Common.Models.Events; - using HealthGateway.Database.Context; - using HealthGateway.Database.Models; - using HealthGateway.JobScheduler.Models; - using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore.Storage; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - - /// - /// Backfills notification settings for eligible users. - /// - /// The dbContext to use. - /// The outbox store to use. - /// Hangfire background job client. - /// The logger to use. - /// Provides access to the configured clear SMS number job options. - /// The monitor used to access the current notification backfill options. - public class NotificationBackfillJob( - GatewayDbContext dbContext, - IOutboxStore outboxStore, - IBackgroundJobClient backgroundJobClient, - ILogger logger, - IOptionsMonitor clearSmsNumberOptionsMonitor, - IOptionsMonitor notificationBackfillOptionsMonitor) - { - private const int ConcurrencyTimeout = 5 * 60; // 5 Minutes - private const string ClearSmsNumberOptionsName = "ClearSmsNumber"; - private const string NotificationEmailBackfillOptionsName = "NotificationEmailBackfill"; - private const string NotificationSmsBackfillOptionsName = "NotificationSmsBackfill"; - private NotificationBackfillOptions options = null!; - - /// - /// Executes notification backfill for both email and SMS channels sequentially. - /// Email backfill runs first, followed by SMS backfill. - /// - /// Cancellation token used to stop processing gracefully. - /// A that represents the asynchronous operation. - [DisableConcurrentExecution(ConcurrencyTimeout)] - public async Task ProcessAsync(CancellationToken ct = default) - { - int emailProcessedCount = await this.ProcessChannelAsync(NotificationEmailBackfillOptionsName, ct); - - if (emailProcessedCount >= this.options.MinPreviousChannelProcessedCount && this.options.ChannelDelaySeconds > 0) - { - logger.LogInformation( - "Waiting {DelaySeconds} seconds before SMS backfill because Email processed {ProcessedCount} profiles", - this.options.ChannelDelaySeconds, - emailProcessedCount); - await Task.Delay(TimeSpan.FromSeconds(this.options.ChannelDelaySeconds), ct); - } - - await this.ProcessChannelAsync(NotificationSmsBackfillOptionsName, ct); - } - - /// - /// Determines the notification targets for a given notification type and channel. - /// Returns a target only when the channel is enabled and the user has a valid - /// contact value; otherwise returns an empty collection. - /// - /// The notification type being processed. - /// Indicates whether the channel is enabled. - /// - /// Indicates whether the user has a valid value for the channel (e.g., email or SMS). - /// - /// - /// A collection containing the resolved , or empty if not applicable. - /// - /// - /// Thrown when the notification type is not supported. - /// - private static IReadOnlyCollection GetTargets( - ProfileNotificationType type, - bool enabled, - bool hasChannelValue) - { - if (!enabled || !hasChannelValue) - { - return []; - } - - NotificationTargets target = type switch - { - ProfileNotificationType.BcCancerScreening => NotificationTargets.BcCancer, - _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported notification type"), - }; - - return [target]; - } - - /// - /// Processes notification backfill for a single channel configuration (email or SMS). - /// Loads configuration by name, retrieves eligible user profiles, and performs - /// insert/update operations along with event generation in a transactional batch. - /// - /// - /// The configuration key used to bind - /// (e.g., NotificationEmailBackfill or NotificationSmsBackfill). - /// - /// Cancellation token used to stop processing gracefully. - /// - /// The number of user profiles processed in the current batch. - /// - private async Task ProcessChannelAsync(string optionsName, CancellationToken ct = default) - { - this.options = notificationBackfillOptionsMonitor.Get(optionsName); - - // Skip execution if the job is disabled via configuration - if (!this.options.Enabled) - { - logger.LogInformation("Job {JobName} is disabled", this.options.JobName); - return 0; - } - - this.ValidateCutoffMatchesNotificationBackfill(); - - logger.LogInformation("Job {JobName} started", this.options.JobName); - Stopwatch stopwatch = Stopwatch.StartNew(); - - if (!Enum.TryParse(this.options.NotificationType, true, out ProfileNotificationType notificationType)) - { - throw new InvalidOperationException( - $"Invalid notification type '{this.options.NotificationType}' " + - $"for job {this.options.JobName}."); - } - - try - { - // Gets user profile that require notification settings to be created/updated - List userProfilesToBackfill = await this.GetNextUserProfilesAsync(notificationType, ct); - - await this.UpsertUserProfileNotificationSettingsAsync(notificationType, userProfilesToBackfill, ct); - - stopwatch.Stop(); - - int count = userProfilesToBackfill.Count; - - logger.LogInformation( - "Job {JobName} finished in {ElapsedMs} ms for {Count} profiles", - this.options.JobName, - stopwatch.ElapsedMilliseconds, - count); - - return count; - } - catch (Exception ex) - { - stopwatch.Stop(); - throw new InvalidOperationException( - $"Job {this.options.JobName} run failed after {stopwatch.ElapsedMilliseconds} ms.", - ex); - } - } - - /// - /// Creates a notification event message for a user profile based on configured channel preferences. - /// Email and SMS targets are only included when corresponding configuration values are provided. - /// Targets are further filtered based on whether the user has valid contact information. - /// - /// The notification type being processed. - /// The user profile for which the event is created. - /// A containing the notification event. - private MessageEnvelope CreateMessageEnvelope( - ProfileNotificationType notificationType, - UserProfile userProfile) - { - // Build email targets only when EmailEnabled is configured - IReadOnlyCollection emailNotificationTargets = - this.options.EmailEnabled.HasValue - ? GetTargets( - notificationType, - this.options.EmailEnabled.Value, - !string.IsNullOrEmpty(userProfile.Email)) - : []; - - // Build SMS targets only when SmsEnabled is configured - IReadOnlyCollection smsNotificationTargets = - this.options.SmsEnabled.HasValue - ? GetTargets( - notificationType, - this.options.SmsEnabled.Value, - !string.IsNullOrEmpty(userProfile.SmsNumber)) - : []; - - // Create event wrapped in message envelope for outbox processing - return new MessageEnvelope( - new NotificationChannelPreferencesChangedEvent( - userProfile.HdId, - userProfile.SmsNumber, - smsNotificationTargets, - userProfile.Email, - emailNotificationTargets), - userProfile.HdId); - } - - /// - /// Creates a new for a user profile, - /// initializing channel values from configured defaults. - /// - /// The notification type being created. - /// The user profile HDID. - /// A new instance. - private UserProfileNotificationSetting CreateNotificationSetting( - ProfileNotificationType notificationType, - string hdid) - { - return new UserProfileNotificationSetting - { - Hdid = hdid, - NotificationType = notificationType, - EmailEnabled = this.options.EmailEnabled, - SmsEnabled = this.options.SmsEnabled, - }; - } - - /// - /// Creates a record to track that a user profile - /// has been processed by the current job run. - /// - /// The user profile HDID. - /// The UTC date/time the user was processed. - /// - /// JobName is used as a logical identifier for the job in UserJobState tracking. - /// It must remain consistent for a given backfill job to prevent reprocessing. - /// Changing JobName will cause all users to be treated as unprocessed. - /// - /// A new instance. - private UserJobState CreateUserJobState(string hdid, DateTime processedDateTime) - { - return new UserJobState - { - JobName = this.options.JobName, - Hdid = hdid, - ProcessedDateTime = processedDateTime, - }; - } - - /// - /// Validates that the cutoff date configured for the ClearSmsNumber job - /// matches the cutoff date configured for the NotificationSmsBackfill job. - /// - /// - /// These two jobs are designed to operate on mutually exclusive sets of users - /// based on LastLoginDateTime: - /// - ClearSmsNumber processes users with LastLoginDateTime less than the cutoff - /// - NotificationSmsBackfill processes users with LastLoginDateTime on or after the cutoff - /// If the cutoff dates differ, the partitioning becomes invalid and can result in: - /// - Overlap: the same user being processed by both jobs - /// - Gaps: some users not being processed by either job - /// This validation ensures configuration consistency and fails fast if the cutoff - /// dates are misaligned. - /// - /// - /// Thrown when the cutoff dates for the two jobs do not match. - /// - private void ValidateCutoffMatchesNotificationBackfill() - { - if (!this.options.UseSmsChannel) - { - return; - } - - ClearSmsNumberOptions clearSmsNumberOptions = clearSmsNumberOptionsMonitor.Get(ClearSmsNumberOptionsName); - - if (!this.options.LastLoginAfterDate.HasValue) - { - throw new InvalidOperationException( - $"{this.options.JobName}: LastLoginAfterDate must be configured."); - } - - if (!clearSmsNumberOptions.LastLoginAfterDate.HasValue) - { - throw new InvalidOperationException( - $"{clearSmsNumberOptions.JobName}: LastLoginAfterDate must be configured."); - } - - DateTime notificationBackfillCutoff = this.options.LastLoginAfterDate.Value.ToUniversalTime(); - DateTime clearSmsNumberCutoff = clearSmsNumberOptions.LastLoginAfterDate.Value.ToUniversalTime(); - - if (notificationBackfillCutoff != clearSmsNumberCutoff) - { - throw new InvalidOperationException( - $"Cutoff mismatch: {this.options.JobName} ({notificationBackfillCutoff:o}) " + - $"!= {clearSmsNumberOptions.JobName} ({clearSmsNumberCutoff:o})."); - } - } - - /// - /// Retrieves existing notification settings for the specified HDIDs and notification type, - /// and returns them as a dictionary keyed by HDID for efficient lookup. - /// - /// The notification type being processed. - /// The list of user profile HDIDs to retrieve settings for. - /// The cancellation token. - /// - /// A dictionary of keyed by HDID. - /// - private async Task> GetExistingSettingsByHdidAsync( - ProfileNotificationType notificationType, - List hdids, - CancellationToken ct) - { - List existingSettings = await dbContext.UserProfileNotificationSetting - .Where(ns => - hdids.Contains(ns.Hdid) && - ns.NotificationType == notificationType) - .ToListAsync(ct); - - return existingSettings.ToDictionary(x => x.Hdid); - } - - /// - /// Retrieves the next batch of user profiles eligible for notification backfill. - /// Selection criteria: - /// - Channel is determined by UseSmsChannel: - /// - When false: selects users with a non-empty Email - /// - When true: selects users with a non-empty SmsNumber - /// - No existing notification setting for the specified type, or only null values for the selected channel: - /// - When false: EmailEnabled is null - /// - When true: SmsEnabled is null - /// - User has not already been processed for this job (no matching UserJobState for Hdid and JobName) - /// Results are ordered by most recent login (descending) with HDID as a tie-breaker - /// to ensure deterministic batching. - /// - /// The notification type being processed. - /// The cancellation token. - /// A list of user profiles to process in the next batch. - private async Task> GetNextUserProfilesAsync(ProfileNotificationType notificationType, CancellationToken ct) - { - IQueryable query = dbContext.UserProfile - .AsNoTracking(); - - query = query - .Where(x => - this.options.UseSmsChannel - ? !string.IsNullOrWhiteSpace(x.SmsNumber) - : !string.IsNullOrWhiteSpace(x.Email)) - .Where(x => - dbContext.UserProfileNotificationSetting - .Where(ns => - ns.Hdid == x.HdId && - ns.NotificationType == notificationType) - .All(ns => - this.options.UseSmsChannel - ? ns.SmsEnabled == null - : ns.EmailEnabled == null)) - .Where(x => - !dbContext.UserJobState.Any(js => - js.Hdid == x.HdId && - js.JobName == this.options.JobName)); - - if (this.options.UseSmsChannel) - { - // LastLoginAfterDate is validated by ValidateCutoffMatchesNotificationBackfill(). - DateTime cutoffDate = this.options.LastLoginAfterDate!.Value.ToUniversalTime(); - query = query - .Where(x => x.LastLoginDateTime >= cutoffDate); - } - - query = query - .OrderByDescending(x => x.LastLoginDateTime) - .ThenBy(x => x.HdId); - - return await query - .Take(this.options.BatchSize) - .ToListAsync(ct); - } - - /// - /// Persists batch results to the database, including new notification settings, - /// job state records, and outbound events via the transactional outbox. - /// All operations are expected to run within an existing transaction. - /// - /// The batch result containing inserts, job states, and events. - /// The cancellation token. - private async Task PersistBatchAsync(NotificationBackfillBatchResult result, CancellationToken ct) - { - // Insert new notification settings (if any) - if (result.RowsToInsert.Count != 0) - { - await dbContext.UserProfileNotificationSetting.AddRangeAsync(result.RowsToInsert, ct); - } - - // Insert job state records for tracking processed users - await dbContext.UserJobState.AddRangeAsync(result.UserJobStates, ct); - - // Persist events to the outbox for later dispatch (if any) - if (result.Events.Count != 0) - { - await outboxStore.StoreAsync(result.Events, false, ct); - } - } - - /// - /// Processes a batch of user profiles to determine notification setting inserts, - /// updates (only for null values), job state records, and outbound events. - /// For each user: - /// - Updates existing settings only when values are null (does not override user choices) - /// - Creates a new setting when none exists - /// - Generates a corresponding job state and notification event. - /// - /// The notification type being processed. - /// The batch of user profiles to process. - /// - /// Existing notification settings keyed by HDID for efficient lookup. - /// - /// - /// A containing inserts, updates, - /// job states, and events for the batch. - /// - private NotificationBackfillBatchResult ProcessBatch( - ProfileNotificationType notificationType, - List userProfilesToBackfill, - Dictionary existingSettingsByHdid) - { - DateTime processedDateTime = DateTime.UtcNow; - - List notificationSettingsToInsert = []; - List jobStatesToInsert = []; - List notificationEvents = []; - int updatedCount = 0; - - foreach (UserProfile userProfile in userProfilesToBackfill) - { - string hdid = userProfile.HdId; - - if (existingSettingsByHdid.TryGetValue(hdid, out UserProfileNotificationSetting? existing)) - { - // Update existing setting only if eligible (e.g., null values); track if changed - if (this.UpdateExistingSetting(existing)) - { - updatedCount++; - } - } - else - { - // Create new setting when none exists for this user - notificationSettingsToInsert.Add(this.CreateNotificationSetting(notificationType, hdid)); - } - - jobStatesToInsert.Add(this.CreateUserJobState(hdid, processedDateTime)); - notificationEvents.Add(this.CreateMessageEnvelope(notificationType, userProfile)); - } - - return new NotificationBackfillBatchResult( - notificationSettingsToInsert, - jobStatesToInsert, - notificationEvents, - updatedCount); - } - - /// - /// Updates an existing notification setting by populating null channel values - /// (EmailEnabled and/or SmsEnabled) using configured defaults. - /// Does not override explicitly set user preferences. - /// - /// The existing notification setting to evaluate and update. - /// - /// True if at least one field was updated; otherwise, false. - /// - private bool UpdateExistingSetting(UserProfileNotificationSetting existing) - { - bool updated = false; - - if (existing.EmailEnabled == null && this.options.EmailEnabled.HasValue) - { - existing.EmailEnabled = this.options.EmailEnabled.Value; - updated = true; - } - - if (existing.SmsEnabled == null && this.options.SmsEnabled.HasValue) - { - existing.SmsEnabled = this.options.SmsEnabled.Value; - updated = true; - } - - return updated; - } - - /// - /// Upserts notification settings for a batch of user profiles. - /// For each user: - /// - Updates existing settings only when channel values are null (does not override user choices) - /// - Creates new settings when none exist - /// All changes are persisted within a single transaction, and any generated events - /// are dispatched after a successful commit. - /// - /// The notification type being processed. - /// The batch of user profiles to process. - /// The cancellation token. - private async Task UpsertUserProfileNotificationSettingsAsync( - ProfileNotificationType notificationType, - List userProfilesToBackfill, - CancellationToken ct) - { - if (userProfilesToBackfill.Count == 0) - { - logger.LogInformation("Job {JobName} no records to process", this.options.JobName); - return; - } - - // Begin transaction to ensure atomic batch processing - await using IDbContextTransaction transaction = - await dbContext.Database.BeginTransactionAsync(ct); - - // Extract HDIDs for lookup - List hdids = [.. userProfilesToBackfill.Select(x => x.HdId)]; - - // Retrieve existing settings keyed by HDID for efficient access - Dictionary existingSettingsByHdid = - await this.GetExistingSettingsByHdidAsync(notificationType, hdids, ct); - - // Determine inserts, updates, and events for this batch - NotificationBackfillBatchResult result = this.ProcessBatch( - notificationType, - userProfilesToBackfill, - existingSettingsByHdid); - - // Persist new settings and enqueue outbox events - await this.PersistBatchAsync(result, ct); - - // Save all changes within the transaction - await dbContext.SaveChangesAsync(ct); - - // Commit transaction after successful persistence - await transaction.CommitAsync(ct); - - // Dispatch events after commit (outbox pattern) - if (result.Events.Count != 0) - { - logger.LogDebug("Dispatching events after commit"); - backgroundJobClient.Enqueue(store => - store.DispatchOutboxItemsAsync(ct)); - } - - // Log batch summary - logger.LogInformation( - "Job {JobName} processed {TotalProfiles} profiles. Inserted {InsertCount}. Updated {UpdateCount}", - this.options.JobName, - userProfilesToBackfill.Count, - result.RowsToInsert.Count, - result.UpdatedCount); - } - - private sealed record NotificationBackfillBatchResult( - List RowsToInsert, - List UserJobStates, - List Events, - int UpdatedCount); - } -} diff --git a/Apps/JobScheduler/src/Models/BatchJobOptionsBase.cs b/Apps/JobScheduler/src/Models/BatchJobOptionsBase.cs deleted file mode 100644 index 113fefda70..0000000000 --- a/Apps/JobScheduler/src/Models/BatchJobOptionsBase.cs +++ /dev/null @@ -1,48 +0,0 @@ -// ------------------------------------------------------------------------- -// Copyright © 2019 Province of British Columbia -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------- -namespace HealthGateway.JobScheduler.Models -{ - using System; - - /// - /// Base configuration options for batch jobs that process user data in chunks. - /// Provides common settings for enabling/disabling execution, identifying the job, - /// controlling batch size, and optionally filtering records by last login date. - /// - public class BatchJobOptionsBase - { - /// - /// Gets or sets a value indicating whether the job is enabled. - /// - public bool Enabled { get; set; } = true; - - /// - /// Gets or sets the short logical job name. - /// - public string JobName { get; set; } = string.Empty; - - /// - /// Gets or sets the batch size. - /// - public int BatchSize { get; set; } = 1000; - - /// - /// Gets or sets the minimum last login date/time required for processing. - /// When null, no last login cutoff is applied. - /// - public DateTime? LastLoginAfterDate { get; set; } - } -} diff --git a/Apps/JobScheduler/src/Models/ClearSmsNumberOptions.cs b/Apps/JobScheduler/src/Models/ClearSmsNumberOptions.cs deleted file mode 100644 index 4e7c720bec..0000000000 --- a/Apps/JobScheduler/src/Models/ClearSmsNumberOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ------------------------------------------------------------------------- -// Copyright © 2019 Province of British Columbia -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------- -namespace HealthGateway.JobScheduler.Models -{ - /// - /// Configuration options for the clear sms number job. - /// - public class ClearSmsNumberOptions : BatchJobOptionsBase - { - } -} diff --git a/Apps/JobScheduler/src/Models/NotificationBackfillOptions.cs b/Apps/JobScheduler/src/Models/NotificationBackfillOptions.cs deleted file mode 100644 index 9ba1cd0f7b..0000000000 --- a/Apps/JobScheduler/src/Models/NotificationBackfillOptions.cs +++ /dev/null @@ -1,56 +0,0 @@ -// ------------------------------------------------------------------------- -// Copyright © 2019 Province of British Columbia -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------- -namespace HealthGateway.JobScheduler.Models -{ - /// - /// Configuration options for the notification backfill job. - /// - public class NotificationBackfillOptions : BatchJobOptionsBase - { - /// - /// Gets or sets the notification type to backfill. - /// - public string NotificationType { get; set; } = string.Empty; - - /// - /// Gets or sets a value indicating whether to target users with a valid SMS number - /// instead of valid email when selecting user profiles for processing. - /// - public bool UseSmsChannel { get; set; } - - /// - /// Gets or sets a value indicating whether email notifications are enabled. - /// A value of null indicates the setting has not been set or processed. - /// - public bool? EmailEnabled { get; set; } - - /// - /// Gets or sets a value indicating whether SMS notifications are enabled. - /// A value of null indicates the setting has not been set or processed. - /// - public bool? SmsEnabled { get; set; } - - /// - /// Gets or sets the delay in seconds before this channel is processed. - /// - public int ChannelDelaySeconds { get; set; } - - /// - /// Gets or sets the minimum number of previously processed records required before applying the delay. - /// - public int MinPreviousChannelProcessedCount { get; set; } = 1000; - } -} diff --git a/Apps/JobScheduler/src/Startup.cs b/Apps/JobScheduler/src/Startup.cs index b62f2f73f0..a6032da73e 100644 --- a/Apps/JobScheduler/src/Startup.cs +++ b/Apps/JobScheduler/src/Startup.cs @@ -34,7 +34,6 @@ namespace HealthGateway.JobScheduler using HealthGateway.JobScheduler.AspNetConfiguration.Modules; using HealthGateway.JobScheduler.Jobs; using HealthGateway.JobScheduler.Listeners; - using HealthGateway.JobScheduler.Models; using HealthGateway.JobScheduler.Utils; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Builder; @@ -96,17 +95,6 @@ public void ConfigureServices(IServiceCollection services) }); }); - // Bind configuration - services.Configure( - "NotificationEmailBackfill", - this.startupConfig.Configuration.GetSection("NotificationBackfill:Email:Options")); - services.Configure( - "NotificationSmsBackfill", - this.startupConfig.Configuration.GetSection("NotificationBackfill:Sms:Options")); - services.Configure( - "ClearSmsNumber", - this.startupConfig.Configuration.GetSection("ClearSmsNumber:Options")); - // Add Delegates and services for jobs services.AddTransient(); services.AddTransient(); diff --git a/Apps/JobScheduler/src/appsettings.json b/Apps/JobScheduler/src/appsettings.json index a10f053857..b0bf25d5d1 100644 --- a/Apps/JobScheduler/src/appsettings.json +++ b/Apps/JobScheduler/src/appsettings.json @@ -196,47 +196,6 @@ "UserCount": 100000, "BetaFeature": "Salesforce" }, - "NotificationBackfill": { - "Id": "NotificationBackfill-BcCancerScreening", - "Schedule": "*/3 * * * *", - "Immediate": false, - "Delay": 60, - "Email": { - "Options": { - "JobName": "NotificationBackfill-BcCancerScreening-Email", - "Enabled": true, - "NotificationType": "BcCancerScreening", - "BatchSize": 5000, - "UseSmsChannel": false, - "EmailEnabled": true - } - }, - "Sms": { - "Options": { - "JobName": "NotificationBackfill-BcCancerScreening-Sms", - "Enabled": true, - "NotificationType": "BcCancerScreening", - "BatchSize": 5000, - "LastLoginAfterDate": "2023-01-01T00:00:00Z", - "UseSmsChannel": true, - "SmsEnabled": true, - "ChannelDelaySeconds": 60, - "MinPreviousChannelProcessedCount": 1000 - } - } - }, - "ClearSmsNumber": { - "Id": "ClearSmsNumber", - "Schedule": "* * * * *", - "Immediate": false, - "Delay": 60, - "Options": { - "JobName": "ClearSmsNumber", - "Enabled": true, - "BatchSize": 5000, - "LastLoginAfterDate": "2023-01-01T00:00:00Z" - } - }, "PharmacyAssessmentFile": { "Url": "https://raw.githubusercontent.com/bcgov/pharmacy-assessment/main/Pharmacy%20Assessment%20PIN.csv", "TargetFolder": "Resources", diff --git a/Apps/WebClient/src/ClientApp/src/components/private/profile/UserProfileNotificationsComponent.vue b/Apps/WebClient/src/ClientApp/src/components/private/profile/UserProfileNotificationsComponent.vue index 4f7623c117..cd839f3b6e 100644 --- a/Apps/WebClient/src/ClientApp/src/components/private/profile/UserProfileNotificationsComponent.vue +++ b/Apps/WebClient/src/ClientApp/src/components/private/profile/UserProfileNotificationsComponent.vue @@ -1,5 +1,5 @@