From b16bcd45e3d68aef5c40de0940e355b61ac7dda9 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 08:45:05 -0700 Subject: [PATCH 01/32] feature/AB#34582-TightenApi --- .../ScheduledNotificationHelperTests.cs | 29 +++ .../FinancialNotificationSummaryWorker.cs | 5 +- .../ReconciliationProducer.cs | 5 +- .../CasPaymentRequestCoordinator.cs | 148 ++++++++++---- .../CasPaymentRequestCoordinatorTests.cs | 187 ++++++++++++++++++ .../Unity.SharedKernel.csproj | 1 + ...BackgroundExecutionAuthorizationHandler.cs | 27 +++ .../Utils/BackgroundJobContext.cs | 12 +- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 30 +++ .../Themes/UX2/Layouts/Account.cshtml | 7 + .../Themes/UX2/Layouts/Application.cshtml | 7 + .../Themes/UX2/Layouts/Empty.cshtml | 7 + .../Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj | 2 + .../ApplicantTenantMapReconciliationWorker.cs | 5 +- .../FiscalYearEndRolloverWorker.cs | 5 +- .../DateBasedScheduledNotificationJob.cs | 5 +- .../GrantManagerApplicationModule.cs | 6 +- .../GrantsPortalMessageCleanupWorker.cs | 6 +- .../ApplicantUpdateEmailRecoveryWorker.cs | 6 +- .../DataHealthCheckWorker.cs | 5 +- .../History/AuditLogCleanupWorker.cs | 6 +- .../History/HistoryAppService.cs | 3 + .../BackgroundWorkers/IntakeSyncWorker.cs | 5 +- .../Integrations/Css/CssApiService.cs | 12 +- .../Endpoints/EndpointManagementAppService.cs | 2 + .../Messaging/InboxWorkerBase.cs | 6 +- .../Messaging/OutboxWorkerBase.cs | 6 +- .../AutomatedNotificationAppService.cs | 2 + ...120000_AddQuartzMisfireOriginalFireTime.cs | 29 +++ .../FormNotificationsApiController.cs | 108 +++++----- .../GrantManagerWebModule.cs | 2 +- .../Pages/_ViewStart.cshtml | 6 + .../ApplicantAddressesController.cs | 3 + .../ApplicantContactsController.cs | 3 + .../ApplicantOrganizationInfoController.cs | 3 + 35 files changed, 551 insertions(+), 150 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs index 8a98d4bb5b..35c35d916d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs @@ -63,6 +63,35 @@ public void BuildTokenValues_ForApplicationTemplate_PreservesApplicationValues() values["unity_application_id"].ShouldBe("UNITY-456"); } + [Fact] + public void BuildTokenValues_ForDeclinedApplication_MapsDisplayDeclineRationale() + { + var application = new Application + { + Applicant = new Applicant { ApplicantName = "VelangTest2", OrgName = "DEC DESIGN" }, + ApplicationStatus = new ApplicationStatus { InternalStatus = "Declined" }, + ApplicationForm = new ApplicationForm { Category = "Community" }, + ReferenceNo = "SUB-123", + DeclineRational = "INELIGIBLE_PROJECT", + ProjectName = "Test project", + RequestedAmount = 1000m, + UnityApplicationId = "UNITY-123" + }; + var applicantAgent = new ApplicantAgent { Name = "Test Contact", Title = "Director" }; + + var values = ScheduledNotificationHelper.BuildTokenValues(application, applicantAgent); + + values["applicant_name"].ShouldBe("VelangTest2"); + values["organization_name"].ShouldBe("DEC DESIGN"); + values["decline_rationale"].ShouldBe("Ineligible Project"); + values["status"].ShouldBe("Declined"); + values["contact_full_name"].ShouldBe("Test Contact"); + values["contact_title"].ShouldBe("Director"); + values["category"].ShouldBe("Community"); + values["submission_number"].ShouldBe("SUB-123"); + values["unity_application_id"].ShouldBe("UNITY-123"); + } + [Fact] public void BuildTokenValues_ForApplicantTemplate_RendersMissingOptionalValuesAsEmpty() { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs index 84e5e516d0..e412444396 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs @@ -2,7 +2,6 @@ using System; using System.Threading.Tasks; using Unity.Payments.PaymentRequests.Notifications; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; using Microsoft.Extensions.Logging; using Unity.Payments.PaymentRequests.BackgroundJobWorkers; @@ -13,7 +12,7 @@ namespace Unity.Payments.PaymentRequests; [DisallowConcurrentExecution] -public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase +public class FinancialNotificationSummaryWorker : UnityQuartzBackgroundWorkerBase { private readonly FinancialSummaryNotifier _financialSummaryNotifier; private readonly IEnumerable _strategies; @@ -45,7 +44,7 @@ public FinancialNotificationSummaryWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("FinancialNotificationSummary Execute"); await _financialSummaryNotifier.NotifyFailedPayments(_strategies); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs index 73c187eb55..923839903d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; using Unity.Modules.Shared.Utils; using Unity.Payments.Settings; @@ -9,7 +8,7 @@ namespace Unity.Payments.PaymentRequests; [DisallowConcurrentExecution] -public class ReconciliationProducer : QuartzBackgroundWorkerBase +public class ReconciliationProducer : UnityQuartzBackgroundWorkerBase { private readonly CasPaymentRequestCoordinator _casPaymentRequestCoordinator; @@ -42,7 +41,7 @@ CasPaymentRequestCoordinator casPaymentRequestCoordinator } } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { await _casPaymentRequestCoordinator.AddPaymentRequestsToReconciliationQueue(); await Task.CompletedTask; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index 74f126b99a..a16fc69f34 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -13,6 +13,7 @@ using Unity.Payments.Events; using Unity.Payments.RabbitMQ.QueueMessages; using Unity.Notifications.Integrations.RabbitMQ; +using Volo.Abp.Data; using Volo.Abp.EventBus.Local; namespace Unity.Payments.PaymentRequests @@ -129,55 +130,134 @@ public async Task AddPaymentRequestsToReconciliationQueue() return null; } - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + const int maxConcurrencyAttempts = 3; + CasPaymentValues? originalCasValues = null; - var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); - var previousPaymentStatus = paymentRequest.PaymentStatus; - var previousStatus = paymentRequest.Status; + for (var attempt = 1; ; attempt++) + { + try + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); - UpdatePaymentRequestFromCasResult(paymentRequest, result); + var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + var previousPaymentStatus = paymentRequest.PaymentStatus; + var previousStatus = paymentRequest.Status; + originalCasValues ??= GetCurrentCasValues(paymentRequest); - await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); + var casValues = GetCasValues(paymentRequest, result); + ApplyCasValues(paymentRequest, casValues); - // CompleteAsync commits the transaction and calls SaveChangesAsync, - // which triggers AbpDbContext to collect entity changes into the active audit log. - // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. - await uow.CompleteAsync(); + await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); - if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status) - { - await localEventBus.PublishAsync(new PaymentStatusChangedEvent + // CompleteAsync commits the transaction and calls SaveChangesAsync, + // which triggers AbpDbContext to collect entity changes into the active audit log. + // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. + await uow.CompleteAsync(); + + if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status) + { + await localEventBus.PublishAsync(new PaymentStatusChangedEvent + { + PaymentRequestId = paymentRequest.Id, + ApplicationId = paymentRequest.CorrelationId, + Status = paymentRequest.Status, + CasPaymentStatus = paymentRequest.PaymentStatus, + TenantId = TenantId + }); + } + + return paymentRequest; + } + catch (AbpDbConcurrencyException ex) when (attempt < maxConcurrencyAttempts) { - PaymentRequestId = paymentRequest.Id, - ApplicationId = paymentRequest.CorrelationId, - Status = paymentRequest.Status, - CasPaymentStatus = paymentRequest.PaymentStatus, - TenantId = TenantId - }); - } + using var inspectionUow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var currentPaymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + var currentCasValues = GetCurrentCasValues(currentPaymentRequest); + var desiredCasValues = GetCasValues(currentPaymentRequest, result); + + if (currentCasValues == desiredCasValues) + { + return currentPaymentRequest; + } - return paymentRequest; + if (currentCasValues != originalCasValues) + { + Logger.LogWarning( + ex, + "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.", + PaymentRequestId); + return currentPaymentRequest; + } + + Logger.LogWarning( + ex, + "Concurrency conflict updating payment request {PaymentRequestId} from CAS; retrying attempt {Attempt} of {MaxAttempts}.", + PaymentRequestId, + attempt + 1, + maxConcurrencyAttempts); + } + catch (AbpDbConcurrencyException) + { + throw; + } + } } - private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result) + private static CasPaymentValues GetCasValues(PaymentRequest paymentRequest, CasPaymentSearchResult result) { - // Handle duplicate NotFound status by appending "2" - if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && - result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + var invoiceStatus = result.InvoiceStatus ?? string.Empty; + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound + "2" && + invoiceStatus == CasPaymentRequestStatus.NotFound) + { + invoiceStatus = paymentRequest.InvoiceStatus; + } + + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && + invoiceStatus == CasPaymentRequestStatus.NotFound) { - result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2"; + invoiceStatus = CasPaymentRequestStatus.NotFound + "2"; } - paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? ""); - paymentRequest.SetPaymentStatus(result.PaymentStatus ?? ""); - paymentRequest.SetPaymentDate(result.PaymentDate ?? ""); - paymentRequest.SetPaymentNumber(result.PaymentNumber ?? ""); - - if (result.InvoiceStatus != null) + return new CasPaymentValues( + invoiceStatus, + result.PaymentStatus ?? string.Empty, + result.PaymentNumber ?? string.Empty, + result.PaymentDate ?? string.Empty, + result.InvoiceStatus != null ? (int)System.Net.HttpStatusCode.OK : null, + result.InvoiceStatus != null ? "SUCCEEDED" : null); + } + + private static CasPaymentValues GetCurrentCasValues(PaymentRequest paymentRequest) + { + return new CasPaymentValues( + paymentRequest.InvoiceStatus ?? string.Empty, + paymentRequest.PaymentStatus ?? string.Empty, + paymentRequest.PaymentNumber ?? string.Empty, + paymentRequest.PaymentDate ?? string.Empty, + paymentRequest.CasHttpStatusCode, + paymentRequest.CasResponse); + } + + private static void ApplyCasValues(PaymentRequest paymentRequest, CasPaymentValues casValues) + { + paymentRequest.SetInvoiceStatus(casValues.InvoiceStatus); + paymentRequest.SetPaymentStatus(casValues.PaymentStatus); + paymentRequest.SetPaymentDate(casValues.PaymentDate); + paymentRequest.SetPaymentNumber(casValues.PaymentNumber); + + if (casValues.CasHttpStatusCode.HasValue) { - paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); - paymentRequest.SetCasResponse("SUCCEEDED"); + paymentRequest.SetCasHttpStatusCode(casValues.CasHttpStatusCode.Value); + paymentRequest.SetCasResponse(casValues.CasResponse ?? string.Empty); } } + + private readonly record struct CasPaymentValues( + string InvoiceStatus, + string PaymentStatus, + string PaymentNumber, + string PaymentDate, + int? CasHttpStatusCode, + string? CasResponse); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs new file mode 100644 index 0000000000..f155acee09 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Microsoft.Extensions.Logging.Abstractions; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Integrations.Cas; +using Unity.Notifications.Integrations.RabbitMQ; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Application.Services; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus.Local; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.PaymentRequests; + +public class CasPaymentRequestCoordinatorTests +{ + private static readonly Guid TenantId = Guid.NewGuid(); + private static readonly Guid PaymentRequestId = Guid.NewGuid(); + + [Fact] + public async Task UpdatePaymentRequestStatus_RetriesAfterConcurrencyConflict() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(), CreatePaymentRequest()); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync() + .Returns(Task.FromException(new AbpDbConcurrencyException()), Task.CompletedTask); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result.ShouldNotBeNull(); + result!.PaymentStatus.ShouldBe("Paid"); + _ = repository.Received(2).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(2).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_ReturnsCurrentRequestWhenResultIsAlreadyApplied() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(paymentStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.PaymentStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_RejectsStaleCasResult() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(invoiceStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.InvoiceStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_ThrowsAfterMaximumConcurrencyAttempts() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns( + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest()); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns( + Task.FromException(new AbpDbConcurrencyException()), + Task.FromException(new AbpDbConcurrencyException()), + Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + await Should.ThrowAsync(() => + coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult())); + + _ = unitOfWork.Received(3).CompleteAsync(); + } + + private static CasPaymentRequestCoordinator CreateCoordinator( + IPaymentRequestRepository repository, + IUnitOfWork unitOfWork) + { + var unitOfWorkManager = Substitute.For(); + unitOfWorkManager.Begin(Arg.Any(), Arg.Any()).Returns(unitOfWork); + + var coordinator = new TestableCasPaymentRequestCoordinator( + paymentQueueService: null!, + paymentRequestsRepository: repository, + unitOfWorkManager: unitOfWorkManager, + tenantRepository: Substitute.For(), + currentTenant: Substitute.For(), + localEventBus: Substitute.For()); + + var lazyServiceProvider = Substitute.For(); + lazyServiceProvider.LazyGetService(Arg.Any>()) + .Returns(NullLogger.Instance); + typeof(ApplicationService) + .GetProperty("LazyServiceProvider", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(coordinator, lazyServiceProvider); + return coordinator; + } + + private sealed class TestableCasPaymentRequestCoordinator( + PaymentQueueService paymentQueueService, + IPaymentRequestRepository paymentRequestsRepository, + IUnitOfWorkManager unitOfWorkManager, + ITenantRepository tenantRepository, + ICurrentTenant currentTenant, + ILocalEventBus localEventBus) + : CasPaymentRequestCoordinator( + paymentQueueService, + paymentRequestsRepository, + unitOfWorkManager, + tenantRepository, + currentTenant, + localEventBus) + { } + + private static PaymentRequest CreatePaymentRequest( + string? invoiceStatus = null, + string? paymentStatus = null) + { + var paymentRequest = new PaymentRequest(PaymentRequestId, new CreatePaymentRequestDto + { + InvoiceNumber = "INV-123", + Amount = 100, + PayeeName = "Supplier", + ContractNumber = "CON-123", + SupplierNumber = "SUP-123", + SupplierName = "Supplier", + CorrelationId = Guid.NewGuid(), + SiteId = Guid.NewGuid(), + AccountCodingId = Guid.NewGuid(), + BatchName = "Batch" + }); + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + return paymentRequest; + } + + private static CasPaymentSearchResult CreateCasResult() + { + return new CasPaymentSearchResult + { + InvoiceStatus = "Paid", + PaymentStatus = "Paid", + PaymentNumber = "PAY-123", + PaymentDate = "2026-10-13" + }; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj index bc3d9950fd..fb10e454cf 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj @@ -19,6 +19,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs new file mode 100644 index 0000000000..0ffec4ba42 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Unity.Modules.Shared.Utils; + +public static class BackgroundExecutionPolicy +{ + public const string Name = "Unity.BackgroundExecution"; +} + +public sealed class BackgroundExecutionRequirement : IAuthorizationRequirement; + +public sealed class BackgroundExecutionAuthorizationHandler + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + BackgroundExecutionRequirement requirement) + { + if (BackgroundJobExecutionContext.IsActive) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs index 78ce8c6609..b450a2c219 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -21,21 +21,19 @@ public static class BackgroundJobContext /// The current principal accessor for setting user identity /// The current tenant accessor for setting tenant context /// The tenant ID to set context for - /// Optional user ID. If null, uses BackgroundJobConstants.BackgroundJobPersonId /// IDisposable that restores previous context when disposed (LIFO order) public static IDisposable Set( IAuditingManager auditingManager, ICurrentPrincipalAccessor principalAccessor, ICurrentTenant currentTenant, - Guid? tenantId, - Guid? userId = null) + Guid? tenantId) { - var effectiveUserId = userId ?? BackgroundJobConstants.BackgroundJobPersonId; + var backgroundJobPersonId = BackgroundJobConstants.BackgroundJobPersonId; var claims = new[] { - new Claim(AbpClaimTypes.UserId, effectiveUserId.ToString()), - new Claim(ClaimTypes.NameIdentifier, effectiveUserId.ToString()), // Standard claim for user ID + new Claim(AbpClaimTypes.UserId, backgroundJobPersonId.ToString()), + new Claim(ClaimTypes.NameIdentifier, backgroundJobPersonId.ToString()), // Standard claim for user ID new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName), new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), @@ -57,7 +55,7 @@ public static IDisposable Set( // Ensure the current audit log has the user ID set for entity change tracking if (auditingManager.Current != null) { - auditingManager.Current.Log.UserId = effectiveUserId; + auditingManager.Current.Log.UserId = backgroundJobPersonId; auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; auditingManager.Current.Log.TenantId = tenantId; } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs new file mode 100644 index 0000000000..398f983ae5 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Establishes the trusted execution context for every Quartz background worker. +/// +public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBase +{ + public sealed override async Task Execute(IJobExecutionContext context) + { + var auditingManager = ServiceProvider.GetRequiredService(); + var principalAccessor = ServiceProvider.GetRequiredService(); + var currentTenant = ServiceProvider.GetRequiredService(); + + using (BackgroundJobExecutionContext.Use()) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) + { + await ExecuteInBackgroundAsync(context); + } + } + + protected abstract Task ExecuteInBackgroundAsync(IJobExecutionContext context); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml index aa19019208..af732d59e4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml @@ -1,6 +1,8 @@ @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Analytics +@using System.Globalization @using Microsoft.Extensions.Localization @using Microsoft.Extensions.Options +@using Microsoft.Extensions.Hosting @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.MainNavbar @@ -18,6 +20,7 @@ @using Volo.Abp.Ui.LayoutHooks @inject IServiceProvider ServiceProvider +@inject IHostEnvironment HostEnvironment @inject IBrandingProvider BrandingProvider @inject IOptions MultiTenancyOptions @inject ICurrentTenant CurrentTenant @@ -119,5 +122,9 @@ } @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Account) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml index 60b57b1baa..80fdb56db9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml @@ -1,5 +1,7 @@ @using Unity.GrantManager.Analytics; +@using System.Globalization; @using System.Reflection; +@using Microsoft.Extensions.Hosting; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.ContentTitle; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.MainNavbar; @@ -19,6 +21,7 @@ @inject IBrandingProvider BrandingProvider @inject IPageLayout PageLayout @inject IAbpAntiForgeryManager AbpAntiForgeryManager +@inject IHostEnvironment HostEnvironment @{ Layout = null; var containerClass = "container-fluid"; @@ -158,5 +161,9 @@ @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Application) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml index 9bf7c310f0..f11f9a36e2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml @@ -1,5 +1,7 @@ @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling; +@using System.Globalization; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.PageAlerts; +@using Microsoft.Extensions.Hosting; @using Volo.Abp.AspNetCore.Mvc.UI.Components.LayoutHook; @using Volo.Abp.AspNetCore.Mvc.UI.Layout; @@ -12,6 +14,7 @@ @inject IBrandingProvider BrandingProvider @inject IPageLayout PageLayout +@inject IHostEnvironment HostEnvironment @{ Layout = null; var containerClass = ViewBag.FluidLayout? "container-fluid" : "container"; @@ -76,5 +79,9 @@ @await Component.InvokeAsync(typeof(WidgetScriptsViewComponent)) @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Empty) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj index 8e9a68da48..eeae6d41e2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj @@ -14,6 +14,8 @@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs index d5f13cb6c5..e59b872461 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs @@ -5,13 +5,12 @@ using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; namespace Unity.GrantManager.Applicants.BackgroundWorkers { [DisallowConcurrentExecution] - public class ApplicantTenantMapReconciliationWorker : QuartzBackgroundWorkerBase + public class ApplicantTenantMapReconciliationWorker : UnityQuartzBackgroundWorkerBase { private readonly IApplicantTenantMapReconciler _reconciler; private readonly ILogger _logger; @@ -87,7 +86,7 @@ public ApplicantTenantMapReconciliationWorker( /// encountered. /// The execution context for the job, providing runtime information and job-specific data. /// A task that represents the asynchronous execution of the job. - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { _logger.LogInformation("Executing ApplicantTenantMapReconciliationWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs index dc48be0ae7..7951f2525c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -15,7 +14,7 @@ namespace Unity.GrantManager.Applicants.BackgroundWorkers; [DisallowConcurrentExecution] -public class FiscalYearEndRolloverWorker : QuartzBackgroundWorkerBase +public class FiscalYearEndRolloverWorker : UnityQuartzBackgroundWorkerBase { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; @@ -71,7 +70,7 @@ public FiscalYearEndRolloverWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { _logger.LogInformation("Executing FiscalYearEndRolloverWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index be3c202bdb..fcc85970ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -14,7 +14,6 @@ using Unity.Notifications.Events; using Unity.Notifications.Settings; using Unity.Notifications.Templates; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; @@ -33,7 +32,7 @@ namespace Unity.GrantManager.Events /// ContractExecutionDate, FiscalYearEnd) have passed and sends emails accordingly. /// [DisallowConcurrentExecution] - public class DateBasedScheduledNotificationJob : QuartzBackgroundWorkerBase, ITransientDependency + public class DateBasedScheduledNotificationJob : UnityQuartzBackgroundWorkerBase, ITransientDependency { private readonly IRepository _scheduledNotificationRepository; private readonly IApplicationRepository _applicationRepository; @@ -128,7 +127,7 @@ public DateBasedScheduledNotificationJob( } } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 21da7aa288..b4ceab4826 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -40,9 +40,9 @@ using Unity.GrantManager.Infrastructure; using Medallion.Threading; using Unity.GrantManager.Locks; -using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; using Unity.GrantManager.Integrations.Chefs; using Unity.Modules.Shared.Http; +using Unity.Modules.Shared.Utils; using Unity.GrantManager.Integrations.Geocoder; using Unity.GrantManager.GrantsPortal; using Unity.GrantManager.GrantsPortal.Configuration; @@ -153,6 +153,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) }); context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddAuthorizationBuilder() + .AddPolicy(BackgroundExecutionPolicy.Name, policy => + policy.Requirements.Add(new BackgroundExecutionRequirement())); context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs index e1cd8a10f9..e8a9c6531b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs @@ -6,7 +6,7 @@ using Quartz; using Unity.GrantManager.GrantsPortal.Configuration; using Unity.GrantManager.Messaging; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal; @@ -16,7 +16,7 @@ namespace Unity.GrantManager.GrantsPortal; /// Runs against the central host database. /// [DisallowConcurrentExecution] -public class GrantsPortalMessageCleanupWorker : QuartzBackgroundWorkerBase +public class GrantsPortalMessageCleanupWorker : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; private readonly int _retentionDays; @@ -43,7 +43,7 @@ public GrantsPortalMessageCleanupWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogDebug("GrantsPortalMessageCleanupWorker executing (retention={RetentionDays} days)...", _retentionDays); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs index 058f75d065..8e94efc57b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs @@ -7,7 +7,7 @@ using Quartz; using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Features; using Volo.Abp.Linq; using Volo.Abp.MultiTenancy; @@ -21,7 +21,7 @@ namespace Unity.GrantManager.GrantsPortal.Notifications; /// Recovers the commit-to-queue gap. Delivery failures are retried by the existing EmailConsumer. /// [DisallowConcurrentExecution] -public class ApplicantUpdateEmailRecoveryWorker : QuartzBackgroundWorkerBase +public class ApplicantUpdateEmailRecoveryWorker : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -35,7 +35,7 @@ public ApplicantUpdateEmailRecoveryWorker(IServiceProvider serviceProvider) .WithMisfireHandlingInstructionIgnoreMisfires()).Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { using var scope = _serviceProvider.CreateScope(); var currentTenant = scope.ServiceProvider.GetRequiredService(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 23b42e4cdb..4f5a2abec3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -7,7 +7,6 @@ using Unity.Notifications.EmailNotifications; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Enums; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -15,7 +14,7 @@ namespace Unity.GrantManager.HealthChecks.BackgroundWorkers { [DisallowConcurrentExecution] - public class DataHealthCheckWorker : QuartzBackgroundWorkerBase + public class DataHealthCheckWorker : UnityQuartzBackgroundWorkerBase { private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; @@ -49,7 +48,7 @@ public DataHealthCheckWorker(ICurrentTenant currentTenant, .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("Executing DataHealthCheckWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs index e4c2c6aa60..320576eff9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs @@ -7,7 +7,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Logs; using Unity.GrantManager.Settings; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -17,7 +17,7 @@ namespace Unity.GrantManager.History; [DisallowConcurrentExecution] -public class AuditLogCleanupWorker : QuartzBackgroundWorkerBase, ITransientDependency +public class AuditLogCleanupWorker : UnityQuartzBackgroundWorkerBase, ITransientDependency { private readonly IServiceProvider _serviceProvider; private readonly ITenantRepository _tenantRepository; @@ -45,7 +45,7 @@ public AuditLogCleanupWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { var enabled = await _settingProvider.GetAsync( SettingsConstants.Retention.CleanupEnabled, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs index e5177775a9..5c2ef2d0c8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.AuditLogging; using Volo.Abp.Data; @@ -10,6 +12,7 @@ namespace Unity.GrantManager.History; +[Authorize(IdentityConsts.ITOperationsPolicyName)] public class HistoryAppService( IAuditLogRepository auditLogRepository, IIdentityUserRepository identityUserRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index dd35d8aaf0..d51b694060 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; using Unity.Notifications.EmailNotifications; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -14,7 +13,7 @@ namespace Unity.GrantManager.Intakes.BackgroundWorkers { [DisallowConcurrentExecution] - public class IntakeSyncWorker : QuartzBackgroundWorkerBase + public class IntakeSyncWorker : UnityQuartzBackgroundWorkerBase { private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; @@ -49,7 +48,7 @@ public IntakeSyncWorker(ICurrentTenant currentTenant, .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("Executing IntakeSyncWorker..."); var tenants = await _tenantRepository.GetListAsync(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs index c89a40c643..1c3fba6a3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs @@ -81,13 +81,12 @@ public async Task SearchUsersAsync(string directory, string? f return new UserSearchResult { Success = true, - Data = successfulResults + Data = [.. successfulResults .SelectMany(result => result.Data ?? []) .GroupBy(user => user.Attributes?.IdirUserGuid?.FirstOrDefault() ?? user.Username ?? user.Email) - .Select(group => group.First()) - .ToArray() + .Select(group => group.First())] }; } @@ -214,12 +213,7 @@ private async Task GetAccessTokenAsync() TokenValidationResponse tokenResponse; try { - tokenResponse = JsonSerializer.Deserialize(content, _jsonOptions)!; - - if (tokenResponse == null) - { - throw new UserFriendlyException("Invalid token response format"); - } + tokenResponse = JsonSerializer.Deserialize(content, _jsonOptions)! ?? throw new UserFriendlyException("Invalid token response format"); } catch (JsonException ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index dd02614dac..ba75c56913 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -13,6 +14,7 @@ namespace Unity.GrantManager.Integrations.Endpoints { + [Authorize(IdentityConsts.ITOperationsPolicyName)] public class EndpointManagementAppService( IRepository repository, IDistributedCache cache, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index c7440d1744..4d03ec81c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; @@ -19,7 +19,7 @@ namespace Unity.GrantManager.Messaging; /// Handlers are resolved from DI as filtered by . /// [DisallowConcurrentExecution] -public abstract class InboxWorkerBase : QuartzBackgroundWorkerBase +public abstract class InboxWorkerBase : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -68,7 +68,7 @@ protected InboxWorkerBase(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs index f41e23c52a..d351c39d89 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Uow; namespace Unity.GrantManager.Messaging; @@ -17,7 +17,7 @@ namespace Unity.GrantManager.Messaging; /// via . /// [DisallowConcurrentExecution] -public abstract class OutboxWorkerBase : QuartzBackgroundWorkerBase +public abstract class OutboxWorkerBase : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -44,7 +44,7 @@ protected OutboxWorkerBase(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index cf5128eba7..0ed0fecc72 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; @@ -8,6 +9,7 @@ namespace Unity.GrantManager.Notifications { + [Authorize] public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService { private readonly IRepository _repository = repository; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs new file mode 100644 index 0000000000..ce3f6dfc39 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +using Unity.GrantManager.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations; + +[DbContext(typeof(GrantManagerDbContext))] +[Migration("20261013120000_AddQuartzMisfireOriginalFireTime")] +public partial class AddQuartzMisfireOriginalFireTime : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "misfire_orig_fire_time", + table: "qrtz_triggers", + type: "bigint", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "misfire_orig_fire_time", + table: "qrtz_triggers"); + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index a084d239e6..b4fe9df92e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -19,38 +19,22 @@ namespace Unity.GrantManager.Web.Controllers { + [Authorize] [Route("api/form-notifications")] [ApiController] - public class FormNotificationsApiController : ControllerBase + public class FormNotificationsApiController(IApplicationStatusService statusService, + IEmailGroupsAppService emailGroupsAppService, + ITemplateService templateService, + Notifications.IAutomatedNotificationAppService automatedNotificationAppService, + EmailAttachmentService emailAttachmentService, ICurrentUser currentUser, + IEmailGroupUsersAppService emailGroupUsersAppService, + IIdentityUserIntegrationService identityUserIntegrationService, + IGrantApplicationAppService grantApplicationAppService, + ScheduledNotificationHelper scheduledNotificationHelper, + IApplicantEmailTemplateAppService applicantEmailTemplateService, + EmailComposerAccessChecker emailAccessChecker) : ControllerBase { - private readonly IApplicationStatusService _statusService; - private readonly IEmailGroupsAppService _emailGroupsAppService; - private readonly ITemplateService _templateService; - private readonly Notifications.IAutomatedNotificationAppService _automatedNotificationAppService; - private readonly EmailAttachmentService _emailAttachmentService; - private readonly ICurrentUser _currentUser; - private readonly IEmailGroupUsersAppService _emailGroupUsersAppService; - private readonly IIdentityUserIntegrationService _identityUserIntegrationService; - private readonly IGrantApplicationAppService _grantApplicationAppService; - private readonly ScheduledNotificationHelper _scheduledNotificationHelper; - private readonly IApplicantEmailTemplateAppService _applicantEmailTemplateService; - private readonly EmailComposerAccessChecker _emailAccessChecker; - - public FormNotificationsApiController(IApplicationStatusService statusService, IEmailGroupsAppService emailGroupsAppService, Unity.Notifications.Templates.ITemplateService templateService, Unity.GrantManager.Notifications.IAutomatedNotificationAppService automatedNotificationAppService, EmailAttachmentService emailAttachmentService, ICurrentUser currentUser, IEmailGroupUsersAppService emailGroupUsersAppService, IIdentityUserIntegrationService identityUserIntegrationService, IGrantApplicationAppService grantApplicationAppService, ScheduledNotificationHelper scheduledNotificationHelper, IApplicantEmailTemplateAppService applicantEmailTemplateService, EmailComposerAccessChecker emailAccessChecker) - { - _statusService = statusService; - _emailGroupsAppService = emailGroupsAppService; - _templateService = templateService; - _automatedNotificationAppService = automatedNotificationAppService; - _emailAttachmentService = emailAttachmentService; - _currentUser = currentUser; - _emailGroupUsersAppService = emailGroupUsersAppService; - _identityUserIntegrationService = identityUserIntegrationService; - _grantApplicationAppService = grantApplicationAppService; - _scheduledNotificationHelper = scheduledNotificationHelper; - _applicantEmailTemplateService = applicantEmailTemplateService; - _emailAccessChecker = emailAccessChecker; - } + [HttpGet("payment-statuses")] public ActionResult> GetPaymentStatuses() @@ -80,7 +64,7 @@ public ActionResult> GetPaymentStatuses() [HttpGet("templates")] public async Task>> GetTemplates([FromQuery] string? templateType = null) { - var templates = await _templateService.GetTemplatesByTenant(); + var templates = await templateService.GetTemplatesByTenant(); if (!string.IsNullOrWhiteSpace(templateType)) { templates = templates @@ -107,13 +91,13 @@ public async Task>> GetTemplates([FromQuery] [HttpGet("templates/{templateId:guid}/applicant-preview")] public async Task> GetApplicantTemplatePreview(Guid templateId, [FromQuery] Guid applicantId) { - return Ok(await _applicantEmailTemplateService.GetPreviewAsync(applicantId, templateId)); + return Ok(await applicantEmailTemplateService.GetPreviewAsync(applicantId, templateId)); } [HttpGet("templates/{templateId:guid}/resolved-recipients")] public async Task> GetResolvedTemplateRecipients(Guid templateId, [FromQuery] Guid? applicationId = null) { - var template = await _templateService.GetTemplateById(templateId); + var template = await templateService.GetTemplateById(templateId); if (template == null) { return NotFound(); @@ -134,11 +118,11 @@ public async Task> GetResolvedTemplateRecipi RecipientIdentifier = recipientIdentifier }; - var emailTo = await _scheduledNotificationHelper.GetInternalRecipientEmailAddressesAsync( + var emailTo = await scheduledNotificationHelper.GetInternalRecipientEmailAddressesAsync( notification, - _emailGroupsAppService, - _emailGroupUsersAppService, - _identityUserIntegrationService); + emailGroupsAppService, + emailGroupUsersAppService, + identityUserIntegrationService); return Ok(new ResolvedRecipientsDto { EmailTo = emailTo }); } @@ -150,7 +134,7 @@ public async Task> GetResolvedTemplateRecipi return Ok(new ResolvedRecipientsDto { EmailTo = string.Empty }); } - var application = await _grantApplicationAppService.GetAsync(applicationId.Value); + var application = await grantApplicationAppService.GetAsync(applicationId.Value); var identifiers = recipientIdentifier .Split(',') .Select(r => r.Trim()) @@ -195,12 +179,12 @@ public async Task> CopyTemplateAttachme if (input.EmailLogId == Guid.Empty) return BadRequest("EmailLogId is required"); - var email = await _emailAccessChecker.CheckEmailAsync(input.EmailLogId, NotificationsPermissions.Email.Send, requireDraft: true); - await _emailAccessChecker.CheckTemplateAsync(templateId, email.ApplicantId); - var copiedCount = await _emailAttachmentService.ReplaceTemplateAttachmentsAsync( + var email = await emailAccessChecker.CheckEmailAsync(input.EmailLogId, NotificationsPermissions.Email.Send, requireDraft: true); + await emailAccessChecker.CheckTemplateAsync(templateId, email.ApplicantId); + var copiedCount = await emailAttachmentService.ReplaceTemplateAttachmentsAsync( templateId, input.EmailLogId, - _currentUser.TenantId); + currentUser.TenantId); return Ok(new CopyAttachmentsResponseDto { AttachmentCount = copiedCount }); } @@ -208,7 +192,7 @@ public async Task> CopyTemplateAttachme [HttpGet("email-template/{templateId}/validate-attachments")] public async Task ValidateTemplateAttachments(Guid templateId) { - await _emailAttachmentService.ValidateTemplateAttachmentsAsync(templateId); + await emailAttachmentService.ValidateTemplateAttachmentsAsync(templateId); return NoContent(); } @@ -216,15 +200,15 @@ public async Task ValidateTemplateAttachments(Guid templateId) [HttpDelete("email-log/{emailLogId}/origin-attachments")] public async Task> DeleteOriginAttachments(Guid emailLogId) { - await _emailAccessChecker.CheckEmailAsync(emailLogId, NotificationsPermissions.Email.Send, requireDraft: true); - var deletedCount = await _emailAttachmentService.DeleteOriginAttachmentsAsync(emailLogId); + await emailAccessChecker.CheckEmailAsync(emailLogId, NotificationsPermissions.Email.Send, requireDraft: true); + var deletedCount = await emailAttachmentService.DeleteOriginAttachmentsAsync(emailLogId); return Ok(new CopyAttachmentsResponseDto { AttachmentCount = deletedCount }); } [HttpGet("statuses")] public async Task>> GetApplicationStatuses() { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var list = statuses.Select(s => new { id = s.Id, internalStatus = s.InternalStatus }).ToList(); return Ok(list); } @@ -235,7 +219,7 @@ public async Task>> GetRecipients([FromQuery] st // For internal recipients, load EmailGroups from the notifications module and expose their Name if (string.Equals(category, "Internal", StringComparison.OrdinalIgnoreCase)) { - var groups = await _emailGroupsAppService.GetListAsync(); + var groups = await emailGroupsAppService.GetListAsync(); var list = groups.Select(g => new RecipientDto { Id = g.Name, DisplayName = g.Name }).ToList(); return Ok(list); } @@ -257,17 +241,17 @@ public async Task>> GetForForm(strin { if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); - var listResult = await _automatedNotificationAppService.GetListAsync(new Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); + var listResult = await automatedNotificationAppService.GetListAsync(new Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); // Resolve template names and status labels var templateIds = listResult.Items.Select(x => x.EmailTemplateId).Where(id => id != Guid.Empty).Distinct().ToList(); var templateMap = new Dictionary(); foreach (var id in templateIds) { - templateMap[id] = await _templateService.GetTemplateById(id); + templateMap[id] = await templateService.GetTemplateById(id); } - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var statusMap = statuses.ToDictionary(s => s.Id, s => s.InternalStatus); var items = listResult.Items.Select(e => new ScheduledNotificationDto @@ -319,7 +303,7 @@ public async Task> CreateForForm(string f return BadRequest("ApplicationStatusId required for Application event trigger"); } - var template = await _templateService.GetTemplateById(input.TemplateId); + var template = await templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); if (!string.Equals(template.TemplateType, TemplateTypes.Application, StringComparison.OrdinalIgnoreCase)) { @@ -331,7 +315,7 @@ public async Task> CreateForForm(string f string? statusLabel = null; if (input.ApplicationStatusId.HasValue) { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); if (match != null) statusLabel = match.InternalStatus; } @@ -353,7 +337,7 @@ public async Task> CreateForForm(string f RecipientIdentifier = input.RecipientIdentifier }; - var created = await _automatedNotificationAppService.CreateAsync(createDto); + var created = await automatedNotificationAppService.CreateAsync(createDto); var dto = new ScheduledNotificationDto { @@ -378,7 +362,7 @@ public async Task> CreateForForm(string f [HttpDelete("{formId}/{id:guid}")] public async Task Delete(string formId, Guid id) { - await _automatedNotificationAppService.DeleteAsync(id); + await automatedNotificationAppService.DeleteAsync(id); return NoContent(); } @@ -386,14 +370,14 @@ public async Task Delete(string formId, Guid id) public async Task CancelNotification(string formId, Guid id) { if (!Guid.TryParse(formId, out _)) return BadRequest("Invalid form id"); - await _automatedNotificationAppService.CancelAsync(id); + await automatedNotificationAppService.CancelAsync(id); return NoContent(); } [HttpGet("can-delete-template/{templateId:guid}")] public async Task> CanDeleteTemplate(Guid templateId) { - var result = await _automatedNotificationAppService.GetListAsync( + var result = await automatedNotificationAppService.GetListAsync( new Notifications.GetNotificationsInput { MaxResultCount = 1000 }); var associatedPlans = result.Items @@ -423,9 +407,9 @@ public async Task> CanDeleteTemplate(Guid templateId) [HttpGet("template-notification-plans/{templateId:guid}")] public async Task> GetTemplateNotificationPlans(Guid templateId) { - var result = await _automatedNotificationAppService.GetListAsync( + var result = await automatedNotificationAppService.GetListAsync( new Notifications.GetNotificationsInput { MaxResultCount = 1000 }); - var template = await _templateService.GetTemplateById(templateId); + var template = await templateService.GetTemplateById(templateId); var templateName = template?.Name ?? "Template"; var planNames = result.Items @@ -447,11 +431,11 @@ public async Task> UpdateForForm(string f if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); - var template = await _templateService.GetTemplateById(input.TemplateId); + var template = await templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); - var existingNotification = await _automatedNotificationAppService.GetAsync(id); - var existingTemplate = await _templateService.GetTemplateById(existingNotification.EmailTemplateId); + var existingNotification = await automatedNotificationAppService.GetAsync(id); + var existingTemplate = await templateService.GetTemplateById(existingNotification.EmailTemplateId); if (existingTemplate != null && !string.Equals(template.TemplateType, existingTemplate.TemplateType, StringComparison.OrdinalIgnoreCase)) { @@ -465,7 +449,7 @@ public async Task> UpdateForForm(string f string? statusLabel = null; if (input.ApplicationStatusId.HasValue) { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); if (match != null) statusLabel = match.InternalStatus; } @@ -487,7 +471,7 @@ public async Task> UpdateForForm(string f RecipientIdentifier = input.RecipientIdentifier }; - var updated = await _automatedNotificationAppService.UpdateAsync(id, updateDto); + var updated = await automatedNotificationAppService.UpdateAsync(id, updateDto); var dto = new ScheduledNotificationDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index f5bd9a6963..32ea5f33c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -506,7 +506,7 @@ private void ConfigureBundles() { Configure(options => { - options.Mode = BundlingMode.Bundle; + options.Mode = BundlingMode.BundleAndMinify; options.MinificationIgnoredFiles.Add("/js/notifications-realtime-client.js"); options.MinificationIgnoredFiles.Add("/Pages/UnityMessaging/Index.js"); options diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000000..a637c7b348 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,6 @@ +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@using Volo.Abp.AspNetCore.Mvc.UI.Theming +@inject IThemeManager ThemeManager +@{ + Layout = ThemeManager.CurrentTheme.GetLayout(StandardLayouts.Application); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs index 7890745e64..d59149d688 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; using System.Threading.Tasks; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantAddresses { [ApiController] [Route("Widget/ApplicantAddresses")] + [Authorize(UnitySelector.ApplicantManagement.Addresses.Default)] public class ApplicantAddressesController : AbpController { [HttpGet] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs index 1e030c1977..f739fd5392 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; using System.Threading.Tasks; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts { [Route("Widget/ApplicantContacts")] + [Authorize(UnitySelector.ApplicantManagement.Contacts.Default)] public class ApplicantContactsController : AbpController { [HttpGet] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs index af4cbf1428..35e393c4fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantOrganizationInfo { [ApiController] [Route("Widget/ApplicantOrganizationInfo")] + [Authorize(UnitySelector.ApplicantManagement.ApplicantInfo.Default)] public class ApplicantOrganizationInfoController : AbpController { [HttpGet] From 70c0de2bfec473d9310717c8a2c135b7a6db2237 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 09:41:46 -0700 Subject: [PATCH 02/32] feature/AB#34582-TightenApi-Copilot --- .../CasPaymentRequestCoordinator.cs | 17 ++++++++++++ .../CasPaymentRequestCoordinatorTests.cs | 21 ++++++++++++++- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 27 +++++++++++++++++++ .../History/HistoryAppService.cs | 3 +-- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index a16fc69f34..f8a8c4c710 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -145,6 +145,23 @@ public async Task AddPaymentRequestsToReconciliationQueue() originalCasValues ??= GetCurrentCasValues(paymentRequest); var casValues = GetCasValues(paymentRequest, result); + if (attempt > 1) + { + var currentCasValues = GetCurrentCasValues(paymentRequest); + if (currentCasValues == casValues) + { + return paymentRequest; + } + + if (currentCasValues != originalCasValues) + { + Logger.LogWarning( + "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.", + PaymentRequestId); + return paymentRequest; + } + } + ApplyCasValues(paymentRequest, casValues); await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs index f155acee09..ae88956239 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -9,7 +9,6 @@ using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Integrations.Cas; using Unity.Notifications.Integrations.RabbitMQ; -using Unity.Payments.PaymentRequests; using Volo.Abp.Application.Services; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; @@ -79,6 +78,26 @@ public async Task UpdatePaymentRequestStatus_RejectsStaleCasResult() _ = unitOfWork.Received(1).CompleteAsync(); } + [Fact] + public async Task UpdatePaymentRequestStatus_RejectsStateChangedBeforeRetryLoad() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns( + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(invoiceStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.InvoiceStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + [Fact] public async Task UpdatePaymentRequestStatus_ThrowsAfterMaximumConcurrencyAttempts() { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index 398f983ae5..e9083648eb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Quartz; @@ -16,14 +17,40 @@ public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBa public sealed override async Task Execute(IJobExecutionContext context) { var auditingManager = ServiceProvider.GetRequiredService(); + var auditingStore = ServiceProvider.GetRequiredService(); var principalAccessor = ServiceProvider.GetRequiredService(); var currentTenant = ServiceProvider.GetRequiredService(); using (BackgroundJobExecutionContext.Use()) using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) { + AddWorkerAuditAction(auditingManager, context); await ExecuteInBackgroundAsync(context); + + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + using (currentTenant.Change(null)) + { + await auditingStore.SaveAsync(log); + } + } + } + } + + private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext context) + { + if (auditingManager.Current?.Log == null) + { + return; } + + auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo + { + ServiceName = GetType().FullName ?? GetType().Name, + MethodName = nameof(ExecuteInBackgroundAsync), + Parameters = System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), + ExecutionTime = DateTime.UtcNow + }); } protected abstract Task ExecuteInBackgroundAsync(IJobExecutionContext context); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs index 5c2ef2d0c8..4c8b505df5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.AuditLogging; using Volo.Abp.Data; @@ -12,7 +11,7 @@ namespace Unity.GrantManager.History; -[Authorize(IdentityConsts.ITOperationsPolicyName)] +[Authorize] public class HistoryAppService( IAuditLogRepository auditLogRepository, IIdentityUserRepository identityUserRepository, From 75948684906dce01ff3a170c8c33c8044fad7f50 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 12:42:36 -0700 Subject: [PATCH 03/32] feature/AB#34582-TightenApi-Co-Pilot --- .../CasPaymentRequestCoordinatorTests.cs | 18 +---- .../Utils/BackgroundJobContext.cs | 20 +++-- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 24 +++++- .../FiscalYearEndRolloverWorker.cs | 43 +++++----- .../DateBasedScheduledNotificationJob.cs | 4 +- .../ApplicantUpdateEmailRecoveryWorker.cs | 11 ++- .../DataHealthCheckWorker.cs | 39 +++++----- .../History/AuditLogCleanupWorker.cs | 2 +- .../BackgroundWorkers/IntakeSyncWorker.cs | 15 ++-- .../Endpoints/EndpointManagementAppService.cs | 2 +- .../Messaging/InboxWorkerBase.cs | 15 ++-- .../AutomatedNotificationAppService.cs | 7 +- .../FormNotificationsApiController.cs | 7 +- .../FiscalYearEndRolloverWorkerTests.cs | 78 ++++++++++++++++++- ...ApplicantUpdateEmailRecoveryWorkerTests.cs | 15 +++- 15 files changed, 209 insertions(+), 91 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs index ae88956239..c22d8869b0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -129,7 +129,7 @@ private static CasPaymentRequestCoordinator CreateCoordinator( var unitOfWorkManager = Substitute.For(); unitOfWorkManager.Begin(Arg.Any(), Arg.Any()).Returns(unitOfWork); - var coordinator = new TestableCasPaymentRequestCoordinator( + var coordinator = new CasPaymentRequestCoordinator( paymentQueueService: null!, paymentRequestsRepository: repository, unitOfWorkManager: unitOfWorkManager, @@ -146,22 +146,6 @@ private static CasPaymentRequestCoordinator CreateCoordinator( return coordinator; } - private sealed class TestableCasPaymentRequestCoordinator( - PaymentQueueService paymentQueueService, - IPaymentRequestRepository paymentRequestsRepository, - IUnitOfWorkManager unitOfWorkManager, - ITenantRepository tenantRepository, - ICurrentTenant currentTenant, - ILocalEventBus localEventBus) - : CasPaymentRequestCoordinator( - paymentQueueService, - paymentRequestsRepository, - unitOfWorkManager, - tenantRepository, - currentTenant, - localEventBus) - { } - private static PaymentRequest CreatePaymentRequest( string? invoiceStatus = null, string? paymentStatus = null) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs index b450a2c219..81c1a1804f 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -50,20 +50,26 @@ public static IDisposable Set( var principalDisposable = principalAccessor.Change(principal); // NOW start auditing - it will see the correct tenant/user context + var auditingDisposable = BeginAuditScope(auditingManager, tenantId); + + // Dispose in LIFO order: last registered is disposed first. + // audit scope closes first (while tenant/user context is still valid), + // then principal, then tenant. + return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + } + + public static IDisposable BeginAuditScope(IAuditingManager auditingManager, Guid? tenantId) + { var auditingDisposable = auditingManager.BeginScope(); - - // Ensure the current audit log has the user ID set for entity change tracking + if (auditingManager.Current != null) { - auditingManager.Current.Log.UserId = backgroundJobPersonId; + auditingManager.Current.Log.UserId = BackgroundJobConstants.BackgroundJobPersonId; auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; auditingManager.Current.Log.TenantId = tenantId; } - // Dispose in LIFO order: last registered is disposed first. - // audit scope closes first (while tenant/user context is still valid), - // then principal, then tenant. - return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + return auditingDisposable; } /// diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index e9083648eb..ed3ce84c85 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Quartz; @@ -37,7 +38,26 @@ public sealed override async Task Execute(IJobExecutionContext context) } } - private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext context) + protected async Task ExecuteInTenantAuditScopeAsync(Guid? tenantId, Func action) + { + var auditingManager = ServiceProvider.GetRequiredService(); + var auditingStore = ServiceProvider.GetRequiredService(); + var currentTenant = ServiceProvider.GetRequiredService(); + + using (currentTenant.Change(tenantId)) + using (BackgroundJobContext.BeginAuditScope(auditingManager, tenantId)) + { + AddWorkerAuditAction(auditingManager, null); + await action(); + + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + await auditingStore.SaveAsync(log); + } + } + } + + private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext? context) { if (auditingManager.Current?.Log == null) { @@ -48,7 +68,7 @@ private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutio { ServiceName = GetType().FullName ?? GetType().Name, MethodName = nameof(ExecuteInBackgroundAsync), - Parameters = System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), + Parameters = context == null ? "{}" : System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), ExecutionTime = DateTime.UtcNow }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs index 7951f2525c..a987be15bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs @@ -86,31 +86,34 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont var currentTenant = tenantScope.ServiceProvider.GetRequiredService(); var uowManager = tenantScope.ServiceProvider.GetRequiredService(); - // The rollover runs raw SQL directly against the tenant connection, which bypasses - // ABP's EF Core query filters (soft-delete, multi-tenancy) entirely, so no explicit - // IDataFilter bypass is required here - CurrentTenant.Change only selects the - // correct tenant connection string. - using (currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); - var applicantRepository = tenantScope.ServiceProvider.GetRequiredService(); + // The rollover runs raw SQL directly against the tenant connection, which bypasses + // ABP's EF Core query filters (soft-delete, multi-tenancy) entirely, so no explicit + // IDataFilter bypass is required here - CurrentTenant.Change only selects the + // correct tenant connection string. + using (currentTenant.Change(tenant.Id, tenant.Name)) + { + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + var applicantRepository = tenantScope.ServiceProvider.GetRequiredService(); - var result = await applicantRepository.RollOverFiscalYearEndAsync(); + var result = await applicantRepository.RollOverFiscalYearEndAsync(); - if (!result.ColumnsAvailable) - { - _logger.LogWarning( - "Skipped fiscal year end rollover for tenant {TenantName} ({TenantId}) - missing column(s): {MissingColumns}", - tenant.Name, tenant.Id, string.Join(", ", result.MissingColumns)); - continue; - } + if (!result.ColumnsAvailable) + { + _logger.LogWarning( + "Skipped fiscal year end rollover for tenant {TenantName} ({TenantId}) - missing column(s): {MissingColumns}", + tenant.Name, tenant.Id, string.Join(", ", result.MissingColumns)); + return; + } - await uow.CompleteAsync(context.CancellationToken); + await uow.CompleteAsync(context.CancellationToken); - _logger.LogInformation( - "Fiscal year end rollover completed for tenant {TenantName} ({TenantId}). Rows affected: {RowsAffected}", - tenant.Name, tenant.Id, result.RowsAffected); - } + _logger.LogInformation( + "Fiscal year end rollover completed for tenant {TenantName} ({TenantId}). Rows affected: {RowsAffected}", + tenant.Name, tenant.Id, result.RowsAffected); + } + }); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index fcc85970ee..0e4d5f333e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -147,11 +147,11 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont // Process each tenant separately foreach (var tenant in allTenants) { - using (_currentTenant.Change(tenant.Id)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { _logger.LogDebug("DateBasedScheduledNotificationJob: Processing notifications for tenant '{TenantName}' ({TenantId}).", tenant.Name, tenant.Id); await ProcessTenantNotificationsAsync(); - } + }); } _logger.LogInformation("DateBasedScheduledNotificationJob: Completed execution."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs index 8e94efc57b..a00147f8b6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs @@ -52,11 +52,14 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont { try { - using var tenantScope = _serviceProvider.CreateScope(); - using (tenantScope.ServiceProvider.GetRequiredService().Change(tenant.Id)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - await RecoverTenantAsync(tenantScope.ServiceProvider); - } + using var tenantScope = _serviceProvider.CreateScope(); + using (tenantScope.ServiceProvider.GetRequiredService().Change(tenant.Id)) + { + await RecoverTenantAsync(tenantScope.ServiceProvider); + } + }); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 4f5a2abec3..995d5421c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -64,27 +64,30 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont foreach (var tenant in tenants) { - using (_currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - // Lookup the missing emails - var missingEmailsCount = await _emailNotificationService.GetEmailsChesWithNoResponseCountAsync(); - if (missingEmailsCount > 0) + using (_currentTenant.Change(tenant.Id, tenant.Name)) { - Logger.LogWarning("Tenant {TenantName} has {MissingEmailsCount} missing email(s) with a status of Initialized or Sent but no CHES Response.", tenant.Name, missingEmailsCount); - string missingEmailBody = $"Unity tenant {tenant.Name} has {missingEmailsCount} email(s) that were sent but have no CHES Response."; - sendEmail = true; - emailBodyBuilder.AppendLine($"{missingEmailBody}
"); + // Lookup the missing emails + var missingEmailsCount = await _emailNotificationService.GetEmailsChesWithNoResponseCountAsync(); + if (missingEmailsCount > 0) + { + Logger.LogWarning("Tenant {TenantName} has {MissingEmailsCount} missing email(s) with a status of Initialized or Sent but no CHES Response.", tenant.Name, missingEmailsCount); + string missingEmailBody = $"Unity tenant {tenant.Name} has {missingEmailsCount} email(s) that were sent but have no CHES Response."; + sendEmail = true; + emailBodyBuilder.AppendLine($"{missingEmailBody}
"); + } + // Lookup the missing payments + var missingPayments = await GetPaymentsSentWithoutResponseCountAsync(); + if (missingPayments > 0) + { + Logger.LogWarning("Tenant {TenantName} has {MissingPaymentsCount} payments sent without a response.", tenant.Name, missingPayments); + string missingPaymentBody = $"Unity tenant {tenant.Name} has {missingPayments} payment(s) that are in Submitted status but have no CAS Response."; + sendEmail = true; + emailBodyBuilder.AppendLine($"{missingPaymentBody}
"); + } } - // Lookup the missing payments - var missingPayments = await GetPaymentsSentWithoutResponseCountAsync(); - if (missingPayments > 0) - { - Logger.LogWarning("Tenant {TenantName} has {MissingPaymentsCount} payments sent without a response.", tenant.Name, missingPayments); - string missingPaymentBody = $"Unity tenant {tenant.Name} has {missingPayments} payment(s) that are in Submitted status but have no CAS Response."; - sendEmail = true; - emailBodyBuilder.AppendLine($"{missingPaymentBody}
"); - } - } + }); } if (sendEmail) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs index 320576eff9..8e8750009b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs @@ -122,7 +122,7 @@ private async Task CleanupInScopeAsync( { try { - await cleanup(scope.ServiceProvider); + await ExecuteInTenantAuditScopeAsync(tenantId, () => cleanup(scope.ServiceProvider)); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index d51b694060..c70899448d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -65,15 +65,18 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont foreach (var tenant in tenants) { - using (_currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - var (missingSubmissions, submissionReport) = await _applicationFormSynchronizationService.GetMissingSubmissions(numberDaysBack); - if (missingSubmissions?.Count > 0) + using (_currentTenant.Change(tenant.Id, tenant.Name)) { - emailBodyBuilder.AppendLine($"

Tenant: {tenant.Name}
{submissionReport}"); - sendEmail = true; + var (missingSubmissions, submissionReport) = await _applicationFormSynchronizationService.GetMissingSubmissions(numberDaysBack); + if (missingSubmissions?.Count > 0) + { + emailBodyBuilder.AppendLine($"

Tenant: {tenant.Name}
{submissionReport}"); + sendEmail = true; + } } - } + }); } emailBodyBuilder.AppendLine("
Bests"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index ba75c56913..bb91b61e09 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -14,7 +14,7 @@ namespace Unity.GrantManager.Integrations.Endpoints { - [Authorize(IdentityConsts.ITOperationsPolicyName)] + [Authorize(IdentityConsts.ITAdminOrITOperationsPolicyName)] public class EndpointManagementAppService( IRepository repository, IDistributedCache cache, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index 4d03ec81c3..0f03a5eb7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -114,7 +114,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i inboxMsg.MessageId, inboxMsg.Source, inboxMsg.DataType, inboxMsg.TenantId); string ackStatus; - string details; + string details = string.Empty; try { @@ -149,12 +149,15 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i else { // Switch to tenant context ONLY for the domain handler execution - using (currentTenant.Change(inboxMsg.TenantId)) + await ExecuteInTenantAuditScopeAsync(inboxMsg.TenantId, async () => { - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); - details = await handler.HandleAsync(inboxMsg.Payload); - await uow.CompleteAsync(); - } + using (currentTenant.Change(inboxMsg.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + details = await handler.HandleAsync(inboxMsg.Payload); + await uow.CompleteAsync(); + } + }); ackStatus = "SUCCESS"; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index 0ed0fecc72..3f59ebe8df 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -6,14 +6,16 @@ using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.Permissions; namespace Unity.GrantManager.Notifications { - [Authorize] + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService { private readonly IRepository _repository = repository; + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task CreateAsync(CreateUpdateNotificationDto input) { var entity = new ScheduledNotification @@ -53,11 +55,13 @@ public async Task CreateAsync(CreateUpdateNotificationDto input }; } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task DeleteAsync(Guid id) { await _repository.DeleteAsync(id, autoSave: true); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task CancelAsync(Guid id) { var entity = await _repository.GetAsync(id); @@ -127,6 +131,7 @@ public async Task> GetListAsync(GetNotifications return new PagedResultDto(total, items); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task UpdateAsync(Guid id, CreateUpdateNotificationDto input) { var e = await _repository.GetAsync(id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index b4fe9df92e..8fcbdf413f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -13,13 +13,14 @@ using Unity.Notifications.Templates; using Volo.Abp.Users; using Unity.GrantManager.Events; +using Unity.GrantManager.Permissions; using Unity.Payments.Codes; using Unity.Payments.Enums; using Volo.Abp.Identity.Integration; namespace Unity.GrantManager.Web.Controllers { - [Authorize] + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [Route("api/form-notifications")] [ApiController] public class FormNotificationsApiController(IApplicationStatusService statusService, @@ -277,6 +278,7 @@ public async Task>> GetForForm(strin return Ok(items); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPost("{formId}")] public async Task> CreateForForm(string formId, [FromBody] CreateScheduledNotificationInput input) { @@ -359,6 +361,7 @@ public async Task> CreateForForm(string f return CreatedAtAction(nameof(GetForForm), new { formId }, dto); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpDelete("{formId}/{id:guid}")] public async Task Delete(string formId, Guid id) { @@ -366,6 +369,7 @@ public async Task Delete(string formId, Guid id) return NoContent(); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPatch("{formId}/{id:guid}/cancel")] public async Task CancelNotification(string formId, Guid id) { @@ -423,6 +427,7 @@ public async Task> GetTemplateNotificationPlans(Guid templa return Ok(new { notificationPlanNames = planNames }); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPut("{formId}/{id:guid}")] public async Task> UpdateForForm(string formId, Guid id, [FromBody] CreateScheduledNotificationInput input) { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs index 52a8280546..0605864c03 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +10,9 @@ using Shouldly; using Unity.GrantManager.Applicants.BackgroundWorkers; using Unity.GrantManager.Applications; +using Volo.Abp.Auditing; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; using Volo.Abp.Uow; @@ -94,30 +98,96 @@ public async Task ShouldContinueProcessingRemainingTenants_WhenOneTenantThrows() await applicantRepository.Received(2).RollOverFiscalYearEndAsync(); } + [Fact] + public async Task ShouldPersistSeparateAuditLogs_WhenProcessingMultipleTenants() + { + var tenantOne = CreateTenant("Tenant One"); + var tenantTwo = CreateTenant("Tenant Two"); + var currentTenant = CreateCurrentTenant(); + var applicantRepository = Substitute.For(); + var auditingManager = CreateAuditingManager(out var savedLogs); + var auditingStore = Substitute.For(); + auditingStore.SaveAsync(Arg.Any()).Returns(callInfo => + { + savedLogs.Add(callInfo.Arg()); + return Task.CompletedTask; + }); + applicantRepository.RollOverFiscalYearEndAsync().Returns(_ => + { + auditingManager.Current!.Log.EntityChanges.Add(new EntityChangeInfo()); + return Task.FromResult(new FiscalYearEndRolloverResult { RowsAffected = 1 }); + }); + + var worker = CreateWorker( + currentTenant, + CreateTenantRepository(tenantOne, tenantTwo), + applicantRepository, + auditingManager, + auditingStore); + + await worker.Execute(Substitute.For()); + + savedLogs.Count.ShouldBe(2); + savedLogs.Select(log => log.TenantId).ShouldBe([tenantOne.Id, tenantTwo.Id]); + savedLogs.ShouldAllBe(log => log.EntityChanges.Count == 1); + } + private static FiscalYearEndRolloverWorker CreateWorker( ICurrentTenant currentTenant, ITenantRepository tenantRepository, - IApplicantRepository applicantRepository) + IApplicantRepository applicantRepository, + IAuditingManager? auditingManager = null, + IAuditingStore? auditingStore = null) { var logger = Substitute.For>(); var settingManager = Substitute.For(); - var serviceProvider = CreateServiceProvider(currentTenant, applicantRepository); + var serviceProvider = CreateServiceProvider(currentTenant, applicantRepository, auditingManager, auditingStore); - return new FiscalYearEndRolloverWorker(logger, serviceProvider, tenantRepository, settingManager); + var worker = new FiscalYearEndRolloverWorker(logger, serviceProvider, tenantRepository, settingManager) + { + ServiceProvider = serviceProvider + }; + return worker; } - private static IServiceProvider CreateServiceProvider(ICurrentTenant currentTenant, IApplicantRepository applicantRepository) + private static IServiceProvider CreateServiceProvider( + ICurrentTenant currentTenant, + IApplicantRepository applicantRepository, + IAuditingManager? auditingManager = null, + IAuditingStore? auditingStore = null) { var uowManager = Substitute.For(); uowManager.Begin(Arg.Any(), Arg.Any()).Returns(Substitute.For()); var services = new ServiceCollection(); services.AddSingleton(currentTenant); + services.AddSingleton(Substitute.For()); services.AddSingleton(uowManager); services.AddSingleton(applicantRepository); + services.AddSingleton(auditingManager ?? CreateAuditingManager(out _)); + services.AddSingleton(auditingStore ?? Substitute.For()); return services.BuildServiceProvider(); } + private static IAuditingManager CreateAuditingManager(out List auditLogs) + { + auditLogs = []; + var scopes = new Stack(); + var auditingManager = Substitute.For(); + auditingManager.Current.Returns(_ => scopes.TryPeek(out var scope) ? scope : null); + auditingManager.BeginScope().Returns(_ => + { + var scope = Substitute.For(); + scope.Log.Returns(new AuditLogInfo()); + scopes.Push(scope); + + var disposable = Substitute.For(); + disposable.When(x => x.Dispose()).Do(_ => scopes.Pop()); + return disposable; + }); + return auditingManager; + } + private static ICurrentTenant CreateCurrentTenant() { var currentTenant = Substitute.For(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs index 21f525635c..b599c1cd60 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs @@ -12,8 +12,10 @@ using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; using Volo.Abp.Features; +using Volo.Abp.Auditing; using Volo.Abp.Linq; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; using Volo.Abp.TenantManagement; using Volo.Abp.Timing; using Volo.Abp.Uow; @@ -68,10 +70,21 @@ public async Task ShouldRequeueOnlyStaleUnattemptedPortalEmailsWithOriginalIdsAn services.AddSingleton(clock); services.AddSingleton(features); services.AddSingleton(currentTenant); + services.AddSingleton(Substitute.For()); services.AddSingleton(manager); services.AddSingleton(uowManager); + var auditingManager = Substitute.For(); + var auditScope = Substitute.For(); + auditScope.Log.Returns(new AuditLogInfo()); + auditingManager.Current.Returns(auditScope); + auditingManager.BeginScope().Returns(Substitute.For()); + services.AddSingleton(auditingManager); + services.AddSingleton(Substitute.For()); using var provider = services.BuildServiceProvider(); - var worker = new ApplicantUpdateEmailRecoveryWorker(provider); + var worker = new ApplicantUpdateEmailRecoveryWorker(provider) + { + ServiceProvider = provider + }; await worker.Execute(Substitute.For()); From 89b9033fb912ecd172cec58fa0886e70de2118fb Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 12:53:57 -0700 Subject: [PATCH 04/32] feature/AB#34582-TightenApi-Co-Pilot --- .../BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs index 0605864c03..aa7a6bbaca 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs @@ -109,7 +109,10 @@ public async Task ShouldPersistSeparateAuditLogs_WhenProcessingMultipleTenants() var auditingStore = Substitute.For(); auditingStore.SaveAsync(Arg.Any()).Returns(callInfo => { - savedLogs.Add(callInfo.Arg()); + if (callInfo.Arg() is { } auditLog) + { + savedLogs.Add(auditLog); + } return Task.CompletedTask; }); applicantRepository.RollOverFiscalYearEndAsync().Returns(_ => From 72d4b7d5188e9b48e34dc20f5c1bd9c22451928d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 14:31:26 -0700 Subject: [PATCH 05/32] feature/AB#34582-TightenApi-Co-Pilot --- .../src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml deleted file mode 100644 index a637c7b348..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml +++ /dev/null @@ -1,6 +0,0 @@ -@using Volo.Abp.AspNetCore.Mvc.UI.Layout -@using Volo.Abp.AspNetCore.Mvc.UI.Theming -@inject IThemeManager ThemeManager -@{ - Layout = ThemeManager.CurrentTheme.GetLayout(StandardLayouts.Application); -} From 546f66e320b033ffe388abbc544ab5d8de42faf7 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 15:00:03 -0700 Subject: [PATCH 06/32] feature/AB#34582-TightenApi-Queue-Retry --- .../Constants/QueueingConstants.cs | 12 ++++ .../Interfaces/IQueueChannelProvider.cs | 8 +++ .../QueueChannelProvider.cs | 56 +++++++++++++++++ .../QueueConsumerHandler.cs | 63 ++++++++++++++++++- 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs index 6e70f6c617..aab9837dba 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs @@ -1,9 +1,21 @@  +using System; + namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants { public static class QueueingConstants { public const string DeadletterAddition = "-deadletter"; + public const string RetryAddition = ".retry"; + public const string RetryCountHeader = "x-retry-count"; + public const int MaxMessageRetries = 3; public const int MAX_RABBIT_CONCURRENT_CONSUMERS = 10; + + public static readonly TimeSpan[] RetryDelays = + [ + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(2), + TimeSpan.FromMinutes(10) + ]; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs index 1baa6ebc8c..db472ad5eb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using RabbitMQ.Client; +using RabbitMQ.Client.Events; #pragma warning disable CA1005 // Avoid excessive parameters on generic types #pragma warning disable S2326 @@ -16,6 +18,12 @@ public interface IQueueChannelProvider : IDisposable where TQueue ///
Task GetChannelAsync(); + /// + /// Republishes a failed delivery to a delayed retry queue. The returned task completes + /// only after RabbitMQ confirms the retry publish. + /// + Task PublishRetryAsync(IChannel channel, BasicDeliverEventArgs delivery, int retryCount, TimeSpan delay, CancellationToken cancellationToken = default); + /// /// Returns a channel obtained from so it can be pooled /// or disposed and its throttling permit released. Callers that finish with a channel diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 9be537782d..3a46e2a315 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; +using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; @@ -71,6 +72,38 @@ public async Task GetChannelAsync() } } + public async Task PublishRetryAsync( + IChannel channel, + BasicDeliverEventArgs delivery, + int retryCount, + TimeSpan delay, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(delivery); + + var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; + var retryQueue = $"{retryExchange}.{GetRetryDelayKey(delay)}"; + var properties = new BasicProperties + { + Persistent = true, + Type = delivery.BasicProperties.Type, + MessageId = delivery.BasicProperties.MessageId, + Headers = new Dictionary + { + [QueueingConstants.RetryCountHeader] = retryCount + } + }; + + await channel.BasicPublishAsync( + exchange: retryExchange, + routingKey: retryQueue, + mandatory: false, + basicProperties: properties, + body: delivery.Body, + cancellationToken: cancellationToken); + } + public void ReturnChannel(IChannel channel) { if (channel?.IsOpen == true && !_disposed) @@ -122,6 +155,7 @@ private async Task DeclareQueueAsync(IChannel channel) { var dlxName = $"{_queueName}.dlx"; var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; + var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; // Ensure DLX exchange exists await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); @@ -135,6 +169,25 @@ await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDe }); await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); + await channel.ExchangeDeclareAsync(retryExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); + foreach (var retryDelay in QueueingConstants.RetryDelays) + { + var retryQueue = $"{retryExchange}.{GetRetryDelayKey(retryDelay)}"; + await channel.QueueDeclareAsync( + retryQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-queue-type", "quorum" }, + { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) }, + { "x-dead-letter-exchange", $"{_queueName}.exchange" }, + { "x-dead-letter-routing-key", _queueName } + }); + await channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null); + } + // Declare main queue with DLX args await channel.QueueDeclareAsync( _queueName, @@ -172,6 +225,9 @@ await channel.QueueDeclareAsync( } } + private static string GetRetryDelayKey(TimeSpan delay) => + $"{(int)delay.TotalSeconds}s"; + private async Task BindToExchangeAsync(IChannel channel) { var mainExchange = $"{_queueName}.exchange"; diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index 37e07c7048..5cb14e0cea 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -1,12 +1,13 @@ using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using RabbitMQ.Client; using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions; +using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; using Unity.Modules.Shared.Utils; using Volo.Abp.Auditing; @@ -126,9 +127,65 @@ private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea) } catch (Exception ex) { - _logger.LogError(ex, "Error processing message on {Queue}", _queueName); - await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); + var retryCount = GetRetryCount(ea.BasicProperties.Headers); + if (IsPermanentFailure(ex) || retryCount >= QueueingConstants.MaxMessageRetries) + { + _logger.LogError(ex, + "Permanently rejecting message {MessageId} on {Queue} after {RetryCount} retries", + ea.BasicProperties.MessageId, + _queueName, + retryCount); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); + return; + } + + var nextRetryCount = retryCount + 1; + var delay = QueueingConstants.RetryDelays[retryCount]; + var channelProvider = consumerScope.ServiceProvider + .GetRequiredService>(); + + _logger.LogWarning(ex, + "Scheduling retry {RetryCount} for message {MessageId} on {Queue} in {Delay}", + nextRetryCount, + ea.BasicProperties.MessageId, + _queueName, + delay); + + await channelProvider.PublishRetryAsync(consumingChannel, ea, nextRetryCount, delay); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); + } + } + + private static int GetRetryCount(IDictionary? headers) + { + if (headers == null || !headers.TryGetValue(QueueingConstants.RetryCountHeader, out var value)) + { + return 0; + } + + return value switch + { + int count => Math.Max(count, 0), + long count => Math.Max((int)count, 0), + byte[] bytes when int.TryParse(Encoding.UTF8.GetString(bytes), out var count) => Math.Max(count, 0), + _ when int.TryParse(value?.ToString(), out var count) => Math.Max(count, 0), + _ => 0 + }; + } + + private static bool IsPermanentFailure(Exception exception) + { + for (var current = exception; current != null; current = current.InnerException) + { + if (current is ArgumentException || + current is Volo.Abp.Validation.AbpValidationException || + current is Volo.Abp.Domain.Entities.EntityNotFoundException) + { + return true; + } } + + return false; } /// From 72845ab0be3d3bb2e6e8eb1232ef002e4da3a18f Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 15:09:56 -0700 Subject: [PATCH 07/32] feature/AB#34582-TightenApi-Co-Pilot --- .../FinancialNotificationSummaryWorker.cs | 7 ++- .../FinancialSummaryNotifier.cs | 22 ++++++--- .../QueueConsumerHandler.cs | 13 +++++- .../Utils/BackgroundJobContext.cs | 25 +++++++++- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 46 +++++++++++++++---- .../FiscalYearEndRolloverWorker.cs | 2 + .../DateBasedScheduledNotificationJob.cs | 2 + .../ApplicantUpdateEmailRecoveryWorker.cs | 2 + .../DataHealthCheckWorker.cs | 2 + .../History/AuditLogCleanupWorker.cs | 2 + .../BackgroundWorkers/IntakeSyncWorker.cs | 2 + .../Messaging/InboxWorkerBase.cs | 2 + .../FormNotificationsApiController.cs | 10 +++- 13 files changed, 117 insertions(+), 20 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs index e412444396..4b24c4585e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs @@ -47,7 +47,12 @@ public FinancialNotificationSummaryWorker( protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("FinancialNotificationSummary Execute"); - await _financialSummaryNotifier.NotifyFailedPayments(_strategies); + foreach (var tenantId in await _financialSummaryNotifier.GetTenantIdsAsync()) + { + await ExecuteInTenantAuditScopeAsync( + tenantId, + () => _financialSummaryNotifier.NotifyFailedPaymentsForTenantAsync(tenantId, _strategies)); + } } private static string ResolveCronExpression(ISettingManager settingManager, ILogger logger) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs index 468ae9172c..3e88926f2d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs @@ -25,13 +25,24 @@ public class FinancialSummaryNotifier( ILocalEventBus localEventBus, ILogger logger) : ISingletonDependency { - public async Task NotifyFailedPayments(IEnumerable strategies) + public async Task> GetTenantIdsAsync() { var tenants = await tenantRepository.GetListAsync(); - foreach (var tenantId in tenants.Select(tenant => tenant.Id)) + return tenants.Select(tenant => tenant.Id).ToList(); + } + + public async Task NotifyFailedPayments(IEnumerable strategies) + { + foreach (var tenantId in await GetTenantIdsAsync()) + { + await NotifyFailedPaymentsForTenantAsync(tenantId, strategies); + } + } + + public async Task NotifyFailedPaymentsForTenantAsync(Guid tenantId, IEnumerable strategies) + { + using (currentTenant.Change(tenantId)) { - using (currentTenant.Change(tenantId)) - { List failedPaymentList = await GetFailedPayments(); if (failedPaymentList != null && failedPaymentList.Count > 0) { @@ -61,7 +72,7 @@ public async Task NotifyFailedPayments(IEnumerable stra if (recipientEmails.Count == 0) { logger.LogWarning("NotifyFailedPayments: No recipients found from any strategy for tenant {TenantId}", tenantId); - continue; + return; } var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); @@ -83,7 +94,6 @@ await localEventBus.PublishAsync( } } - } } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index 5cb14e0cea..4593370b3b 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -194,7 +194,7 @@ current is Volo.Abp.Validation.AbpValidationException || /// audit persistence are handled here so individual consumers stay free of /// infrastructure concerns. /// - private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message) + private async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message) { var auditingManager = consumerScope.ServiceProvider.GetRequiredService(); var principalAccessor = consumerScope.ServiceProvider.GetRequiredService(); @@ -214,7 +214,16 @@ private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, // so this call happens after the consumer's unit of work completes. if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) { - await auditingStore.SaveAsync(log); + try + { + await auditingStore.SaveAsync(log); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to persist audit log for message {MessageId}; consumer work was already committed.", + message.MessageId); + } } } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs index 81c1a1804f..46370e100f 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -55,7 +55,30 @@ public static IDisposable Set( // Dispose in LIFO order: last registered is disposed first. // audit scope closes first (while tenant/user context is still valid), // then principal, then tenant. - return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + return new CompositeDisposable(tenantDisposable, principalDisposable, auditingDisposable); + } + + public static IDisposable SetExecutionContext( + ICurrentPrincipalAccessor principalAccessor, + ICurrentTenant currentTenant, + Guid? tenantId) + { + var backgroundJobPersonId = BackgroundJobConstants.BackgroundJobPersonId; + var claims = new[] + { + new Claim(AbpClaimTypes.UserId, backgroundJobPersonId.ToString()), + new Claim(ClaimTypes.NameIdentifier, backgroundJobPersonId.ToString()), + new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName), + new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), + new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), + new Claim(AbpClaimTypes.Name, BackgroundJobConstants.BackgroundJobName) + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + claims, "BackgroundJob", AbpClaimTypes.UserName, AbpClaimTypes.Role)); + var tenantDisposable = currentTenant.Change(tenantId); + var principalDisposable = principalAccessor.Change(principal); + return new CompositeDisposable(tenantDisposable, principalDisposable); } public static IDisposable BeginAuditScope(IAuditingManager auditingManager, Guid? tenantId) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index ed3ce84c85..f366bf4c8c 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Quartz; using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.Auditing; @@ -15,6 +16,8 @@ namespace Unity.Modules.Shared.Utils; /// public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBase { + protected virtual bool UsesTenantAuditScopes => false; + public sealed override async Task Execute(IJobExecutionContext context) { var auditingManager = ServiceProvider.GetRequiredService(); @@ -23,16 +26,34 @@ public sealed override async Task Execute(IJobExecutionContext context) var currentTenant = ServiceProvider.GetRequiredService(); using (BackgroundJobExecutionContext.Use()) - using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) { - AddWorkerAuditAction(auditingManager, context); - await ExecuteInBackgroundAsync(context); + if (UsesTenantAuditScopes) + { + using (BackgroundJobContext.SetExecutionContext(principalAccessor, currentTenant, tenantId: null)) + { + await ExecuteInBackgroundAsync(context); + } + return; + } - if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) { - using (currentTenant.Change(null)) + AddWorkerAuditAction(auditingManager, context); + await ExecuteInBackgroundAsync(context); + + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) { - await auditingStore.SaveAsync(log); + try + { + using (currentTenant.Change(null)) + { + await auditingStore.SaveAsync(log); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to persist audit log for background worker {WorkerName}; worker work was already committed.", GetType().Name); + } } } } @@ -43,16 +64,23 @@ protected async Task ExecuteInTenantAuditScopeAsync(Guid? tenantId, Func a var auditingManager = ServiceProvider.GetRequiredService(); var auditingStore = ServiceProvider.GetRequiredService(); var currentTenant = ServiceProvider.GetRequiredService(); + var principalAccessor = ServiceProvider.GetRequiredService(); - using (currentTenant.Change(tenantId)) - using (BackgroundJobContext.BeginAuditScope(auditingManager, tenantId)) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId)) { AddWorkerAuditAction(auditingManager, null); await action(); if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) { - await auditingStore.SaveAsync(log); + try + { + await auditingStore.SaveAsync(log); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to persist audit log for tenant {TenantId}; worker work was already committed.", tenantId); + } } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs index a987be15bc..00b5d8b58f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs @@ -16,6 +16,8 @@ namespace Unity.GrantManager.Applicants.BackgroundWorkers; [DisallowConcurrentExecution] public class FiscalYearEndRolloverWorker : UnityQuartzBackgroundWorkerBase { + protected override bool UsesTenantAuditScopes => true; + private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly ITenantRepository _tenantRepository; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index 0e4d5f333e..682a1669ac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -34,6 +34,8 @@ namespace Unity.GrantManager.Events [DisallowConcurrentExecution] public class DateBasedScheduledNotificationJob : UnityQuartzBackgroundWorkerBase, ITransientDependency { + protected override bool UsesTenantAuditScopes => true; + private readonly IRepository _scheduledNotificationRepository; private readonly IApplicationRepository _applicationRepository; private readonly IRepository _trackingRepository; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs index a00147f8b6..afea577612 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs @@ -23,6 +23,8 @@ namespace Unity.GrantManager.GrantsPortal.Notifications; [DisallowConcurrentExecution] public class ApplicantUpdateEmailRecoveryWorker : UnityQuartzBackgroundWorkerBase { + protected override bool UsesTenantAuditScopes => true; + private readonly IServiceProvider _serviceProvider; public ApplicantUpdateEmailRecoveryWorker(IServiceProvider serviceProvider) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 995d5421c7..0b35a13ea7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -16,6 +16,8 @@ namespace Unity.GrantManager.HealthChecks.BackgroundWorkers [DisallowConcurrentExecution] public class DataHealthCheckWorker : UnityQuartzBackgroundWorkerBase { + protected override bool UsesTenantAuditScopes => true; + private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; private readonly IEmailNotificationService _emailNotificationService; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs index 8e8750009b..5cb5230346 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs @@ -19,6 +19,8 @@ namespace Unity.GrantManager.History; [DisallowConcurrentExecution] public class AuditLogCleanupWorker : UnityQuartzBackgroundWorkerBase, ITransientDependency { + protected override bool UsesTenantAuditScopes => true; + private readonly IServiceProvider _serviceProvider; private readonly ITenantRepository _tenantRepository; private readonly ISettingProvider _settingProvider; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index c70899448d..b34a9a0964 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -15,6 +15,8 @@ namespace Unity.GrantManager.Intakes.BackgroundWorkers [DisallowConcurrentExecution] public class IntakeSyncWorker : UnityQuartzBackgroundWorkerBase { + protected override bool UsesTenantAuditScopes => true; + private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; private readonly IApplicationFormSycnronizationService _applicationFormSynchronizationService; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index 0f03a5eb7b..49db72b010 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -21,6 +21,8 @@ namespace Unity.GrantManager.Messaging; [DisallowConcurrentExecution] public abstract class InboxWorkerBase : UnityQuartzBackgroundWorkerBase { + protected override bool UsesTenantAuditScopes => true; + private readonly IServiceProvider _serviceProvider; /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index 8fcbdf413f..8404c45165 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -20,7 +20,6 @@ namespace Unity.GrantManager.Web.Controllers { - [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [Route("api/form-notifications")] [ApiController] public class FormNotificationsApiController(IApplicationStatusService statusService, @@ -37,6 +36,7 @@ public class FormNotificationsApiController(IApplicationStatusService statusServ { + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("payment-statuses")] public ActionResult> GetPaymentStatuses() { @@ -62,6 +62,7 @@ public ActionResult> GetPaymentStatuses() // In-memory storage removed; persisting to ScheduledNotifications table via IAutomatedNotificationAppService + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("templates")] public async Task>> GetTemplates([FromQuery] string? templateType = null) { @@ -95,6 +96,7 @@ public async Task> GetApplicantTe return Ok(await applicantEmailTemplateService.GetPreviewAsync(applicantId, templateId)); } + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("templates/{templateId:guid}/resolved-recipients")] public async Task> GetResolvedTemplateRecipients(Guid templateId, [FromQuery] Guid? applicationId = null) { @@ -190,6 +192,7 @@ public async Task> CopyTemplateAttachme return Ok(new CopyAttachmentsResponseDto { AttachmentCount = copiedCount }); } + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("email-template/{templateId}/validate-attachments")] public async Task ValidateTemplateAttachments(Guid templateId) { @@ -206,6 +209,7 @@ public async Task> DeleteOriginAttachme return Ok(new CopyAttachmentsResponseDto { AttachmentCount = deletedCount }); } + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("statuses")] public async Task>> GetApplicationStatuses() { @@ -214,6 +218,7 @@ public async Task>> GetApplicationStatuses() return Ok(list); } + [Authorize(NotificationsPermissions.Email.Send)] [HttpGet("recipients")] public async Task>> GetRecipients([FromQuery] string category) { @@ -237,6 +242,7 @@ public async Task>> GetRecipients([FromQuery] st return Ok(new List()); } + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [HttpGet("{formId}")] public async Task>> GetForForm(string formId) { @@ -378,6 +384,7 @@ public async Task CancelNotification(string formId, Guid id) return NoContent(); } + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [HttpGet("can-delete-template/{templateId:guid}")] public async Task> CanDeleteTemplate(Guid templateId) { @@ -408,6 +415,7 @@ public async Task> CanDeleteTemplate(Guid templateId) return Ok(new { canDelete = true, errorMessage = (string?)null, notificationPlanNames = Array.Empty() }); } + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [HttpGet("template-notification-plans/{templateId:guid}")] public async Task> GetTemplateNotificationPlans(Guid templateId) { From b908f56789f5045ec181a5bd7047e0db2c569b6c Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 16:11:37 -0700 Subject: [PATCH 08/32] feature/AB#34582-TightenApi-Co-Pilot --- .../DataGrid/DataGridWriteService.cs | 2 +- .../Templates/TemplatesService.cs | 14 +++- .../NotificationsSettingGroup/Default.js | 6 +- .../TemplatesServiceTests.cs | 5 +- .../Interfaces/IQueueChannelProvider.cs | 6 +- .../QueueChannelProvider.cs | 69 ++++++++++++++++--- .../QueueConsumerHandler.cs | 12 +++- 7 files changed, 91 insertions(+), 23 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs index 7489f231e2..8144f245e5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs @@ -279,7 +279,7 @@ internal async Task UpdateRowAsync(RowInputData rowInputDa }; } - internal async Task DeleteRowAsync(Guid valueId, uint row, Guid worksheetInstanceId) + public async Task DeleteRowAsync(Guid valueId, uint row, Guid worksheetInstanceId) { var currentValue = await customFieldValueAppService.GetAsync(valueId); var dataGridValue = DataGridServiceUtils.DeserializeDataGridValue(currentValue.CurrentValue); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs index 1468102240..14975a76d6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -18,18 +18,21 @@ public class TemplateService : ApplicationService, ITemplateService private readonly ITemplatesRepository _templatesRepository; private readonly ICurrentTenant _currentTenant; private readonly ITemplateVariablesRepository _templateVariablesRepository; + private readonly Volo.Abp.Domain.Repositories.IRepository _scheduledNotificationsRepository; public TemplateService( ITemplatesRepository templatesRepository, ICurrentTenant currentTenant, - ITemplateVariablesRepository templateVariablesRepository + ITemplateVariablesRepository templateVariablesRepository, + Volo.Abp.Domain.Repositories.IRepository scheduledNotificationsRepository ) { _templatesRepository = templatesRepository; _currentTenant = currentTenant; _templateVariablesRepository = templateVariablesRepository; + _scheduledNotificationsRepository = scheduledNotificationsRepository; } [Authorize] @@ -87,6 +90,13 @@ public async Task> GetTemplatesByTenant() [Authorize] public async Task DeleteTemplate(Guid id) { + // Enforce server-side reference check so a client-side preflight failure cannot bypass the in-use safeguard. + var inUse = await _scheduledNotificationsRepository.AnyAsync(x => x.EmailTemplateId == id); + if (inUse) + { + throw new Volo.Abp.BusinessException("Notifications:TemplateInUse", "Template is referenced by scheduled notifications and cannot be deleted."); + } + await _templatesRepository.DeleteAsync(id); } [Authorize] diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js index 630dd8bb11..6990c18554 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js @@ -844,10 +844,8 @@ $(function () { } function handleDeleteCanCheckError(templateId) { - // If check fails, proceed with deletion - proceedWithDelete(templateId, function () { - PubSub.publish('reload_templates_table_with_close'); - }); + // If check fails, do not proceed with deletion. Let the user know the preflight check failed. + abp.notify.error('Unable to verify whether this template can be safely deleted. Deletion has been cancelled.'); } function checkCanDeleteTemplate(templateId) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs index 60664aafcd..2a8edc4728 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NSubstitute; @@ -52,6 +52,7 @@ private static TemplateService CreateService(IEnumerable varia return new TemplateService( Substitute.For(), Substitute.For(), - variablesRepository); + variablesRepository, + Substitute.For>()); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs index db472ad5eb..d161313cb3 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs @@ -20,9 +20,11 @@ public interface IQueueChannelProvider : IDisposable where TQueue /// /// Republishes a failed delivery to a delayed retry queue. The returned task completes - /// only after RabbitMQ confirms the retry publish. + /// only after RabbitMQ confirms the retry publish. Returns true when the retry handoff + /// was accepted by the broker; false if the original delivery was requeued or channel recovery + /// was arranged due to a publishing failure. /// - Task PublishRetryAsync(IChannel channel, BasicDeliverEventArgs delivery, int retryCount, TimeSpan delay, CancellationToken cancellationToken = default); + Task PublishRetryAsync(IChannel channel, BasicDeliverEventArgs delivery, int retryCount, TimeSpan delay, CancellationToken cancellationToken = default); /// /// Returns a channel obtained from so it can be pooled diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 3a46e2a315..38b26e518a 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; @@ -72,7 +72,7 @@ public async Task GetChannelAsync() } } - public async Task PublishRetryAsync( + public async Task PublishRetryAsync( IChannel channel, BasicDeliverEventArgs delivery, int retryCount, @@ -95,13 +95,60 @@ public async Task PublishRetryAsync( } }; - await channel.BasicPublishAsync( - exchange: retryExchange, - routingKey: retryQueue, - mandatory: false, - basicProperties: properties, - body: delivery.Body, - cancellationToken: cancellationToken); + // Use mandatory=true so unroutable publishes are reported by publisher-confirms. + // Retry publish failures are handled explicitly: retry with backoff, and if all attempts fail + // requeue the original delivery (or close the channel to force broker redelivery) so the + // message is not lost. + var attempts = 0; + const int maxAttempts = 3; + var backoff = TimeSpan.FromMilliseconds(100); + while (true) + { + attempts++; + try + { + await channel.BasicPublishAsync( + exchange: retryExchange, + routingKey: retryQueue, + mandatory: true, + basicProperties: properties, + body: delivery.Body, + cancellationToken: cancellationToken); + + return true; + } + catch (Exception ex) when (attempts < maxAttempts) + { + _logger.LogWarning(ex, "Retry publish attempt {Attempt} failed for message {MessageId}; backing off {Backoff} before retrying.", attempts, delivery.BasicProperties?.MessageId, backoff); + try + { + await Task.Delay(backoff, cancellationToken); + } + catch (OperationCanceledException) { } + backoff = TimeSpan.FromTicks(backoff.Ticks * 2); + continue; + } + catch (Exception ex) + { + _logger.LogError(ex, "Retry publish failed for message {MessageId}; attempting to requeue original delivery.", delivery.BasicProperties?.MessageId); + try + { + // Requeue the original delivery so it will be redelivered when the broker recovers. + await channel.BasicNackAsync(delivery.DeliveryTag, multiple: false, requeue: true); + } + catch (Exception nackEx) + { + _logger.LogError(nackEx, "Failed to BasicNack original delivery after retry publish failure for message {MessageId}. Closing channel to force redelivery.", delivery.BasicProperties?.MessageId); + try + { + await channel.CloseAsync(); + } + catch { } + } + + return false; + } + } } public void ReturnChannel(IChannel channel) @@ -183,7 +230,9 @@ await channel.QueueDeclareAsync( { "x-queue-type", "quorum" }, { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) }, { "x-dead-letter-exchange", $"{_queueName}.exchange" }, - { "x-dead-letter-routing-key", _queueName } + { "x-dead-letter-routing-key", _queueName }, + { "x-overflow", "reject-publish" }, + { "x-dead-letter-strategy", "at-least-once" } }); await channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null); } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index 4593370b3b..7be8bc66a7 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -151,8 +151,16 @@ private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea) _queueName, delay); - await channelProvider.PublishRetryAsync(consumingChannel, ea, nextRetryCount, delay); - await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); + var retryAccepted = await channelProvider.PublishRetryAsync(consumingChannel, ea, nextRetryCount, delay); + if (retryAccepted) + { + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); + } + else + { + // Retry publish failed; the provider requeued the original delivery or arranged channel recovery. + // Do not acknowledge the original delivery here so it will be redelivered. + } } } From b3c32550eb993b9907edad6e54e6d8d5c7c36aa2 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 16:11:46 -0700 Subject: [PATCH 09/32] feature/AB#34582-TightenApi-Co-Pilot --- .../Templates/TemplatesService.cs | 2 +- .../TemplatesServiceTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs index 14975a76d6..db64030ef2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs @@ -91,7 +91,7 @@ public async Task> GetTemplatesByTenant() public async Task DeleteTemplate(Guid id) { // Enforce server-side reference check so a client-side preflight failure cannot bypass the in-use safeguard. - var inUse = await _scheduledNotificationsRepository.AnyAsync(x => x.EmailTemplateId == id); +var inUse = await _scheduledNotificationsRepository.AnyAsync((Unity.GrantManager.Notifications.ScheduledNotification x) => x.EmailTemplateId == id); if (inUse) { throw new Volo.Abp.BusinessException("Notifications:TemplateInUse", "Template is referenced by scheduled notifications and cannot be deleted."); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs index 2a8edc4728..2c7f0b2bc1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs @@ -53,6 +53,6 @@ private static TemplateService CreateService(IEnumerable varia Substitute.For(), Substitute.For(), variablesRepository, - Substitute.For>()); +Substitute.For>()); } } \ No newline at end of file From a89a24e7ab9f5f2bce06851ed2985b8273130211 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 16:15:04 -0700 Subject: [PATCH 10/32] feature/AB#34582-TightenApi-Co-Pilot --- .../Templates/TemplatesService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs index db64030ef2..3a579e6037 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; @@ -57,7 +58,7 @@ public TemplateService( [Authorize] public async Task UpdateTemplate(Guid id, EmailTempateDto templateDto) { - + EmailTemplate template = await _templatesRepository.GetAsync(id); template.Description = templateDto.Description; @@ -91,7 +92,8 @@ public async Task> GetTemplatesByTenant() public async Task DeleteTemplate(Guid id) { // Enforce server-side reference check so a client-side preflight failure cannot bypass the in-use safeguard. -var inUse = await _scheduledNotificationsRepository.AnyAsync((Unity.GrantManager.Notifications.ScheduledNotification x) => x.EmailTemplateId == id); + var scheduledQuery = await _scheduledNotificationsRepository.GetQueryableAsync(); + var inUse = await scheduledQuery.AnyAsync(x => x.EmailTemplateId == id); if (inUse) { throw new Volo.Abp.BusinessException("Notifications:TemplateInUse", "Template is referenced by scheduled notifications and cannot be deleted."); @@ -104,8 +106,8 @@ public async Task DeleteTemplate(Guid id) { var data = await _templatesRepository.GetByNameAsync(name); return data; - } - + } + [Authorize] public Task> GetTemplateTypes() { From c8bf58c98beb61740f11371f620e7b75f088cc0d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 16:23:03 -0700 Subject: [PATCH 11/32] feature/AB#34582-TightenApi-Co-Pilot --- .../src/Unity.AI.Application/Unity.AI.Application.csproj | 4 ++-- .../modules/Unity.SharedKernel/Unity.SharedKernel.csproj | 3 ++- .../Unity.GrantManager.Application.Contracts.csproj | 3 ++- .../Unity.GrantManager.Application.csproj | 3 ++- .../Unity.GrantManager.DbMigrator.csproj | 3 ++- .../Unity.GrantManager.Domain.Shared.csproj | 3 ++- .../Unity.GrantManager.Domain.csproj | 3 ++- .../Unity.GrantManager.EntityFrameworkCore.csproj | 3 ++- .../Unity.GrantManager.HttpApi.Client.csproj | 3 ++- .../Unity.GrantManager.HttpApi.csproj | 3 ++- .../src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj | 3 ++- 11 files changed, 22 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj index 157e19c3fa..61e2236b05 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,7 +10,7 @@ - + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj index fb10e454cf..e34c6a3982 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj @@ -1,4 +1,4 @@ - + @@ -27,5 +27,6 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj index 8b5977867e..3b307c8e3f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -22,6 +22,7 @@ + **/Assessments/AssessmentListItemDto.cs, **/Assessments/AssessmentScoresDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj index 9c3ca77020..10ad818ac7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj @@ -1,4 +1,4 @@ - + @@ -57,6 +57,7 @@ + \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj index 11ae6d5466..533727757f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj @@ -1,4 +1,4 @@ - + @@ -33,6 +33,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj index 3647cb4840..8fbe3ef7ff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj @@ -1,4 +1,4 @@ - + @@ -32,6 +32,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 8def4f7e90..730923f93b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -1,4 +1,4 @@ - + @@ -29,6 +29,7 @@ + **/Assessments/Assessment.cs, **/Assessments/AssessmentWithAssessorQueryResultItem.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj index abdd042926..234ea1c5a6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj @@ -1,4 +1,4 @@ - + @@ -104,6 +104,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj index 6ed03b7432..a53c15a69f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/Unity.GrantManager.HttpApi.Client.csproj @@ -1,4 +1,4 @@ - + @@ -19,6 +19,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj index ae807c1d10..3a33a28818 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj @@ -1,4 +1,4 @@ - + @@ -20,6 +20,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index c1366b7ee4..71edec8409 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -1,4 +1,4 @@ - + @@ -81,6 +81,7 @@ + From 2121dd1cb9c0627a48e1b947045594527dd0e9fe Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 16:46:48 -0700 Subject: [PATCH 12/32] feature/AB#34582-TightenApi-Co-Pilot --- .../QuestionSelectListWidget/QuestionSelectListViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionSelectListWidget/QuestionSelectListViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionSelectListWidget/QuestionSelectListViewModel.cs index b61da02057..0f46f4f783 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionSelectListWidget/QuestionSelectListViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/QuestionSelectListWidget/QuestionSelectListViewModel.cs @@ -7,7 +7,7 @@ public class QuestionSelectListViewModel : RequiredFieldViewModel public Guid QuestionId { get; set; } public bool IsDisabled { get; set; } public string Answer { get; set; } = string.Empty; - public string Definition { get; set; } = "{}"; + public new string Definition { get; set; } = "{}"; public bool IsHumanConfirmed { get; set; } = true; public string? AICitation { get; set; } public int? AIConfidence { get; set; } From 423db8ec10f01ac2e452af3035dc81bb9b0872dd Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 23:08:03 -0700 Subject: [PATCH 13/32] feature/AB#34582-TightenApi-Co-Pilot --- .../Templates/TemplatesService.cs | 59 +++++++++---------- .../QueueChannelProvider.cs | 11 ++-- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 8 ++- .../AutomatedNotificationAppService.cs | 14 +++-- .../GrantTenantDbContext.cs | 13 ++-- 5 files changed, 53 insertions(+), 52 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs index 3a579e6037..a49dbbbf47 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs @@ -7,36 +7,28 @@ using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; +using Unity.Notifications.Permissions; namespace Unity.Notifications.Templates; [Dependency(ReplaceServices = false)] [ExposeServices(typeof(TemplateService), typeof(ITemplateService))] -public class TemplateService : ApplicationService, ITemplateService -{ - - private readonly ITemplatesRepository _templatesRepository; - private readonly ICurrentTenant _currentTenant; - private readonly ITemplateVariablesRepository _templateVariablesRepository; - private readonly Volo.Abp.Domain.Repositories.IRepository _scheduledNotificationsRepository; +public class TemplateService( + ITemplatesRepository templatesRepository, + ICurrentTenant currentTenant, + ITemplateVariablesRepository templateVariablesRepository, + Volo.Abp.Domain.Repositories.IRepository scheduledNotificationsRepository + ) : ApplicationService, ITemplateService +{ - public TemplateService( - ITemplatesRepository templatesRepository, - ICurrentTenant currentTenant, - ITemplateVariablesRepository templateVariablesRepository, - Volo.Abp.Domain.Repositories.IRepository scheduledNotificationsRepository + private readonly ITemplatesRepository _templatesRepository = templatesRepository; + private readonly ICurrentTenant _currentTenant = currentTenant; + private readonly ITemplateVariablesRepository _templateVariablesRepository = templateVariablesRepository; + private readonly Volo.Abp.Domain.Repositories.IRepository _scheduledNotificationsRepository = scheduledNotificationsRepository; - ) - { - _templatesRepository = templatesRepository; - _currentTenant = currentTenant; - _templateVariablesRepository = templateVariablesRepository; - _scheduledNotificationsRepository = scheduledNotificationsRepository; - } - - [Authorize] + [Authorize(NotificationsPermissions.Email.Default)] public async Task CreateAsync(EmailTempateDto templateDto) { var templateType = NormalizeTemplateType(templateDto.TemplateType); @@ -55,7 +47,7 @@ public TemplateService( templateType)); } - [Authorize] + [Authorize(NotificationsPermissions.Email.Send)] public async Task UpdateTemplate(Guid id, EmailTempateDto templateDto) { @@ -64,7 +56,7 @@ public TemplateService( template.Description = templateDto.Description; template.Subject = templateDto.Subject; template.BodyText = templateDto.BodyText; - template.BodyHTML = templateDto.BodyHTML != null ? templateDto.BodyHTML : ""; + template.BodyHTML = templateDto.BodyHTML ?? ""; template.Name = templateDto.Name; template.SendFrom = templateDto.SendFrom; template.RecipientCategory = templateDto.RecipientCategory; @@ -76,19 +68,21 @@ public TemplateService( return updatedTemplate; } - [Authorize] + [Authorize(NotificationsPermissions.Email.Default)] public async Task> GetTemplatesByTenant() { var tenentId = _currentTenant.Id; return await _templatesRepository.GetByTenentIdAsync(tenentId); } - [Authorize] + + // Shared lookup endpoint: accessible to anyone with email/notification access (lookup, schedule, send) + [Authorize(NotificationsPermissions.Email.Default)] public async Task GetTemplateById(Guid id) { return await _templatesRepository.GetAsync(id); } - [Authorize] + [Authorize(NotificationsPermissions.Email.Send)] public async Task DeleteTemplate(Guid id) { // Enforce server-side reference check so a client-side preflight failure cannot bypass the in-use safeguard. @@ -101,14 +95,15 @@ public async Task DeleteTemplate(Guid id) await _templatesRepository.DeleteAsync(id); } - [Authorize] + [Authorize(NotificationsPermissions.Email.Send)] public async Task GetTemplateByName(string name) { var data = await _templatesRepository.GetByNameAsync(name); return data; } - [Authorize] + // Shared lookup endpoint: accessible to anyone with email/notification access + [Authorize(NotificationsPermissions.Email.Default)] public Task> GetTemplateTypes() { return Task.FromResult(new List @@ -118,15 +113,15 @@ public Task> GetTemplateTypes() }); } - [Authorize] + // Shared lookup endpoint: accessible to anyone with email/notification access (forms, templates, scheduling) + [Authorize(NotificationsPermissions.Email.Default)] public async Task> GetTemplateVariables(string? templateType = null) { var selectedType = NormalizeTemplateType(templateType); var templateVariables = await _templateVariablesRepository.GetQueryableAsync(); - return templateVariables + return [.. templateVariables .Where(x => x.TemplateType == selectedType) - .OrderBy(x => x.Name) - .ToList(); + .OrderBy(x => x.Name)]; } private static string NormalizeTemplateType(string? templateType) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 38b26e518a..99e1a994a2 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -138,12 +138,11 @@ await channel.BasicPublishAsync( } catch (Exception nackEx) { - _logger.LogError(nackEx, "Failed to BasicNack original delivery after retry publish failure for message {MessageId}. Closing channel to force redelivery.", delivery.BasicProperties?.MessageId); - try - { - await channel.CloseAsync(); - } - catch { } + // BasicNack failure means the channel is compromised. Do NOT close it here - if it's an active consumer channel, + // closing it will interrupt message delivery. Log the error and return false to signal failure; the consumer + // handler or connection recovery will handle channel state. The broker will eventually redeliver the message + // via its internal recovery mechanisms even if this immediate nack fails. + _logger.LogError(nackEx, "Failed to BasicNack original delivery after retry publish failure for message {MessageId}. Channel may require recovery by consumer handler.", delivery.BasicProperties?.MessageId); } return false; diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index f366bf4c8c..163d648d59 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -52,7 +52,9 @@ public sealed override async Task Execute(IJobExecutionContext context) } catch (Exception ex) { - Logger.LogError(ex, "Failed to persist audit log for background worker {WorkerName}; worker work was already committed.", GetType().Name); + // Audit persistence failures after transaction commit should not cause worker to retry. + // The business transaction already succeeded; audit logging is non-critical. + Logger.LogWarning(ex, "Failed to persist audit log for background worker {WorkerName}; worker work was already committed (not retrying).", GetType().Name); } } } @@ -79,7 +81,9 @@ protected async Task ExecuteInTenantAuditScopeAsync(Guid? tenantId, Func a } catch (Exception ex) { - Logger.LogError(ex, "Failed to persist audit log for tenant {TenantId}; worker work was already committed.", tenantId); + // Audit persistence failures after transaction commit should not cause worker to retry. + // The business transaction already succeeded; audit logging is non-critical. + Logger.LogWarning(ex, "Failed to persist audit log for tenant {TenantId}; worker work was already committed (not retrying).", tenantId); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index 3f59ebe8df..c8898b4417 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -6,16 +6,16 @@ using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; using Microsoft.EntityFrameworkCore; -using Unity.GrantManager.Permissions; +using Unity.Notifications.Permissions; namespace Unity.GrantManager.Notifications { - [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] + [Authorize] public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService { private readonly IRepository _repository = repository; - [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] + [Authorize(NotificationsPermissions.Email.ScheduleCreate)] public async Task CreateAsync(CreateUpdateNotificationDto input) { var entity = new ScheduledNotification @@ -55,13 +55,13 @@ public async Task CreateAsync(CreateUpdateNotificationDto input }; } - [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] + [Authorize(NotificationsPermissions.Email.ScheduleCancel)] public async Task DeleteAsync(Guid id) { await _repository.DeleteAsync(id, autoSave: true); } - [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] + [Authorize(NotificationsPermissions.Email.ScheduleCancel)] public async Task CancelAsync(Guid id) { var entity = await _repository.GetAsync(id); @@ -69,6 +69,7 @@ public async Task CancelAsync(Guid id) await _repository.UpdateAsync(entity, autoSave: true); } + [Authorize(NotificationsPermissions.Email.NotificationsTab)] public async Task GetAsync(Guid id) { var e = await _repository.GetAsync(id); @@ -92,6 +93,7 @@ public async Task GetAsync(Guid id) }; } + [Authorize(NotificationsPermissions.Email.NotificationsTab)] public async Task> GetListAsync(GetNotificationsInput input) { var query = await _repository.GetQueryableAsync(); @@ -131,7 +133,7 @@ public async Task> GetListAsync(GetNotifications return new PagedResultDto(total, items); } - [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] + [Authorize(NotificationsPermissions.Email.Schedule)] public async Task UpdateAsync(Guid id, CreateUpdateNotificationDto input) { var e = await _repository.GetAsync(id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs index c80a60b025..c979825ad1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -17,6 +17,7 @@ using Unity.GrantManager.Intakes; using Unity.GrantManager.Notifications; using Unity.Notifications.EntityFrameworkCore; +using Unity.Notifications.Templates; using Unity.Payments.EntityFrameworkCore; using Unity.Reporting.EntityFrameworkCore; using Volo.Abp.Data; @@ -26,7 +27,7 @@ namespace Unity.GrantManager.EntityFrameworkCore { [ConnectionStringName(GrantManagerConsts.DefaultTenantConnectionStringName)] - public class GrantTenantDbContext : AbpDbContext + public class GrantTenantDbContext(DbContextOptions options) : AbpDbContext(options) { #region Domain Entities public DbSet Intakes { get; set; } @@ -64,11 +65,8 @@ public class GrantTenantDbContext : AbpDbContext public DbSet ReportsHistories { get; set; } public DbSet ScheduledNotifications { get; set; } public DbSet ScheduledNotificationTrackings { get; set; } - #endregion - public GrantTenantDbContext(DbContextOptions options) : base(options) - { - } + #endregion protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -489,6 +487,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(x => x.DateField).HasMaxLength(128); b.Property(x => x.TenantId).HasColumnName("TenantId"); b.HasIndex(x => x.TenantId); + b.HasIndex(x => x.EmailTemplateId); + // Foreign key constraint prevents concurrent template deletion races. + b.HasOne().WithMany().HasForeignKey(x => x.EmailTemplateId).IsRequired().OnDelete(DeleteBehavior.Restrict); b.TryConfigureExtraProperties(); }); @@ -503,7 +504,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => x.ApplicationId); b.HasIndex(x => x.ScheduledNotificationId); b.HasIndex(x => x.CreationTime); - b.HasIndex(new[] { nameof(ScheduledNotificationTracking.ApplicationId), nameof(ScheduledNotificationTracking.ScheduledNotificationId), nameof(ScheduledNotificationTracking.DateField), nameof(ScheduledNotificationTracking.TriggerDate) }).IsUnique(); + b.HasIndex([nameof(ScheduledNotificationTracking.ApplicationId), nameof(ScheduledNotificationTracking.ScheduledNotificationId), nameof(ScheduledNotificationTracking.DateField), nameof(ScheduledNotificationTracking.TriggerDate)]).IsUnique(); b.HasOne().WithMany().HasForeignKey(x => x.ApplicationId).IsRequired().OnDelete(DeleteBehavior.Cascade); b.HasOne().WithMany().HasForeignKey(x => x.ScheduledNotificationId).IsRequired().OnDelete(DeleteBehavior.Cascade); }); From a45341586a0da2056593d55d1c068b37f3f31d98 Mon Sep 17 00:00:00 2001 From: James Pasta <129337673+JamesPasta@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:29:07 -0700 Subject: [PATCH 14/32] Potential fix for pull request finding 'Concurrent retry publishing shares consuming channel' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../QueueChannelProvider.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 99e1a994a2..f2e5395560 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -107,13 +107,21 @@ public async Task PublishRetryAsync( attempts++; try { - await channel.BasicPublishAsync( - exchange: retryExchange, - routingKey: retryQueue, - mandatory: true, - basicProperties: properties, - body: delivery.Body, - cancellationToken: cancellationToken); + var publisherChannel = await GetChannelAsync(); + try + { + await publisherChannel.BasicPublishAsync( + exchange: retryExchange, + routingKey: retryQueue, + mandatory: true, + basicProperties: properties, + body: delivery.Body, + cancellationToken: cancellationToken); + } + finally + { + ReturnChannel(publisherChannel); + } return true; } From 50aeebf44215e951964825c9c92e9ba1dcf67b47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:23:24 +0000 Subject: [PATCH 15/32] Bump dompurify Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.9 to 3.4.16. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.9...3.4.16) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.16 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .../src/Unity.GrantManager.Web/package.json | 2 +- .../src/Unity.GrantManager.Web/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json index c4fedb9f3b..a75e937ffd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json @@ -15,7 +15,7 @@ "datatables.net-select-bs5": "~3.1.3", "datatables.net-staterestore": "~1.4.3", "datatables.net-staterestore-dt": "~1.4.3", - "dompurify": "~3.4.1", + "dompurify": "~3.4.16", "echarts": "~6.1.0", "formiojs": "4.21.7", "handlebars": "~4.7.9", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock index 11fdcdb273..672fe4a789 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock @@ -539,10 +539,10 @@ dom-set@^1.0.1: is-array "^1.0.1" iselement "^1.1.4" -dompurify@^3.0.5, dompurify@~3.4.1: - version "3.4.9" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz" - integrity sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ== +dompurify@^3.0.5, dompurify@~3.4.16: + version "3.4.16" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.16.tgz#c51b3709ea15ee9abb07e804f2ef30ea9b953128" + integrity sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ== optionalDependencies: "@types/trusted-types" "^2.0.7" From 4ce698b2fe8f26c1ae3b4ac35f357a4143687219 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 15 Oct 2026 14:32:22 -0700 Subject: [PATCH 16/32] feature/AB#34582-TightenApi-Co-Pilot --- applications/Unity.GrantManager/common.props | 3 +- applications/Unity.GrantManager/global.json | 7 +++ .../Templates/TemplatesService.cs | 11 +++- .../Templates/ITemplatesRepository.cs | 1 + ...cationsDbContextModelCreatingExtensions.cs | 2 +- .../Repositories/TemplatesRepository.cs | 32 +++++++++-- .../Pages/Notifications/Index.js | 5 +- .../TemplatesServiceTests.cs | 1 + .../TemplateLockRepositoryTests.cs | 47 ++++++++++++++++ .../Domain/PaymentRequests/PaymentRequest.cs | 13 +++++ .../Domain/Services/PaymentsManager.cs | 2 + .../UpdatePaymentRequestStatus.cshtml.cs | 30 +--------- .../PaymentRequest_Constructor_Tests.cs | 28 +++++++++- .../Toolbar/UserMenu/Default.cshtml | 9 +-- .../UX2/Components/Topbar/Default.cshtml | 2 +- applications/Unity.GrantManager/package.json | 5 ++ .../AutomatedNotificationAppService.cs | 22 +++++++- .../DbMigratorHostedService.cs | 28 +++++----- ...EntityFrameworkCoreTenantDatabasePurger.cs | 2 +- .../Pages/ApplicationForms/TokenModal.js | 13 ++--- .../ScheduledNotificationTemplateLockTests.cs | 55 +++++++++++++++++++ applications/Unity.GrantManager/yarn.lock | 11 ++++ .../notifications-scheduled-notifications.md | 2 + .../payments/payments-approval-workflow.md | 9 ++- documentation/payments/payments-overview.md | 2 +- documentation/payments/payments-roadmap.md | 4 -- documentation/payments/payments-web-ui.md | 2 +- 27 files changed, 265 insertions(+), 83 deletions(-) create mode 100644 applications/Unity.GrantManager/global.json create mode 100644 applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs create mode 100644 applications/Unity.GrantManager/package.json create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Notifications/ScheduledNotificationTemplateLockTests.cs diff --git a/applications/Unity.GrantManager/common.props b/applications/Unity.GrantManager/common.props index 85e7cf2d80..f461c7ac7d 100644 --- a/applications/Unity.GrantManager/common.props +++ b/applications/Unity.GrantManager/common.props @@ -9,6 +9,7 @@ $(NoWarn);0436 + $(NoWarn);NU1510 @@ -24,7 +25,7 @@ - + \ No newline at end of file diff --git a/applications/Unity.GrantManager/global.json b/applications/Unity.GrantManager/global.json new file mode 100644 index 0000000000..c396020fc7 --- /dev/null +++ b/applications/Unity.GrantManager/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.303", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs index a49dbbbf47..1935c9b638 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Templates/TemplatesService.cs @@ -7,6 +7,7 @@ using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; using Unity.Notifications.Permissions; namespace Unity.Notifications.Templates; @@ -18,6 +19,7 @@ public class TemplateService( ITemplatesRepository templatesRepository, ICurrentTenant currentTenant, ITemplateVariablesRepository templateVariablesRepository, + IUnitOfWorkManager unitOfWorkManager, Volo.Abp.Domain.Repositories.IRepository scheduledNotificationsRepository ) : ApplicationService, ITemplateService @@ -26,6 +28,7 @@ public class TemplateService( private readonly ITemplatesRepository _templatesRepository = templatesRepository; private readonly ICurrentTenant _currentTenant = currentTenant; private readonly ITemplateVariablesRepository _templateVariablesRepository = templateVariablesRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager = unitOfWorkManager; private readonly Volo.Abp.Domain.Repositories.IRepository _scheduledNotificationsRepository = scheduledNotificationsRepository; [Authorize(NotificationsPermissions.Email.Default)] @@ -85,7 +88,10 @@ public async Task> GetTemplatesByTenant() [Authorize(NotificationsPermissions.Email.Send)] public async Task DeleteTemplate(Guid id) { - // Enforce server-side reference check so a client-side preflight failure cannot bypass the in-use safeguard. + using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + var template = await _templatesRepository.LockActiveAsync(id) + ?? throw new Volo.Abp.Domain.Entities.EntityNotFoundException(typeof(EmailTemplate), id); + var scheduledQuery = await _scheduledNotificationsRepository.GetQueryableAsync(); var inUse = await scheduledQuery.AnyAsync(x => x.EmailTemplateId == id); if (inUse) @@ -93,7 +99,8 @@ public async Task DeleteTemplate(Guid id) throw new Volo.Abp.BusinessException("Notifications:TemplateInUse", "Template is referenced by scheduled notifications and cannot be deleted."); } - await _templatesRepository.DeleteAsync(id); + await _templatesRepository.DeleteAsync(template); + await uow.CompleteAsync(); } [Authorize(NotificationsPermissions.Email.Send)] public async Task GetTemplateByName(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Templates/ITemplatesRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Templates/ITemplatesRepository.cs index 5e7c38e3bd..6fa6448ddb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Templates/ITemplatesRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Templates/ITemplatesRepository.cs @@ -8,6 +8,7 @@ namespace Unity.Notifications.Templates public interface ITemplatesRepository : IBasicRepository { Task GetByIdAsync(Guid id, bool includeDetails = false); + Task LockActiveAsync(Guid id); Task> GetByTenentIdAsync(Guid? tenentId); Task GetByNameAsync(string name); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs index c09380facb..c74679e5b0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs @@ -147,7 +147,7 @@ public static void ConfigureNotifications( b.ConfigureByConvention(); b.Property(x => x.TemplateType) .HasMaxLength(64) - .HasDefaultValue(TemplateTypes.Applicant) + .HasDefaultValue(TemplateTypes.Application) .IsRequired(); }); modelBuilder.Entity(b => diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/TemplatesRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/TemplatesRepository.cs index d65f96c739..bf7ecfc47b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/TemplatesRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/TemplatesRepository.cs @@ -13,18 +13,40 @@ namespace Unity.Notifications.Repositories { - public class TemplatesRepository : EfCoreRepository, ITemplatesRepository + public class TemplatesRepository(IDbContextProvider dbContextProvider) : EfCoreRepository(dbContextProvider), ITemplatesRepository { - public TemplatesRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) - { - } - public async Task GetByIdAsync(Guid id, bool includeDetails = false) { var dbSet = await GetDbSetAsync(); return await dbSet.FirstOrDefaultAsync(s => s.Id == id); } + public async Task LockActiveAsync(Guid id) + { + var dbContext = await GetDbContextAsync(); + if (dbContext.Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + { + if (dbContext.Database.CurrentTransaction == null) + { + throw new InvalidOperationException("Template row locking requires a database transaction."); + } + + return await dbContext.EmailTemplates + .FromSqlInterpolated($"""SELECT * FROM "Notifications"."EmailTemplates" WHERE "Id" = {id} FOR UPDATE""") + .SingleOrDefaultAsync(); + } + + if (dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + // SQLite has no SELECT FOR UPDATE; a write serializes access inside a transaction. + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $"""UPDATE "EmailTemplates" SET "IsDeleted" = "IsDeleted" WHERE "Id" = {id}"""); + return await dbContext.EmailTemplates.SingleOrDefaultAsync(x => x.Id == id); + } + + throw new NotSupportedException("Template row locking requires PostgreSQL or SQLite."); + } + public async Task> GetByTenentIdAsync(Guid? tenentId) { var dbSet = await GetDbSetAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js index 8b5ca6bc51..98fb43576c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js @@ -216,7 +216,7 @@ $(function () { setDateRangeFilters(selectedRange, range); setDateRangeLocalStorage(selectedRange, range); updateDateInputsVisibility(); - notificationsTable.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function handleInputFilterChange() { @@ -230,8 +230,7 @@ $(function () { localStorage.setItem(STORAGE_KEYS.quickRange, 'custom'); localStorage.setItem(STORAGE_KEYS.fromDate, notificationTableFilters.dateFrom || ''); localStorage.setItem(STORAGE_KEYS.toDate, notificationTableFilters.dateTo || ''); - - notificationsTable.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function setDateRangeFilters(quickDateRange, range) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs index 2c7f0b2bc1..7e62b2d5a7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs @@ -53,6 +53,7 @@ private static TemplateService CreateService(IEnumerable varia Substitute.For(), Substitute.For(), variablesRepository, + Substitute.For(), Substitute.For>()); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs new file mode 100644 index 0000000000..79acc8fded --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Unity.Notifications.EntityFrameworkCore; +using Unity.Notifications.Templates; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.Notifications; + +public class TemplateLockRepositoryTests : NotificationsEntityFrameworkCoreTestBase +{ + [Fact] + public async Task LockActiveAsync_ReturnsOnlyActiveTemplatesForCurrentTenant() + { + var repository = GetRequiredService(); + var tenant = GetRequiredService(); + var tenantId = Guid.NewGuid(); + var template = new EmailTemplate(Guid.NewGuid(), "test", "", "", "", "", "") { TenantId = tenantId }; + + using (tenant.Change(tenantId)) + { + await WithUnitOfWorkAsync(() => repository.InsertAsync(template, autoSave: true)); + await WithUnitOfWorkAsync(async () => + { + (await repository.LockActiveAsync(template.Id)).ShouldNotBeNull(); + }); + } + + using (tenant.Change(Guid.NewGuid())) + { + await WithUnitOfWorkAsync(async () => + { + (await repository.LockActiveAsync(template.Id)).ShouldBeNull(); + }); + } + + using (tenant.Change(tenantId)) + { + await WithUnitOfWorkAsync(() => repository.DeleteAsync(template.Id)); + await WithUnitOfWorkAsync(async () => + { + (await repository.LockActiveAsync(template.Id)).ShouldBeNull(); + }); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 4742e2178b..1d1be952b3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -163,6 +163,19 @@ public PaymentRequest SetPaymentRequestStatus(PaymentRequestStatus status) return this; } + public void SetLevel3ApprovalRequired(bool required, Guid approvalId) + { + var level3Approval = ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3); + if (required && level3Approval == null) + { + ExpenseApprovals.Add(new ExpenseApproval(approvalId, ExpenseApprovalType.Level3)); + } + else if (!required && level3Approval != null) + { + ExpenseApprovals.Remove(level3Approval); + } + } + public PaymentRequest SetInvoiceStatus(string status) { InvoiceStatus = status; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs index 0960c1a8fb..e393e06100 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs @@ -127,6 +127,7 @@ public async Task TriggerAction(Guid paymentRequestsId, PaymentA { var index = paymentRequest.ExpenseApprovals.FindIndex(i => i.Type == ExpenseApprovalType.Level2); paymentRequest.ExpenseApprovals[index].Approve(currentUserId); + paymentRequest.SetLevel3ApprovalRequired(true, GuidGenerator.Create()); statusChangedTo = PaymentRequestStatus.L3Pending; } @@ -150,6 +151,7 @@ public async Task TriggerAction(Guid paymentRequestsId, PaymentA { var index = paymentRequest.ExpenseApprovals.FindIndex(i => i.Type == ExpenseApprovalType.Level2); paymentRequest.ExpenseApprovals[index].Approve(currentUserId); + paymentRequest.SetLevel3ApprovalRequired(false, GuidGenerator.Create()); } else if (await HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline) && paymentRequest.Status == PaymentRequestStatus.L3Pending) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs index 0568393a6e..c021fdba7f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading.Tasks; using Unity.GrantManager.ApplicationForms; -using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Shared; using Unity.Payments.Enums; @@ -28,7 +27,6 @@ public class UpdatePaymentRequestStatus( IPaymentsManager paymentsManager, IPaymentRequestAppService paymentRequestAppService, IApplicationFormAppService applicationFormAppService, - IPaymentRequestRepository paymentRepository, IPaymentConfigurationAppService paymentConfigurationAppService, IPermissionCheckerService permissionCheckerService, PaymentIdsCacheService cacheService) : AbpPageModel @@ -113,7 +111,7 @@ private async Task> BuildPaymentApprovalsAsync(List< PaymentThreshold = 0m; } - var approvalModel = await CreateApprovalModel(payment); + var approvalModel = CreateApprovalModel(payment); ValidateApprovalModel(approvalModel); if (await VerifyPermissionsAsync(payment.Status, approvalModel)) @@ -125,10 +123,9 @@ private async Task> BuildPaymentApprovalsAsync(List< return paymentApprovals; } - private async Task CreateApprovalModel(PaymentDetailsDto payment) + private PaymentsApprovalModel CreateApprovalModel(PaymentDetailsDto payment) { bool isL3ApprovalRequired = payment.Amount > PaymentThreshold; - await UpdateExpenseApprovalsAsync(payment, isL3ApprovalRequired); return new PaymentsApprovalModel { @@ -148,29 +145,6 @@ private async Task CreateApprovalModel(PaymentDetailsDto }; } - private async Task UpdateExpenseApprovalsAsync(PaymentDetailsDto payment, bool isL3ApprovalRequired) - { - if (payment.Status == PaymentRequestStatus.L2Pending) - { - var l3Approval = payment.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3); - PaymentRequest paymentEntity = await paymentRepository.GetAsync(payment.Id); - if (isL3ApprovalRequired && l3Approval == null) - { - paymentEntity.ExpenseApprovals.Add(new ExpenseApproval(Guid.NewGuid(), ExpenseApprovalType.Level3)); - } - else if (!isL3ApprovalRequired && l3Approval != null) - { - var l3ApprovalEntity = paymentEntity.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3); - if (l3ApprovalEntity != null) - { - paymentEntity.ExpenseApprovals.Remove(l3ApprovalEntity); - } - } - - await paymentRepository.UpdateAsync(paymentEntity); - } - } - private void ValidateApprovalModel(PaymentsApprovalModel request) { var validationResults = new List(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs index 7a646fc933..cbbe6edcf2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs @@ -1,9 +1,9 @@ using Shouldly; using System; using System.ComponentModel; +using System.Linq; using Unity.Payments.Codes; using Unity.Payments.Domain.Exceptions; -using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp; @@ -51,6 +51,32 @@ public void NormalConstructor_WithValidData_Succeeds() payment.ExpenseApprovals.Count.ShouldBe(2); } + [Fact] + public void SetLevel3ApprovalRequired_AddsOnlyOneLevel3Slot() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildNormalDto()); + var approvalId = Guid.NewGuid(); + + payment.SetLevel3ApprovalRequired(true, approvalId); + payment.SetLevel3ApprovalRequired(true, Guid.NewGuid()); + + payment.ExpenseApprovals.Count.ShouldBe(3); + payment.ExpenseApprovals.Single(x => x.Type == ExpenseApprovalType.Level3).Id.ShouldBe(approvalId); + } + + [Fact] + public void SetLevel3ApprovalRequired_RemovesOnlyLevel3Slot() + { + var payment = new PaymentRequest(Guid.NewGuid(), BuildNormalDto()); + payment.SetLevel3ApprovalRequired(true, Guid.NewGuid()); + + payment.SetLevel3ApprovalRequired(false, Guid.NewGuid()); + payment.SetLevel3ApprovalRequired(false, Guid.NewGuid()); + + payment.ExpenseApprovals.Count.ShouldBe(2); + payment.ExpenseApprovals.Select(x => x.Type).ShouldBe([ExpenseApprovalType.Level1, ExpenseApprovalType.Level2]); + } + #endregion #region Historical payment constructor diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml index c6832c41e0..674914101b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml @@ -9,14 +9,11 @@ @model ApplicationMenu