From 1a94e902f494403951fd3a4f49d82d8680a83a7c Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 4 Aug 2026 15:25:56 -0700 Subject: [PATCH 1/6] feature/AB#33563-AddPaymentScheduling --- .../Events/PaymentStatusChangedEvent.cs | 16 ++++ .../PaymentRequestAppService.cs | 38 ++++++++- .../CreateUpdateNotificationDto.cs | 2 + .../Notifications/NotificationDto.cs | 1 + .../ScheduledNotificationEventHandler.cs | 51 +++++++++++- .../AutomatedNotificationAppService.cs | 6 ++ .../Notifications/ScheduledNotification.cs | 2 + .../GrantTenantDbContext.cs | 1 + ...ModuleToScheduledNotifications.Designer.cs | 15 ++++ ...93000_AddModuleToScheduledNotifications.cs | 36 +++++++++ .../GrantTenantDbContextModelSnapshot.cs | 4 + .../FormNotificationsApiController.cs | 77 ++++++++++++++++--- .../Components/Notifications/Default.cshtml | 9 +++ .../Components/Notifications/Default.css | 42 +++++++++- .../Components/Notifications/Default.js | 67 ++++++++++++++-- 15 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs new file mode 100644 index 0000000000..b1e5f0de6b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Events/PaymentStatusChangedEvent.cs @@ -0,0 +1,16 @@ +using System; +using Unity.Payments.Enums; + +namespace Unity.Payments.Events +{ + public class PaymentStatusChangedEvent + { + public Guid PaymentRequestId { get; set; } + + public Guid ApplicationId { get; set; } + + public PaymentRequestStatus Status { get; set; } + + public Guid? TenantId { get; set; } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index 6b3670d4cb..0e8b49e9a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -10,12 +10,14 @@ using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Shared; using Unity.Payments.Enums; +using Unity.Payments.Events; using Unity.Payments.PaymentRequests.Notifications; using Unity.Payments.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Data; +using Volo.Abp.EventBus.Local; using Volo.Abp.Features; using Volo.Abp.Users; @@ -31,7 +33,8 @@ public class PaymentRequestAppService( FsbPaymentNotifier fsbPaymentNotifier, IPaymentRequestQueryManager paymentRequestQueryManager, IPaymentRequestConfigurationManager paymentRequestConfigurationManager, - Lazy applicationLinksService) : PaymentsAppService, IPaymentRequestAppService + Lazy applicationLinksService, + ILocalEventBus localEventBus) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -60,6 +63,7 @@ public virtual async Task> CreateAsync(List> CreateHistoricalAsync(List GetNextBatchInfoAsync() { return await paymentRequestConfigurationManager.GetNextBatchInfoAsync(); @@ -212,6 +228,17 @@ public virtual async Task> UpdateStatusAsync(List CancelAsync(Guid paymentRequestId) .WithData("Status", payment.Status.ToString()); var result = await paymentsManager.CancelPaymentAsync(paymentRequestId); + + await localEventBus.PublishAsync(new PaymentStatusChangedEvent + { + PaymentRequestId = result.Id, + ApplicationId = result.CorrelationId, + Status = result.Status, + TenantId = CurrentTenant.Id + }); + return MapToPaymentRequestDto(result); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs index 9c8659e0d7..2c86cf9638 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs @@ -14,6 +14,8 @@ public class CreateUpdateNotificationDto [Required] public string TriggerType { get; set; } = "Event"; + public string? Module { get; set; } + public string? TriggerDetail { get; set; } public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs index 815c3fadbe..f7071ef01f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs @@ -9,6 +9,7 @@ public class NotificationDto : EntityDto public Guid EmailTemplateId { get; set; } public string? TemplateName { get; set; } public string TriggerType { get; set; } = string.Empty; + public string? Module { get; set; } public string? TriggerDetail { get; set; } public bool IsActive { get; set; } public string? EventType { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs index 82200085a5..5e742ea5a5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationEventHandler.cs @@ -9,6 +9,7 @@ using Unity.Notifications.Events; using Unity.Notifications.Settings; using Unity.Notifications.Templates; +using Unity.Payments.Events; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus; @@ -34,7 +35,7 @@ internal class ScheduledNotificationEventHandler( ICurrentTenant currentTenant, ScheduledNotificationHelper scheduledNotificationHelper, ILogger logger) - : ILocalEventHandler, ITransientDependency + : ILocalEventHandler, ILocalEventHandler, ITransientDependency { public async Task HandleEventAsync(ApplicationChangedEvent eventData) { @@ -58,6 +59,7 @@ public async Task HandleEventAsync(ApplicationChangedEvent eventData) n => n.FormId == application.ApplicationFormId && n.TriggerType == "Event" && n.IsActive + && (n.Module == null || n.Module == "Application") && n.ApplicationStatusId == application.ApplicationStatusId)) .ToList(); @@ -83,6 +85,53 @@ public async Task HandleEventAsync(ApplicationChangedEvent eventData) } } + public async Task HandleEventAsync(PaymentStatusChangedEvent eventData) + { + if (!await featureChecker.IsEnabledAsync("Unity.Notifications")) + { + return; + } + + try + { + var application = await applicationRepository.GetAsync(eventData.ApplicationId, includeDetails: true); + if (application == null) + { + logger.LogWarning("ScheduledNotificationEventHandler: Application {ApplicationId} not found for payment {PaymentRequestId}.", + eventData.ApplicationId, eventData.PaymentRequestId); + return; + } + + var notifications = (await scheduledNotificationRepository.GetListAsync( + n => n.FormId == application.ApplicationFormId + && n.TriggerType == "Event" + && n.IsActive + && n.Module == "Payment" + && n.EventType == eventData.Status.ToString())) + .ToList(); + + if (notifications.Count == 0) + { + return; + } + + var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); + string emailFrom = defaultFromAddress ?? "NoReply@gov.bc.ca"; + var applicantAgent = await applicantAgentRepository.FirstOrDefaultAsync(a => a.ApplicationId == application.Id); + + foreach (var notification in notifications) + { + await ProcessNotificationAsync(notification, application, applicantAgent, emailFrom); + } + } + catch (Exception ex) + { + logger.LogError(ex, + "ScheduledNotificationEventHandler: Error processing payment event for payment {PaymentRequestId}.", + eventData.PaymentRequestId); + } + } + private async Task ProcessNotificationAsync( ScheduledNotification notification, Application application, 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 fba613275c..b7b5c0ac56 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -19,6 +19,7 @@ public async Task CreateAsync(CreateUpdateNotificationDto input FormId = input.FormId, EmailTemplateId = input.EmailTemplateId, TriggerType = input.TriggerType, + Module = input.Module, TriggerDetail = input.TriggerDetail, IsActive = input.IsActive, EventType = input.EventType, @@ -38,6 +39,7 @@ public async Task CreateAsync(CreateUpdateNotificationDto input EmailTemplateId = entity.EmailTemplateId, TemplateName = null, TriggerType = entity.TriggerType, + Module = entity.Module, TriggerDetail = entity.TriggerDetail, IsActive = entity.IsActive, EventType = entity.EventType, @@ -69,6 +71,7 @@ public async Task GetAsync(Guid id) EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, @@ -104,6 +107,7 @@ public async Task> GetListAsync(GetNotifications EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, @@ -122,6 +126,7 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification var e = await _repository.GetAsync(id); e.EmailTemplateId = input.EmailTemplateId; e.TriggerType = input.TriggerType; + e.Module = input.Module; e.TriggerDetail = input.TriggerDetail; e.IsActive = input.IsActive; e.EventType = input.EventType; @@ -140,6 +145,7 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification EmailTemplateId = e.EmailTemplateId, TemplateName = null, TriggerType = e.TriggerType, + Module = e.Module, TriggerDetail = e.TriggerDetail, IsActive = e.IsActive, EventType = e.EventType, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs index 6cd43e72fc..56d66838d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs @@ -17,6 +17,8 @@ public class ScheduledNotification : FullAuditedAggregateRoot, IMultiTenan public string TriggerType { get; set; } = string.Empty; // Date or Event + public string? Module { get; set; } + public string? TriggerDetail { get; set; } public bool IsActive { get; set; } = true; 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 c77da4df0b..e6f20582d3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -426,6 +426,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(x => x.FormId).IsRequired(); b.Property(x => x.EmailTemplateId).IsRequired(); b.Property(x => x.TriggerType).IsRequired().HasMaxLength(64); + b.Property(x => x.Module).HasMaxLength(64); b.Property(x => x.TriggerDetail).HasMaxLength(1000); b.Property(x => x.EventType).HasMaxLength(128); b.Property(x => x.ApplicationStatus).HasMaxLength(128); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs new file mode 100644 index 0000000000..3aeede4d72 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.Designer.cs @@ -0,0 +1,15 @@ +// +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Unity.GrantManager.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260804193000_AddModuleToScheduledNotifications")] + partial class AddModuleToScheduledNotifications + { + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs new file mode 100644 index 0000000000..e7a5e4a498 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260804193000_AddModuleToScheduledNotifications.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AddModuleToScheduledNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Module", + schema: "Notifications", + table: "ScheduledNotifications", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.Sql(@" + UPDATE ""Notifications"".""ScheduledNotifications"" + SET ""Module"" = 'Application' + WHERE ""TriggerType"" = 'Event';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Module", + schema: "Notifications", + table: "ScheduledNotifications"); + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index 3df92980ba..22da5fd8cb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -3110,6 +3110,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("IsDeleted") .ValueGeneratedOnAdd() .HasColumnType("boolean") 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 1850dcb94e..7e6533d639 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -8,6 +8,7 @@ using Unity.Notifications.Emails; using Volo.Abp.Users; using Unity.GrantManager.Events; +using Unity.Payments.Enums; using Volo.Abp.Identity.Integration; namespace Unity.GrantManager.Web.Controllers @@ -40,6 +41,15 @@ public FormNotificationsApiController(IApplicationStatusService statusService, I _grantApplicationAppService = grantApplicationAppService; _scheduledNotificationHelper = scheduledNotificationHelper; } + + [HttpGet("payment-statuses")] + public ActionResult> GetPaymentStatuses() + { + var statuses = Enum.GetNames() + .Select(status => (object)new { id = status, internalStatus = status }) + .ToList(); + return Ok(statuses); + } // In-memory storage removed; persisting to ScheduledNotifications table via IAutomatedNotificationAppService @@ -245,8 +255,9 @@ public async Task>> GetForForm(strin TemplateId = e.EmailTemplateId, TemplateName = templateMap.TryGetValue(e.EmailTemplateId, out var t) && t != null ? t.Name : string.Empty, TriggerType = e.TriggerType, + Module = e.Module ?? (e.TriggerType == "Event" ? "Application" : null), DateType = e.DateField, - EventStatus = e.ApplicationStatus, + EventStatus = e.EventType ?? e.ApplicationStatus, ApplicationStatusId = e.ApplicationStatusId, RecipientCategory = e.RecipientCategory, RecipientIdentifier = e.RecipientIdentifier, @@ -262,11 +273,27 @@ public async Task> CreateForForm(string f { if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.RecipientIdentifier)) { return BadRequest("RecipientIdentifier required for Event trigger"); } + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && + string.Equals(input.Module, "Payment", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(input.EventStatus)) + { + return BadRequest("EventStatus required for Event trigger"); + } + + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && + string.Equals(input.Module, "Application", StringComparison.OrdinalIgnoreCase) && + !input.ApplicationStatusId.HasValue) + { + return BadRequest("ApplicationStatusId required for Application event trigger"); + } + var template = await _templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); @@ -285,10 +312,11 @@ public async Task> CreateForForm(string f FormId = parsedFormId, EmailTemplateId = template.Id, TriggerType = input.TriggerType, - TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + Module = input.Module, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : input.Module == "Payment" ? input.EventStatus : statusLabel, IsActive = true, - EventType = null, - ApplicationStatusId = input.ApplicationStatusId, + EventType = input.Module == "Payment" ? input.EventStatus : null, + ApplicationStatusId = input.Module == "Application" ? input.ApplicationStatusId : null, ApplicationStatus = statusLabel, DateField = input.DateType, RecipientCategory = input.RecipientCategory, @@ -303,8 +331,9 @@ public async Task> CreateForForm(string f TemplateId = input.TemplateId, TemplateName = template.Name, TriggerType = created.TriggerType, + Module = created.Module, DateType = created.DateField, - EventStatus = created.ApplicationStatus, + EventStatus = created.EventType ?? created.ApplicationStatus, ApplicationStatusId = created.ApplicationStatusId, RecipientCategory = created.RecipientCategory, RecipientIdentifier = created.RecipientIdentifier, @@ -355,6 +384,8 @@ public async Task> UpdateForForm(string f if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); + var template = await _templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); @@ -371,10 +402,11 @@ public async Task> UpdateForForm(string f FormId = parsedFormId, EmailTemplateId = template.Id, TriggerType = input.TriggerType, - TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + Module = input.Module, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : input.Module == "Payment" ? input.EventStatus : statusLabel, IsActive = true, - EventType = null, - ApplicationStatusId = input.ApplicationStatusId, + EventType = input.Module == "Payment" ? input.EventStatus : null, + ApplicationStatusId = input.Module == "Application" ? input.ApplicationStatusId : null, ApplicationStatus = statusLabel, DateField = input.DateType, RecipientCategory = input.RecipientCategory, @@ -389,8 +421,9 @@ public async Task> UpdateForForm(string f TemplateId = input.TemplateId, TemplateName = template.Name, TriggerType = updated.TriggerType, + Module = updated.Module, DateType = updated.DateField, - EventStatus = updated.ApplicationStatus, + EventStatus = updated.EventType ?? updated.ApplicationStatus, ApplicationStatusId = updated.ApplicationStatusId, RecipientCategory = updated.RecipientCategory, RecipientIdentifier = updated.RecipientIdentifier, @@ -399,6 +432,30 @@ public async Task> UpdateForForm(string f return Ok(dto); } + + private static bool ValidateModule(CreateScheduledNotificationInput input, out string error) + { + error = string.Empty; + if (!string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(input.Module)) + { + error = "Module required for Event trigger"; + return false; + } + + if (!string.Equals(input.Module, "Application", StringComparison.OrdinalIgnoreCase) && + !string.Equals(input.Module, "Payment", StringComparison.OrdinalIgnoreCase)) + { + error = "Module must be Application or Payment"; + return false; + } + + return true; + } } public record EmailTemplateDto @@ -418,6 +475,7 @@ public record ScheduledNotificationDto public Guid TemplateId { get; init; } public string TemplateName { get; init; } = string.Empty; public string TriggerType { get; init; } = string.Empty; + public string? Module { get; init; } public string? DateType { get; init; } public string? EventStatus { get; init; } public Guid? ApplicationStatusId { get; init; } @@ -431,6 +489,7 @@ public record CreateScheduledNotificationInput { public Guid TemplateId { get; init; } public string TriggerType { get; init; } = "Date"; + public string? Module { get; init; } public string? DateType { get; init; } public Guid? ApplicationStatusId { get; init; } public string? EventStatus { get; init; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml index 061e8cd0f7..2cbf3eb2ad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml @@ -84,6 +84,15 @@
+
+ + +
Please select a module.
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css index 2ed7dbd718..db488fafe0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css @@ -8,8 +8,18 @@ .notifications-widget .card { border: 0; } .notifications-widget .card .card-body { background: #fff; } -/* Select2 Bootstrap 5 Theme - Use default styling */ -/* Let Select2's Bootstrap 5 theme handle the layout naturally */ +/* Keep every notification form control aligned to the left column. */ +#notificationForm .left-col .form-select, +#notificationForm .left-col .form-control, +#notificationForm .left-col .select2, +#notificationForm .left-col .select2-container { + display: block; + width: 100% !important; + max-width: 100%; + box-sizing: border-box; +} + +/* Select2 Bootstrap 5 theme */ .select2-container--bootstrap-5 .select2-selection--multiple { min-height: 38px; height: auto; @@ -54,11 +64,13 @@ display: flex; flex: 1 1 auto; min-height: 0; + min-width: 0; } .left-col { - flex: 0 0 33%; + flex: 0 1 33%; min-width: 320px; + max-width: 100%; overflow-y: auto; } @@ -80,7 +92,8 @@ .notification-modal-content { display: flex; flex-direction: column; - min-width: 900px; + width: min(100%, 1200px); + min-width: min(900px, 100%); min-height: 480px; max-height: 85vh; } @@ -96,6 +109,27 @@ flex-shrink: 0; } +@media (max-width: 991.98px) { + #modalColumns { + flex-direction: column; + gap: 1.5rem; + } + + .left-col { + flex: 0 1 auto; + min-width: 0; + } + + .right-col { + min-height: 220px; + } + + .notification-modal-content { + min-width: 0; + width: 100%; + } +} + /* Notification info note styling */ .notification-info-note { background-color: #d1ecf1; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js index ee3fdc4b0b..1f17a0e2d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js @@ -137,6 +137,9 @@ function fetchStatuses() { return fetch('/api/form-notifications/statuses').then(r => r.json()); } + function fetchPaymentStatuses() { + return fetch('/api/form-notifications/payment-statuses').then(r => r.json()); + } function fetchRecipients(category) { return fetch('/api/form-notifications/recipients?category=' + encodeURIComponent(category)).then(r => r.json()); @@ -158,11 +161,19 @@ return detail; } + function renderTriggerType(data, type, row) { + if (row.triggerType === 'Event' && row.module) { + return 'Event - ' + row.module; + } + + return row.triggerType || ''; + } + function getNotificationColumns() { let index = 0; return [ { title: 'Template', name: 'templateName', data: 'templateName', visible: true, index: index++ }, - { title: 'Trigger Type', name: 'triggerType', data: 'triggerType', visible: true, index: index++ }, + { title: 'Trigger Type', name: 'triggerType', data: 'triggerType', visible: true, index: index++, render: renderTriggerType }, { title: 'Trigger Detail',name: 'triggerDetail',data: null, visible: true, orderable: true, defaultContent: '', index: index++, render: renderTriggerDetail }, { title: 'Status', name: 'status', data: 'isActive', visible: true, orderable: true, index: index++, @@ -295,7 +306,7 @@ if (modalEl) { modalEl.dataset.editId = row.id; } - document.getElementById('notificationModal')?.addEventListener('shown.bs.modal', function () { + document.getElementById('notificationModal')?.addEventListener('shown.bs.modal', async function () { const setVal = (id, val) => { document.getElementById(id).value = val ?? ''; }; @@ -326,7 +337,9 @@ const values = row.recipientIdentifier ? row.recipientIdentifier.split(',').map(v => v.trim()) : []; setSelectedRecipients(values); } else if (row.triggerType === 'Event') { - setVal('statusSelect', row.applicationStatusId); + setVal('moduleSelect', row.module); + await loadStatusesForModule(row.module); + setVal('statusSelect', row.applicationStatusId || row.eventStatus); setVal('recipientCategory', row.recipientCategory); // Set multiple values for recipient select const values = row.recipientIdentifier ? row.recipientIdentifier.split(',').map(v => v.trim()) : []; @@ -369,6 +382,25 @@ sel.appendChild(opt); }); } + async function loadStatusesForModule(module) { + const statusSelect = document.getElementById('statusSelect'); + if (!statusSelect) return; + + statusSelect.innerHTML = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.text = ''; + statusSelect.appendChild(blank); + statusSelect.disabled = !module; + + if (!module) return; + + const statuses = module === 'Payment' + ? await fetchPaymentStatuses() + : await fetchStatuses(); + populateStatuses(statuses); + statusSelect.disabled = false; + } function populateRecipients(list) { const sel = document.getElementById('recipientSelect'); @@ -480,9 +512,10 @@ function showModal() { resetValidationState(); - ['templateSelect', 'triggerType', 'dateType', 'statusSelect', 'recipientCategory'].forEach(id => { + ['templateSelect', 'triggerType', 'dateType', 'moduleSelect', 'statusSelect', 'recipientCategory'].forEach(id => { document.getElementById(id).value = ''; }); + document.getElementById('statusSelect').disabled = true; // Clear the recipient select clearSelectedRecipients(); @@ -506,7 +539,7 @@ const requiredAlways = ['templateSelect', 'triggerType']; const requiredForDate = ['dateType', 'recipientCategory', 'recipientSelect']; - const requiredForEvent = ['statusSelect', 'recipientCategory', 'recipientSelect']; + const requiredForEvent = ['moduleSelect', 'statusSelect', 'recipientCategory', 'recipientSelect']; const fieldsToValidate = [ ...requiredAlways, @@ -628,7 +661,7 @@ e.target.classList.remove('is-invalid'); updatePreview(); }); - ['dateType', 'statusSelect'].forEach(id => { + ['dateType', 'moduleSelect', 'statusSelect'].forEach(id => { document.getElementById(id)?.addEventListener('change', (e) => { e.target.classList.remove('is-invalid'); }); @@ -651,6 +684,13 @@ dateOptionsEl?.classList.add('hidden-section'); eventOptionsEl?.classList.remove('hidden-section'); recipientOptionsEl?.classList.remove('hidden-section'); + const moduleSelect = document.getElementById('moduleSelect'); + const statusSelect = document.getElementById('statusSelect'); + if (moduleSelect?.value) { + loadStatusesForModule(moduleSelect.value); + } else if (statusSelect) { + statusSelect.disabled = true; + } } else { dateOptionsEl?.classList.add('hidden-section'); eventOptionsEl?.classList.add('hidden-section'); @@ -660,6 +700,14 @@ e.target.classList.remove('is-invalid'); }); + document.getElementById('moduleSelect')?.addEventListener('change', (e) => { + e.target.classList.remove('is-invalid'); + loadStatusesForModule(e.target.value).catch(err => { + console.error('Failed to load module statuses', err); + abp.notify.error('Failed to load status triggers'); + }); + }); + document.getElementById('recipientCategory')?.addEventListener('change', (e) => { const cat = e.target.value; e.target.classList.remove('is-invalid'); @@ -693,19 +741,22 @@ const templateId = (document.getElementById('templateSelect').value || '').trim(); const dateType = document.getElementById('dateType').value; - const applicationStatusId = document.getElementById('statusSelect')?.value; + const module = document.getElementById('moduleSelect')?.value; + const statusValue = document.getElementById('statusSelect')?.value; const recipientCategory = document.getElementById('recipientCategory')?.value; // Collect multiple selected recipients as comma-separated string const recipientIdentifier = getSelectedRecipients().join(','); - const resolvedStatusId = triggerType === 'Event' ? (applicationStatusId || null) : null; + const resolvedStatusId = triggerType === 'Event' && module === 'Application' ? (statusValue || null) : null; const bodyObj = { templateId: templateId, triggerType: triggerType, + module: triggerType === 'Event' ? module : null, dateType: triggerType === 'Date' ? dateType : null, applicationStatusId: resolvedStatusId, + eventStatus: triggerType === 'Event' && module === 'Payment' ? (statusValue || null) : null, recipientCategory: recipientCategory, recipientIdentifier: recipientIdentifier }; From 3fa612f426752effff755ec0c4c05900ae8f81fa Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 5 Aug 2026 12:25:45 -0700 Subject: [PATCH 2/6] feature/AB#33652-ToolTip --- .../NotificationsSettingGroup/Default.css | 62 +++++++++++++++++-- .../NotificationsSettingGroup/Default.js | 12 +++- .../_TemplateDetails.cshtml | 16 ++++- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css index 14ef7a32e3..594aecbd47 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.css @@ -1,4 +1,50 @@ -body { +.notification-tooltip { + background: transparent; + border: 0; + cursor: help; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + height: 20px; + margin-left: 0.35rem; + padding: 0; + position: relative; + z-index: 2; + margin-top: -7px; +} + +.notification-tooltip-icon { + border: 2px solid rgb(46, 93, 215); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background-color: rgb(255, 255, 255); + color: rgb(46, 93, 215); + font-family: Georgia, serif; + font-size: 0.8rem; + font-weight: 700; + font-style: italic; + line-height: 22px; + transform: translateY(4px); +} + +.notification-tooltip:focus-visible { + outline: 2px solid #2e5dd7; + outline-offset: 2px; +} + +.notification-tooltip-popover .tooltip-inner { + font-size: 0.75rem; + line-height: 1.35; + max-width: 280px; + padding: 0.35rem 0.5rem; +} + +body { overflow-y:auto!important; } @@ -48,7 +94,7 @@ white-space: nowrap; transition: all 0.15s ease-in-out; border-radius: 4px; - font-size: 0.875rem; + font-size: 1rem; } .btn-add-user:hover:not(:disabled) { @@ -183,10 +229,16 @@ span.tooltip-wrapper { } .template-field { - flex: 0 0 135px !important; - min-width: 140px !important; + align-items: center; + box-sizing: border-box; + display: flex; + flex: 0 0 180px !important; + font-size: 0.975rem; + gap: 0.15rem; + min-width: 180px !important; white-space: nowrap; - margin: 0.5rem; + margin: 0.5rem 0.25rem 0.5rem 0.5rem; + width: 180px; } /* ── Drag ghost (prevent text selection while dragging) ───────────────────── */ 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 54e1360715..65b6569fec 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 @@ -1,4 +1,13 @@ - +function initializeTooltips() { + if (typeof bootstrap === 'undefined') return; + + document.querySelectorAll('#nav-template [data-bs-toggle="tooltip"]').forEach((tooltipElement) => { + bootstrap.Tooltip.getOrCreateInstance(tooltipElement, { + customClass: 'notification-tooltip-popover' + }); + }); +} + $(function () { const UiElements = { saveButton: $("#saveTemplateBtn"), @@ -23,6 +32,7 @@ $(function () { function init() { $('#email-attachments-section').hide(); + initializeTooltips(); initializeTemplateDataTables(); initializeDivider(); initializeTabPersistence(); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml index e09b8ab96b..377a55840a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml @@ -29,7 +29,13 @@
- +
From 1d812356557ae52bc54327d124f20b5331dc4898 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 6 Aug 2026 11:42:06 -0700 Subject: [PATCH 3/6] feature/AB#33824-AlphabeticalTemplates --- .../Shared/Components/EmailsWidget/Default.js | 36 +++++++++++-------- .../Components/Notifications/Default.js | 16 +++++---- .../Components/Notifications/Notifications.js | 6 ++-- .../js/formConfiguration/Notifications.js | 6 ++-- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 167abe5906..a361aef984 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -912,25 +912,31 @@ $select.find('option').not($placeholder).remove(); const seenTemplateIds = new Set(); - templates.forEach((template) => { - const templateName = template.name || template.Name || 'Unnamed Template'; - const templateId = (template.id || template.Id || '').toString(); - if (!templateId || seenTemplateIds.has(templateId)) { - return; - } + [...templates] + .sort((left, right) => { + const leftName = (left.name || left.Name || 'Unnamed Template').trim(); + const rightName = (right.name || right.Name || 'Unnamed Template').trim(); + return leftName.localeCompare(rightName, undefined, { sensitivity: 'base' }); + }) + .forEach((template) => { + const templateName = template.name || template.Name || 'Unnamed Template'; + const templateId = (template.id || template.Id || '').toString(); + if (!templateId || seenTemplateIds.has(templateId)) { + return; + } - seenTemplateIds.add(templateId); + seenTemplateIds.add(templateId); - const $option = $('