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 @@