diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs index b2d4ee5872..d51c68030e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs @@ -1,23 +1,29 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Volo.Abp.MultiTenancy; using Unity.Payments.Integrations.Cas; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; -public class InvoiceConsumer(InvoiceService invoiceService, - ICurrentTenant currentTenant) : IQueueConsumer +/// +/// Processes invoice creation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// +public class InvoiceConsumer( + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(InvoiceMessages invoiceMessage) { - if (invoiceMessage != null && !invoiceMessage.InvoiceNumber.IsNullOrEmpty() && invoiceMessage.TenantId != Guid.Empty) + if (invoiceMessage == null || + invoiceMessage.InvoiceNumber.IsNullOrEmpty() || + invoiceMessage.TenantId == Guid.Empty) { - using (currentTenant.Change(invoiceMessage.TenantId)) - { - await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); - } + return; } + + await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs index 48ac9fb986..78b7824bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class InvoiceMessages : IQueueMessage + public class InvoiceMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs index 7b7737d682..5798351f58 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class ReconcilePaymentMessages : IQueueMessage + public class ReconcilePaymentMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs index c462ae9b35..1fc39278ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs @@ -1,40 +1,46 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Unity.Payments.PaymentRequests; using Unity.Payments.Integrations.Cas; -using Volo.Abp.MultiTenancy; +using Unity.Payments.PaymentRequests; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; +/// +/// Processes payment reconciliation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// public class ReconciliationConsumer( - CasPaymentRequestCoordinator casPaymentRequestCoordinator, - InvoiceService invoiceService, - ICurrentTenant currentTenant - ) : IQueueConsumer + CasPaymentRequestCoordinator casPaymentRequestCoordinator, + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage) { - if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty) - { + if (reconcilePaymentMessage == null || + reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() || + reconcilePaymentMessage.TenantId == Guid.Empty) + { + return; + } - using (currentTenant.Change(reconcilePaymentMessage.TenantId)) - { - // string invoiceNumber, string supplierNumber, string siteNumber) - // Go to CAS retrieve the status of the payment - CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( - reconcilePaymentMessage.TenantId, - reconcilePaymentMessage.InvoiceNumber, - reconcilePaymentMessage.SupplierNumber, - reconcilePaymentMessage.SiteNumber); + // string invoiceNumber, string supplierNumber, string siteNumber) + // Go to CAS retrieve the status of the payment + CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.InvoiceNumber, + reconcilePaymentMessage.SupplierNumber, + reconcilePaymentMessage.SiteNumber); - if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "") - { - await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result); - } - } + + if (!string.IsNullOrEmpty(result?.InvoiceStatus)) + { + await casPaymentRequestCoordinator.UpdatePaymentRequestStatus( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.PaymentRequestId, + result); } } - } \ No newline at end of file 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 7e2e287bec..a4698477af 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 @@ -15,28 +15,15 @@ namespace Unity.Payments.PaymentRequests { - public class CasPaymentRequestCoordinator : ApplicationService - { - private readonly IPaymentRequestRepository _paymentRequestsRepository; - private readonly IUnitOfWorkManager _unitOfWorkManager; - private readonly ITenantRepository _tenantRepository; - private readonly ICurrentTenant _currentTenant; - private readonly PaymentQueueService _paymentQueueService; - private static int TenMinutes = 10; - - public CasPaymentRequestCoordinator( - PaymentQueueService paymentQueueService, + public class CasPaymentRequestCoordinator(PaymentQueueService paymentQueueService, IPaymentRequestRepository paymentRequestsRepository, IUnitOfWorkManager unitOfWorkManager, ITenantRepository tenantRepository, - ICurrentTenant currentTenant) - { - _paymentQueueService = paymentQueueService; - _paymentRequestsRepository = paymentRequestsRepository; - _tenantRepository = tenantRepository; - _currentTenant = currentTenant; - _unitOfWorkManager = unitOfWorkManager; - } + ICurrentTenant currentTenant) : ApplicationService + { + + private static int TenMinutes = 10; + protected virtual dynamic GetPaymentRequestObject( Guid paymentRequestId, @@ -60,7 +47,7 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest { try { - if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && _currentTenant != null && _currentTenant.Id != null) + if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && currentTenant != null && currentTenant.Id != null) { InvoiceMessages message = new InvoiceMessages { @@ -69,10 +56,10 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest InvoiceNumber = paymentRequest.InvoiceNumber, SupplierNumber = paymentRequest.SupplierNumber, SiteNumber = paymentRequest.Site.Number, - TenantId = (Guid)_currentTenant.Id + TenantId = (Guid)currentTenant.Id }; - await _paymentQueueService.SendPaymentToInvoiceQueueAsync(message); + await paymentQueueService.SendPaymentToInvoiceQueueAsync(message); } } catch (Exception ex) @@ -93,21 +80,21 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List tenant.Id)) { - using (_currentTenant.Change(tenantId)) + using (currentTenant.Change(tenantId)) { - List paymentRequests = await _paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); + List paymentRequests = await paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); foreach (PaymentRequest paymentRequest in paymentRequests) { ReconcilePaymentMessages reconcilePaymentMessage = new ReconcilePaymentMessages @@ -120,52 +107,60 @@ public async Task AddPaymentRequestsToReconciliationQueue() TenantId = tenantId }; - await _paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); + await paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); } } } } + /// + /// Updates payment request status from CAS integration results. + /// Tenant context and audit scope are already established by the caller + /// (via ); + /// this method only needs to own its unit of work. + /// public async Task UpdatePaymentRequestStatus(Guid TenantId, Guid PaymentRequestId, CasPaymentSearchResult result) { - PaymentRequest? paymentReqeust = null; - if (TenantId != Guid.Empty) + if (TenantId == Guid.Empty) { - using (_currentTenant.Change(TenantId)) - { - try - { - using var uow = _unitOfWorkManager.Begin(true, false); - paymentReqeust = await _paymentRequestsRepository.GetAsync(PaymentRequestId); - if (paymentReqeust != null) - { - if(paymentReqeust.InvoiceStatus == CasPaymentRequestStatus.NotFound && result.InvoiceStatus == CasPaymentRequestStatus.NotFound) - { - result.InvoiceStatus = CasPaymentRequestStatus.NotFound+"2"; - } - - paymentReqeust.SetInvoiceStatus(result.InvoiceStatus ?? ""); - paymentReqeust.SetPaymentStatus(result.PaymentStatus ?? ""); - paymentReqeust.SetPaymentDate(result.PaymentDate ?? ""); - paymentReqeust.SetPaymentNumber(result.PaymentNumber ?? ""); - if(result.InvoiceStatus != null) - { - paymentReqeust.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); - paymentReqeust.SetCasResponse("SUCCEEDED"); - } - - await _paymentRequestsRepository.UpdateAsync(paymentReqeust, autoSave: false); - await uow.SaveChangesAsync(); - } - } - catch (Exception ex) - { - string ExceptionMessage = ex.Message; - Logger.LogInformation(ex, "UpdatePaymentRequestStatus: Error updating payment request: {ExceptionMessage}", ExceptionMessage); - } - } + return null; + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + + var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + + UpdatePaymentRequestFromCasResult(paymentRequest, result); + + await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); + + // 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(); + + return paymentRequest; + } + + private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result) + { + // Handle duplicate NotFound status by appending "2" + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && + result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + { + result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2"; + } + + paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? ""); + paymentRequest.SetPaymentStatus(result.PaymentStatus ?? ""); + paymentRequest.SetPaymentDate(result.PaymentDate ?? ""); + paymentRequest.SetPaymentNumber(result.PaymentNumber ?? ""); + + if (result.InvoiceStatus != null) + { + paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); + paymentRequest.SetCasResponse("SUCCEEDED"); } - return paymentReqeust; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs index 46a506fb26..db352a16aa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs @@ -15,10 +15,12 @@ using Volo.Abp.Application.Dtos; using Volo.Abp.AspNetCore.ExceptionHandling; using Unity.Payments.PaymentRequests.Notifications; +using Unity.Modules.Shared.Auditing; namespace Unity.Payments; [DependsOn( + typeof(UnityAuditingOverrideModule), typeof(AbpVirtualFileSystemModule), typeof(AbpDddApplicationModule), typeof(AbpAutoMapperModule), diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs new file mode 100644 index 0000000000..eafd1acb3c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs @@ -0,0 +1,58 @@ +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Timing; +using Volo.Abp.Users; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom audit property setter that ensures background jobs have proper user context. +/// With proper BackgroundJobContext setup, ABP should populate most values automatically. +/// This provides a safety net fallback using reflection for readonly properties. +/// +public class BackgroundJobAuditPropertySetter : AuditPropertySetter, ITransientDependency +{ + public BackgroundJobAuditPropertySetter(ICurrentUser currentUser, ICurrentTenant currentTenant, IClock clock) + : base(currentUser, currentTenant, clock) + { + } + + public override void SetCreationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetCreationProperties(targetObject); + + // If in background job context and ABP hasn't set creator, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is ICreationAuditedObject createdObject && + createdObject.CreatorId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(ICreationAuditedObject.CreatorId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } + + public override void SetModificationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetModificationProperties(targetObject); + + // If in background job context and ABP hasn't set modifier, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is IModificationAuditedObject modifiedObject && + modifiedObject.LastModifierId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(IModificationAuditedObject.LastModifierId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs new file mode 100644 index 0000000000..821b06ccc2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom auditing helper that forces audit logging for background job operations. +/// Wraps ABP's default AuditingHelper to intercept auditing decisions and ensure +/// EntityChanges are recorded even when no authenticated user is present. +/// +public class UnityAuditingHelper : IAuditingHelper, ITransientDependency +{ + private readonly AuditingHelper _inner; + + public UnityAuditingHelper(AuditingHelper inner) + { + _inner = inner; + } + + public bool ShouldSaveAudit(MethodInfo? methodInfo, bool defaultValue = false, bool ignoreIntegrationServiceAttribute = false) + { + // Force auditing for background jobs - bypass normal checks that fail when currentUser.Id is null + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.ShouldSaveAudit(methodInfo, defaultValue, ignoreIntegrationServiceAttribute); + } + + public bool IsEntityHistoryEnabled(Type entityType, bool defaultValue = false) + { + // Force entity history for background jobs - ensures EntityChanges table gets populated + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.IsEntityHistoryEnabled(entityType, defaultValue); + } + + public AuditLogInfo CreateAuditLogInfo() + { + var auditLogInfo = _inner.CreateAuditLogInfo(); + + // Enrich audit log with background job user when no authenticated user present + if (BackgroundJobExecutionContext.IsActive && auditLogInfo.UserId == null) + { + auditLogInfo.UserId = BackgroundJobConstants.BackgroundJobPersonId; + auditLogInfo.UserName = BackgroundJobConstants.BackgroundJobUserName; + } + + return auditLogInfo; + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + object?[] arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + IDictionary arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } +} + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs new file mode 100644 index 0000000000..0cd68880c4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Auditing; +using Volo.Abp.Modularity; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// ABP module that overrides default auditing behavior to support background job entity change tracking. +/// Registers custom implementations that force auditing when BackgroundJobExecutionContext is active. +/// +[DependsOn( + typeof(AbpAuditingModule) +)] +public class UnityAuditingOverrideModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Override audit property setter to handle readonly audit properties in background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + + // Override auditing helper to force entity change tracking for background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs new file mode 100644 index 0000000000..43a106ad90 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.Constants; + +public static class BackgroundJobConstants +{ + // Well-known fixed GUID for the Background Job Execution Person record (one per tenant) + public static readonly Guid BackgroundJobPersonId = new("00000000-0000-0000-0000-000000000002"); + public const string BackgroundJobOidcSub = "unity-background-job"; + public const string BackgroundJobDisplayName = "Unity Background Job Execution"; + public const string BackgroundJobBadge = "BGJ"; + public const string BackgroundJobUserName = "UBGJ"; + public const string BackgroundJobName = "UnityBackgroundJob"; + public const string BackgroundJobEmail = "grantmanagementsupport@gov.bc.ca"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs new file mode 100644 index 0000000000..4aac91736e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +{ + /// + /// Extends for messages that carry tenant context. + /// Implementing this interface causes + /// to automatically establish background-job auditing scope before invoking the consumer, + /// mirroring the way ASP.NET Core middleware wraps controller actions. + /// + public interface ITenantedQueueMessage : IQueueMessage + { + Guid TenantId { get; set; } + } +} 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 c94fc005af..d64d0a1248 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -8,6 +8,10 @@ using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ { @@ -86,8 +90,21 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) _logger.LogInformation("Processing MessageId {MessageId}", message.MessageId); - var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); - await consumerInstance.ConsumeAsync(message); + if (message is not ITenantedQueueMessage tenantedMessage) + { + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + } + else if (tenantedMessage.TenantId == Guid.Empty) + { + _logger.LogError("Message {MessageId} on {Queue} has an empty TenantId and cannot be processed", message.MessageId, _queueName); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + return; + } + else + { + await ConsumeWithAuditingAsync(consumerScope, tenantedMessage, message); + } consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); @@ -105,6 +122,58 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) } } + /// + /// Wraps consumer execution in a background-job auditing scope, mirroring the way + /// ASP.NET Core middleware wraps controller actions. Tenant context, identity, and + /// audit persistence are handled here so individual consumers stay free of + /// infrastructure concerns. + /// + private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message) + { + var auditingManager = consumerScope.ServiceProvider.GetRequiredService(); + var principalAccessor = consumerScope.ServiceProvider.GetRequiredService(); + var currentTenant = consumerScope.ServiceProvider.GetRequiredService(); + var auditingStore = consumerScope.ServiceProvider.GetRequiredService(); + + using (BackgroundJobExecutionContext.Use()) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantedMessage.TenantId)) + { + AddConsumerAuditAction(auditingManager, message); + + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + + // Persist audit log if the consumer produced any entity changes. + // Entity changes are collected by AbpDbContext.SaveChangesAsync() during UOW commit, + // 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); + } + } + } + + /// + /// Adds a single to the current audit scope using + /// reflection on the generic consumer type. ABP requires at least one recorded action + /// before it will persist an audit log with entity changes. + /// + private static void AddConsumerAuditAction(IAuditingManager auditingManager, TQueueMessage message) + { + if (auditingManager.Current?.Log == null) + { + return; + } + + auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo + { + ServiceName = typeof(TMessageConsumer).FullName ?? typeof(TMessageConsumer).Name, + MethodName = nameof(IQueueConsumer.ConsumeAsync), + Parameters = System.Text.Json.JsonSerializer.Serialize(message), + ExecutionTime = DateTime.UtcNow + }); + } + private static TQueueMessage? DeserializeMessage(byte[] body) { var json = Encoding.UTF8.GetString(body); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs new file mode 100644 index 0000000000..78ce8c6609 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -0,0 +1,96 @@ +using System; +using System.Security.Claims; +using Unity.Modules.Shared.Constants; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Utility for establishing proper execution context for background jobs and message consumers. +/// Sets up tenant, user identity, and audit scope required for entity change tracking. +/// +public static class BackgroundJobContext +{ + /// + /// Sets up complete background job execution context with auditing, tenant, and user identity. + /// CRITICAL: Must be called AFTER BackgroundJobExecutionContext.Use() to enable forced auditing. + /// + /// The auditing manager for creating audit scope + /// 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) + { + var effectiveUserId = userId ?? 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.UserName, BackgroundJobConstants.BackgroundJobUserName), + new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), + new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), + new Claim(AbpClaimTypes.Name, BackgroundJobConstants.BackgroundJobName) + }; + + // Create an authenticated identity (authenticationType must be non-null for IsAuthenticated to be true) + var identity = new ClaimsIdentity(claims, "BackgroundJob", AbpClaimTypes.UserName, AbpClaimTypes.Role); + var principal = new ClaimsPrincipal(identity); + + // CRITICAL: Set tenant and principal BEFORE starting audit scope + // This ensures ABP captures correct context when audit scope is created + var tenantDisposable = currentTenant.Change(tenantId); + var principalDisposable = principalAccessor.Change(principal); + + // NOW start auditing - it will see the correct tenant/user context + 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 = effectiveUserId; + 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); + } + + /// + /// Private helper class to combine multiple disposables into one + /// + private sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] _disposables; + private bool _disposed; + + public CompositeDisposable(params IDisposable[] disposables) + { + _disposables = disposables; + } + + public void Dispose() + { + if (!_disposed) + { + for (int i = _disposables.Length - 1; i >= 0; i--) + { + _disposables[i]?.Dispose(); + } + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs new file mode 100644 index 0000000000..ceb635bbb0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Context marker for background job execution that survives async boundaries. +/// Used by auditing overrides to detect when code is running in a background job. +/// +public static class BackgroundJobExecutionContext +{ + private static readonly AsyncLocal _isActive = new(); + + /// + /// Returns true if currently executing within a background job context. + /// + public static bool IsActive => _isActive.Value; + + /// + /// Marks the current async context as executing within a background job. + /// Returns an IDisposable that clears the marker when disposed. + /// + /// IDisposable to clear the background job context + public static IDisposable Use() + { + bool previous = _isActive.Value; + _isActive.Value = true; + return new DisposeAction(() => _isActive.Value = previous); + } + + private sealed class DisposeAction : IDisposable + { + private readonly Action _action; + private bool _disposed; + + public DisposeAction(Action action) + { + _action = action; + } + + public void Dispose() + { + if (!_disposed) + { + _action?.Invoke(); + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs index 330c2551fb..8dcf1f12a1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs @@ -22,4 +22,5 @@ public class ApplicantSummaryDto public string? FiscalDay { get; set; } public string? FiscalMonth { get; set; } public string? ElectoralDistrict { get; set; } + public bool? IsDuplicated { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index 6291c358cb..489461f27b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -4,15 +4,20 @@ using Unity.GrantManager.Assessments; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Identity; +using Unity.Modules.Shared.Constants; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager; public class GrantManagerDataSeederContributor( IApplicationStatusRepository applicationStatusRepository, - IPersonRepository personRepository) : IDataSeedContributor, ITransientDependency + IPersonRepository personRepository, + IIdentityUserRepository userRepository, + ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency { public static class GrantApplicationStates { @@ -41,6 +46,7 @@ public async Task SeedAsync(DataSeedContext context) await SeedApplicationStatusAsync(); await SeedAiScoringPersonAsync(context.TenantId); + await SeedBackgroundJobUserAsync(context.TenantId); } @@ -88,4 +94,43 @@ await personRepository.InsertAsync(new Person }); } } + + private async Task SeedBackgroundJobUserAsync(System.Guid? tenantId) + { + // Ensure we're in the correct tenant context + using (currentTenant.Change(tenantId)) + { + // Check if the IdentityUser already exists + var existingUser = await userRepository.FindAsync(BackgroundJobConstants.BackgroundJobPersonId); + if (existingUser == null) + { + // Create the IdentityUser in the tenant context + await userRepository.InsertAsync( + new IdentityUser( + BackgroundJobConstants.BackgroundJobPersonId, + BackgroundJobConstants.BackgroundJobUserName, + BackgroundJobConstants.BackgroundJobEmail, + tenantId) + { + Name = BackgroundJobConstants.BackgroundJobName + }, + autoSave: true); + } + + // Check if the Person record already exists + var existingPerson = await personRepository.FirstOrDefaultAsync(p => p.Id == BackgroundJobConstants.BackgroundJobPersonId); + if (existingPerson == null) + { + await personRepository.InsertAsync(new Person + { + Id = BackgroundJobConstants.BackgroundJobPersonId, + OidcSub = BackgroundJobConstants.BackgroundJobOidcSub, + OidcDisplayName = BackgroundJobConstants.BackgroundJobDisplayName, + FullName = BackgroundJobConstants.BackgroundJobDisplayName, + Badge = BackgroundJobConstants.BackgroundJobBadge, + TenantId = tenantId + }); + } + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs index 5a1dd31514..042711194b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs @@ -1,5 +1,6 @@ using Unity.GrantManager.MultiTenancy; using Unity.GrantManager.Settings; +using Unity.Modules.Shared.Auditing; using Unity.Notifications; using Volo.Abp.AuditLogging; using Volo.Abp.BackgroundJobs; @@ -19,6 +20,7 @@ namespace Unity.GrantManager; [DependsOn( + typeof(UnityAuditingOverrideModule), typeof(GrantManagerDomainSharedModule), typeof(AbpAuditLoggingDomainModule), typeof(AbpBackgroundJobsDomainModule), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs index f6da07f3f9..7d9d7850e0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs @@ -104,7 +104,8 @@ public async Task GetApplicantAutocompleteQueryAsync(string? appli a.SectorSubSectorIndustryDesc, a.FiscalDay, a.FiscalMonth, - a.UnityApplicantId + a.UnityApplicantId, + a.IsDuplicated }) .Take(10) .ToList(); 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 7a957daef7..af8cbd315a 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 @@ -44,6 +44,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 826590b751..76b9d46596 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -171,6 +171,9 @@ public override void ConfigureServices(ServiceConfigurationContext context) ) ); + options.IsEnabledForAnonymousUsers = true; + options.IsEnabledForIntegrationServices = true; // Enable auditing for background jobs and message consumers + options.EntityHistorySelectors.Add( new NamedTypeSelector( "ExplictEntityAudit", @@ -179,7 +182,8 @@ public override void ConfigureServices(ServiceConfigurationContext context) if (type.Name.Contains("Role", StringComparison.OrdinalIgnoreCase) || type.Name.Contains("User", StringComparison.OrdinalIgnoreCase) - || type.Name.Contains("Permission", StringComparison.OrdinalIgnoreCase)) + || type.Name.Contains("Permission", StringComparison.OrdinalIgnoreCase) + || type.Name.Contains("Payment", StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs index 544ca94802..cc218ce1cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs @@ -135,6 +135,8 @@ public class ApplicantInfoScriptBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) { + context.Files + .AddIfNotContains("/Views/Shared/Components/_Shared/string-utils.js"); context.Files .AddIfNotContains("/Views/Shared/Components/ApplicantInfo/Default.js"); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs index cc639c8dce..31bbe87d56 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs @@ -63,5 +63,8 @@ public class ApplicantSummaryViewModel [Display(Name = "ApplicantInfoView:ApplicantInfo.ApplicantName")] public string? ApplicantName { get; set; } + + [HiddenInput] + public bool? IsDuplicated { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index ad1d64a50c..e5b8c96942 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -55,6 +55,7 @@ update-permission-requirement="@UnitySelector.Applicant.Summary.Update"> + @@ -386,6 +387,9 @@
Compare Accounts

Choose one account record as the principal, and choose the field values that you want to keep.

+
+ +
@@ -393,10 +397,12 @@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js index 70f8fee38e..0d9e5e44b8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js @@ -273,7 +273,8 @@ function getExistingApplicantData() { SubSector: getVal('ApplicantSummary_SubSector'), SectorSubSectorIndustryDesc: getVal('ApplicantSummary_SectorSubSectorIndustryDesc'), FiscalDay: getVal('ApplicantSummary_FiscalDay'), - FiscalMonth: getVal('ApplicantSummary_FiscalMonth') + FiscalMonth: getVal('ApplicantSummary_FiscalMonth'), + IsDuplicated: $activeWidget.find('#ApplicantSummary_IsDuplicated').val() === 'True' }; } @@ -295,7 +296,8 @@ function createNewApplicantDataObject(selectedData) { SubSector: selectedData.SubSector || '', SectorSubSectorIndustryDesc: selectedData.SectorSubSectorIndustryDesc || '', FiscalDay: selectedData.FiscalDay || '', - FiscalMonth: selectedData.FiscalMonth || '' + FiscalMonth: selectedData.FiscalMonth || '', + IsDuplicated: selectedData.IsDuplicated ?? false }; } @@ -304,6 +306,21 @@ function populateMergeModal(existing, newData) { $('#existing_ApplicantNameHeader').text(existing.ApplicantName); $('#new_ApplicantNameHeader').text(newData.ApplicantName); + $('#mergeExistingDuplicateFlag').toggleClass('d-none', !existing.IsDuplicated); + $('#mergeNewDuplicateFlag').toggleClass('d-none', !newData.IsDuplicated); + + // Name match summary badge + let score = compareStrings(existing.ApplicantName || '', newData.ApplicantName || ''); + let $badge = $('#mergeNameMatchBadge'); + $badge.removeClass('unity-badge-warning'); + if (score >= 100) { + $badge.text('100% Matched - Possible Duplicate'); + } else if (score >= 50) { + $badge.text('Partially Matched'); + } else { + $badge.text('Not Matched').addClass('unity-badge-warning'); + } + for (const key in existing) { $(`#existing_${key}`).text(existing[key]); $(`#new_${key}`).text(newData[key]); @@ -452,7 +469,8 @@ function initializeApplicantLookup() { SectorSubSectorIndustryDesc: item.SectorSubSectorIndustryDesc, FiscalDay: item.FiscalDay, FiscalMonth: item.FiscalMonth, - UnityApplicantId: item.UnityApplicantId + UnityApplicantId: item.UnityApplicantId, + IsDuplicated: item.IsDuplicated ?? false }; }); return { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs index ffa695d942..66d935d0c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs @@ -31,8 +31,12 @@ public class ApplicantsActionBarWidgetScriptBundleContributor : BundleContributo { public override void ConfigureBundle(BundleConfigurationContext context) { + context.Files + .AddIfNotContains("/Views/Shared/Components/_Shared/string-utils.js"); context.Files .AddIfNotContains("/Views/Shared/Components/ApplicantsActionBar/Default.js"); + context.Files + .AddIfNotContains("/Views/Shared/Components/ApplicantsActionBar/ListMerge.js"); } } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml index aa6dc75e73..52e9348106 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml @@ -22,8 +22,17 @@ class="custom-table-btn flex-none btn btn-secondary action-bar-btn-unavailable" button-type="Secondary" /> } + @if (await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.Edit)) + { + + } @* Spacer to align buttons properly *@ - \ No newline at end of file + + +@await Html.PartialAsync("~/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml") \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js index 87f04f3461..ef5f2c8064 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js @@ -43,6 +43,13 @@ $(function () { $('#openApplicant').removeClass('action-bar-btn-unavailable'); } } + + // Show MERGE button only when exactly 2 applicants are selected + if (selectedApplicantIds.length === 2) { + $('#mergeApplicants').removeClass('d-none'); + } else { + $('#mergeApplicants').addClass('d-none'); + } } // Handle OPEN button click @@ -52,6 +59,16 @@ $(function () { } }); + // MERGE button click — open modal with the 2 selected applicants + $('#mergeApplicants').on('click', () => { + if (selectedApplicants.length === 2) { + PubSub.publish('open_applicant_list_merge', { + a: selectedApplicants[0], + b: selectedApplicants[1] + }); + } + }); + // Handle search input $('#search').on('input', function () { let table = $('#ApplicantsTable').DataTable(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml new file mode 100644 index 0000000000..3c3b371e6d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml @@ -0,0 +1,84 @@ +
+
Flagged as Duplicated
+
Flagged as Duplicated
+ + + + + + + + + + + + +
+ +
Flagged as Duplicated
+ +
+ +
Flagged as Duplicated
+ +
Principal Record + + + +
+
+
+ +
+ + + +
+
+
Compare Action
+

You are about to merge these applicants. This action cannot be undone.
+ The principal record will be updated with your chosen values.
+ The other record will not be deleted; instead, it will be flagged as a duplicate and can be removed from the Applicant list in a separate process. +

+

Note: The address and contact information for any affected applications will be preserved and remain untouched.

+

Are you sure?

+
+
+ Merging... +
+
Merging, please wait...
+
+
+
+ + +
+
+ + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js new file mode 100644 index 0000000000..b8130e1248 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js @@ -0,0 +1,175 @@ +(function () { + // Module-level state: the two applicants received when the modal is opened + let _applicantA = null; + let _applicantB = null; + + // Field definitions — label matches ApplicantInfo localization, key is ApplicantListDto camelCase + // The Principal Record row (merge_ApplicantId) is static HTML; these are the dynamic field rows. + const MERGE_FIELDS = [ + { label: 'Applicant Id', key: 'unityApplicantId', radioName: 'merge_UnityApplicantId' }, + { label: 'Applicant Name', key: 'applicantName', radioName: 'merge_ApplicantName' }, + { label: 'Registered Organization Name', key: 'orgName', radioName: 'merge_OrgName' }, + { label: 'Registered Organization Number', key: 'orgNumber', radioName: 'merge_OrgNumber' }, + { label: 'Non-Registered Organization Name', key: 'nonRegOrgName', radioName: 'merge_NonRegOrgName' }, + { label: 'Organization Type', key: 'organizationType', radioName: 'merge_OrganizationType' }, + { label: 'Organization Size', key: 'organizationSize', radioName: 'merge_OrganizationSize' }, + { label: 'Org book status', key: 'orgStatus', radioName: 'merge_OrgStatus' }, + { label: 'Indigenous', key: 'indigenousOrgInd', radioName: 'merge_IndigenousOrgInd' }, + { label: 'Sector', key: 'sector', radioName: 'merge_Sector' }, + { label: 'Sub-sector', key: 'subSector', radioName: 'merge_SubSector' }, + { label: 'Other Sector/Sub/Industry Description', key: 'sectorSubSectorIndustryDesc', radioName: 'merge_SectorSubSectorIndustryDesc' }, + { label: 'Fiscal Year End Day', key: 'fiscalDay', radioName: 'merge_FiscalDay' }, + { label: 'Fiscal Year End Month', key: 'fiscalMonth', radioName: 'merge_FiscalMonth' }, + ]; + + function openListMergeModal(a, b) { + _applicantA = a; + _applicantB = b; + + // Column headers show applicant names + $('#listMergeColA').text(a.applicantName ?? a.id); + $('#listMergeColB').text(b.applicantName ?? b.id); + + // Show "Flagged as Duplicated" badge if the applicant has IsDuplicated=true + $('#listMergeDuplicateFlagA').toggleClass('d-none', !a.isDuplicated); + $('#listMergeDuplicateFlagB').toggleClass('d-none', !b.isDuplicated); + + // Name match summary badge + let score = compareStrings(a.applicantName || '', b.applicantName || ''); + let $badge = $('#listMergeNameMatchBadgeText'); + $badge.removeClass('unity-badge-warning'); + if (score >= 100) { + $badge.text('100% Matched - Possible Duplicate'); + } else if (score >= 50) { + $badge.text('Partially Matched'); + } else { + $badge.text('Not Matched').addClass('unity-badge-warning'); + } + + // Build dynamic field rows + const $tbody = $('#listMergeTableBody').empty(); + MERGE_FIELDS.forEach(f => { + const aVal = a[f.key] ?? ''; + const bVal = b[f.key] ?? ''; + $tbody.append(` + + ${f.label} + + + + + + + `); + }); + + // Reset to step 1 + $('#listMergeStep1').removeClass('d-none'); + $('#listMergeStep2').addClass('d-none'); + + $('#applicantListMergeModal').modal('show'); + } + + $(function () { + PubSub.subscribe('open_applicant_list_merge', (msg, data) => { + openListMergeModal(data.a, data.b); + }); + + // Select All — covers both the static Principal Record row and all dynamic rows + $('#listMergeSelectAllExisting').on('click', () => { + $('#applicantListMergeModal input[type="radio"][value="a"]').prop('checked', true); + }); + $('#listMergeSelectAllNew').on('click', () => { + $('#applicantListMergeModal input[type="radio"][value="b"]').prop('checked', true); + }); + + // Step navigation + $('#listMergeNextBtn').on('click', () => { + $('#listMergeStep1').addClass('d-none'); + $('#listMergeStep2').removeClass('d-none'); + }); + $('#listMergeBackBtn').on('click', () => { + $('#listMergeStep2').addClass('d-none'); + $('#listMergeStep1').removeClass('d-none'); + }); + + // Execute merge + $('#listMergeMergeBtn').on('click', () => { + const a = _applicantA; + const b = _applicantB; + + // Determine principal from the static merge_ApplicantId radio + const principalChoice = $('input[name="merge_ApplicantId"]:checked').val(); + const principal = principalChoice === 'a' ? a : b; + const nonPrincipal = principalChoice === 'a' ? b : a; + + // Build merged field values from dynamic radio selections + const merged = {}; + MERGE_FIELDS.forEach(f => { + const choice = $(`input[name="${f.radioName}"]:checked`).val(); + merged[f.key] = choice === 'a' ? a[f.key] : b[f.key]; + }); + + // Convert indigenousOrgInd "Yes"/"No"/null → bool?/null for UpdateApplicantSummaryDto + let indigenousOrgIndBool = null; + if (merged['indigenousOrgInd'] === 'Yes') indigenousOrgIndBool = true; + else if (merged['indigenousOrgInd'] === 'No') indigenousOrgIndBool = false; + + // Build payload matching UpdateApplicantSummaryDto property names (camelCase via ABP) + const summaryData = { + applicantName: merged['applicantName'] ?? null, + unityApplicantId: merged['unityApplicantId'] ?? null, + orgName: merged['orgName'] ?? null, + orgNumber: merged['orgNumber'] ?? null, + nonRegOrgName: merged['nonRegOrgName'] ?? null, + organizationType: merged['organizationType'] ?? null, + organizationSize: merged['organizationSize'] ?? null, + orgStatus: merged['orgStatus'] ?? null, + indigenousOrgInd: indigenousOrgIndBool, + sector: merged['sector'] ?? null, + subSector: merged['subSector'] ?? null, + sectorSubSectorIndustryDesc: merged['sectorSubSectorIndustryDesc'] ?? null, + fiscalDay: merged['fiscalDay'] === null ? null : String(merged['fiscalDay']), + fiscalMonth: merged['fiscalMonth'] ?? null, + }; + + const modifiedFields = Object.keys(summaryData); + + $('#listMergeSpinner').removeClass('d-none'); + $('#listMergeMergeBtn').prop('disabled', true); + + // Step 1: mark non-principal as duplicated + $.ajax({ + url: '/api/app/applicant/set-duplicated', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + principalApplicantId: principal.id, + nonPrincipalApplicantId: nonPrincipal.id + }) + }).then(() => { + // Step 2: update principal's summary fields + return unity.grantManager.applicants.applicant + .partialUpdateApplicantSummary(principal.id, { + modifiedFields: modifiedFields, + data: summaryData + }); + }).then(() => { + $('#applicantListMergeModal').modal('hide'); + PubSub.publish('deselect_applicant', 'reset_data'); + $('#ApplicantsTable').DataTable().ajax.reload(); + abp.notify.success('Applicants merged successfully.'); + }).catch(err => { + console.warn('Merge failed:', err); + abp.notify.error('Merge failed. Please try again.'); + }).always(() => { + $('#listMergeSpinner').addClass('d-none'); + $('#listMergeMergeBtn').prop('disabled', false); + }); + }); + }); +})(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml index 058ddd828c..8f91b481cf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml @@ -34,7 +34,7 @@
$ + onchange="enableAssessmentResultsSaveBtn(this)" class="unity-currency-input" data-allow-zero="true" disabled="@(!Model.IsPostEditFieldsAllowed_Approval)" />
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js index 11a8042d64..adf440cf7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js @@ -152,7 +152,11 @@ enableAssessmentResultsSaveBtn(); } ); - + $('#PaymentApprovalThreshold').rules('add', { + normalizer: function (value) { + return value.replaceAll(',', ''); + } + }); $('.unity-currency-input').maskMoney(); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml index feb209af54..520f6f2f0a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml @@ -31,15 +31,15 @@
$ - +
@@ -131,3 +131,11 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js index 598cb245cc..feacee5edb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js @@ -204,13 +204,18 @@ ? (UIElements.defaultPaymentGroup.val() || '1') : null; + const rawThreshold = UIElements.paymentApprovalThreshold.val(); + const unMaskedPaymentApprovalThreshold = rawThreshold === '' + ? null + : (UIElements.paymentApprovalThreshold.maskMoney('unmasked')[0] ?? null); + unity.grantManager.applicationForms.applicationForm.savePaymentConfiguration( { accountCodingId: UIElements.accountCode.val(), applicationFormId: UIElements.appFormId.val(), preventPayment: UIElements.preventPayment.is(':checked'), payable: UIElements.payable.is(':checked'), - paymentApprovalThreshold: UIElements.paymentApprovalThreshold.val() === '' ? null : UIElements.paymentApprovalThreshold.val(), + paymentApprovalThreshold: unMaskedPaymentApprovalThreshold, formHierarchy: Number.isNaN(formHierarchy) ? null : formHierarchy, parentFormId: parentFormId || null, defaultPaymentGroup: defaultPaymentGroupValue @@ -261,4 +266,6 @@ } } + $('.unity-currency-input') //Required for initial masking + .maskMoney({thousands: ',',decimal: '.',}).maskMoney('mask'); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/PaymentConfigurationViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/PaymentConfigurationViewComponent.cs index b3f1668e26..f2ccb33c68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/PaymentConfigurationViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/PaymentConfigurationViewComponent.cs @@ -107,6 +107,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/libs/pubsub-js/src/pubsub.js"); context.Files .AddIfNotContains("/libs/select2/js/select2.full.min.js"); + context.Files + .AddIfNotContains("/libs/jquery-maskmoney/dist/jquery.maskMoney.min.js"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js new file mode 100644 index 0000000000..39dd64a601 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js @@ -0,0 +1,36 @@ +// Mirrors Unity.Modules.Shared.Utils.StringExtensions.CompareStrings (bigram Dice coefficient) +function wordLetterPairs(str) { + let pairs = []; + let words = str.split(/\s+/); + for (const word of words) { + if (!word) continue; + for (let j = 0; j < word.length - 1; j++) { + pairs.push(word[j] + word[j + 1]); + } + } + return pairs; +} + +// Returns a match percentage (0–100), mirroring C# StringExtensions.CompareStrings. +// pairs1 is a plain array (duplicates kept); pairs2 is deduplicated (mirrors HashSet). +// intersection = count of pairs1 items found in set2, each match deletes the key. +// union = pairs1.length + remaining set2 size after deletions. +function compareStrings(str1, str2) { + if (!str1 || !str2) return 0; + let pairs1 = wordLetterPairs(str1.toUpperCase()); + let rawPairs2 = wordLetterPairs(str2.toUpperCase()); + let set2 = {}; + for (const p of rawPairs2) { + set2[p] = true; + } + let intersection = 0; + for (const p of pairs1) { + if (Object.hasOwn(set2, p)) { + intersection++; + delete set2[p]; + } + } + let union = pairs1.length + Object.keys(set2).length; + if (union === 0) return 0; + return Math.round(Math.min(2 * intersection * 100 / union, 100) * 100) / 100; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/global-styles.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/global-styles.css index 4dc55380b1..f0b9743758 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/global-styles.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/global-styles.css @@ -75,3 +75,19 @@ inner-menu-item .lpx-menu-item-link:hover { .lpx-content-container { background-color: #F2F2F2; } + +.unity-match-badge { + font-weight: 700; + color: var(--bc-colors-blue-text-links); + text-transform: uppercase; + border: 3px solid var(--bc-colors-blue-text-links); + border-radius: 1rem; + font-size: 0.8rem; + padding: 0.025rem 0.5rem; + line-height: 1.75rem; +} + +.unity-badge-warning { + color: var(--lpx-danger); + border-color: var(--lpx-danger); +}