From 357104aa856bdff65c512637909ccf9c36d6c04e Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 25 Sep 2026 12:27:05 -0700 Subject: [PATCH] bugfix/AB#34242-PaymentEvents --- .../cypress/config/dev.json.example | 7 + .../cypress/e2e/dateNotifications.cy.ts | 227 ++++++++++++++++++ .../cypress/e2e/eventNotifications.cy.ts | 223 +++++++++++++++++ .../Events/EmailNotificationHandler.cs | 38 +-- .../css/notifications-realtime-widget.css | 15 +- .../Events/EventNotificationHandler.cs | 227 ++++++++++++++++++ .../Events/ScheduledNotificationHelper.cs | 2 +- .../AutomatedNotificationAppService.cs | 13 +- .../FormNotificationsApiController.cs | 4 +- .../Components/CommentsWidget/Default.cshtml | 1 + .../Components/CommentsWidget/Default.css | 19 +- .../Components/Notifications/Default.cshtml | 1 + .../Events/EventNotificationHandlerTests.cs | 131 ++++++++++ 13 files changed, 869 insertions(+), 39 deletions(-) create mode 100644 applications/Unity.AutoUI/cypress/e2e/dateNotifications.cy.ts create mode 100644 applications/Unity.AutoUI/cypress/e2e/eventNotifications.cy.ts create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/EventNotificationHandler.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Events/EventNotificationHandlerTests.cs diff --git a/applications/Unity.AutoUI/cypress/config/dev.json.example b/applications/Unity.AutoUI/cypress/config/dev.json.example index 48035f0b27..f3797da8f0 100644 --- a/applications/Unity.AutoUI/cypress/config/dev.json.example +++ b/applications/Unity.AutoUI/cypress/config/dev.json.example @@ -8,5 +8,12 @@ "TEST_EMAIL_TO": "", "TEST_EMAIL_CC": "", "TEST_EMAIL_BCC": "", + "notificationFormId": "", + "notificationApplicationId": "", + "notificationRecipientGroup": "James", + "notificationTemplateName": "", + "notificationProjectStartDate": "2026-09-25", + "notificationProjectEndDate": "2026-09-26", + "notificationContractExecutionDate": "2026-09-27", "chefsApiKey": "" } diff --git a/applications/Unity.AutoUI/cypress/e2e/dateNotifications.cy.ts b/applications/Unity.AutoUI/cypress/e2e/dateNotifications.cy.ts new file mode 100644 index 0000000000..dd693f9358 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/e2e/dateNotifications.cy.ts @@ -0,0 +1,227 @@ +/// + +import { loginIfNeeded } from "../support/auth"; + +type Application = { + id: string; + projectName?: string; + projectStartDate?: string | null; + projectEndDate?: string | null; + contractExecutionDate?: string | null; + requestedAmount?: number; + totalProjectBudget?: number; +}; + +type NotificationPlan = { + id: string; + templateId: string; + triggerType: string; + module?: string; + dateType?: string; + applicationStatusIds?: string[]; + recipientCategory?: string; + recipientIdentifier?: string; +}; + +type DateScenario = { + dateType: "ProjectStartDate" | "ProjectEndDate" | "ContractExecutionDate"; + date: string; +}; + +const env = (name: string): string => String(Cypress.env(name) || ""); +const formId = (): string => env("notificationFormId"); +const applicationId = (): string => env("notificationApplicationId"); +const recipientName = (): string => env("notificationRecipientGroup") || "James"; +const templateName = (): string => env("notificationTemplateName").toLowerCase(); + +const requireConfiguration = (): void => { + const missing = [ + ["notificationFormId", formId()], + ["notificationApplicationId", applicationId()], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + + if (missing.length > 0) { + throw new Error( + `Set ${missing.join(" and ")} in cypress/config/.json before running dateNotifications.cy.ts.`, + ); + } +}; + +const getApplication = (): Cypress.Chainable => + cy.request("GET", `/api/app/grant-application/${applicationId()}`).then( + (response) => { + expect(response.status).to.eq(200); + return response.body; + }, + ); + +const getTemplateId = (): Cypress.Chainable => + cy + .request("GET", "/api/form-notifications/templates?templateType=Application") + .then((response) => { + expect(response.status).to.eq(200); + const templates = response.body as Array<{ id: string; name: string }>; + const template = templateName() + ? templates.find((item) => item.name.toLowerCase() === templateName()) + : templates[0]; + expect(template, "an application notification template").to.exist; + return template!.id; + }); + +const getRecipientId = (): Cypress.Chainable => + cy.request("GET", "/api/form-notifications/recipients?category=Internal").then( + (response) => { + expect(response.status).to.eq(200); + const recipients = response.body as Array<{ id: string; displayName: string }>; + const configured = recipientName().toLowerCase(); + const recipient = recipients.find( + (item) => + item.id.toLowerCase() === configured || item.displayName.toLowerCase() === configured, + ); + expect(recipient, `internal recipient group '${recipientName()}'`).to.exist; + return recipient!.id; + }, + ); + +const updateApplicationDates = ( + original: Application, + scenario: DateScenario[], +): Cypress.Chainable => { + const start = scenario.find((item) => item.dateType === "ProjectStartDate")!.date; + const end = scenario.find((item) => item.dateType === "ProjectEndDate")!.date; + const execution = scenario.find((item) => item.dateType === "ContractExecutionDate")!.date; + + return cy + .request({ + method: "PUT", + url: `/api/app/grant-application/${applicationId()}/partial-project-info`, + body: { + data: { + projectStartDate: start, + projectEndDate: end, + }, + }, + }) + .then(() => + cy.request({ + method: "PUT", + url: `/api/app/grant-application/${applicationId()}/funding-agreement-info`, + body: { contractExecutionDate: execution }, + }), + ) + .then(() => getApplication()); +}; + +const restoreApplicationDates = (original: Application) => + cy + .request({ + method: "PUT", + url: `/api/app/grant-application/${applicationId()}/partial-project-info`, + body: { + data: { + projectStartDate: original.projectStartDate, + projectEndDate: original.projectEndDate, + }, + }, + failOnStatusCode: false, + }) + .then(() => + cy.request({ + method: "PUT", + url: `/api/app/grant-application/${applicationId()}/funding-agreement-info`, + body: { contractExecutionDate: original.contractExecutionDate }, + failOnStatusCode: false, + }), + ); + +const createNotification = ( + templateId: string, + recipientIdentifier: string, + dateType: DateScenario["dateType"], +): Cypress.Chainable => + cy + .request({ + method: "POST", + url: `/api/form-notifications/${formId()}`, + body: { + templateId, + triggerType: "Date", + module: "Application", + dateType, + applicationStatusIds: [], + recipientCategory: "Internal", + recipientIdentifier, + }, + }) + .then((response) => { + expect(response.status).to.eq(201); + return response.body as NotificationPlan; + }); + +const deleteNotification = (plan: NotificationPlan) => + cy.request({ + method: "DELETE", + url: `/api/form-notifications/${formId()}/${plan.id}`, + failOnStatusCode: false, + }); + +describe("Date-based notifications on one application", () => { + before(() => { + requireConfiguration(); + loginIfNeeded({ timeout: 60000 }); + }); + + it("creates three date plans for separate weekend dates and restores the application", () => { + const createdPlans: NotificationPlan[] = []; + let original: Application; + + const scenario: DateScenario[] = [ + { dateType: "ProjectStartDate", date: env("notificationProjectStartDate") || "2026-09-25" }, + { dateType: "ProjectEndDate", date: env("notificationProjectEndDate") || "2026-09-26" }, + { + dateType: "ContractExecutionDate", + date: env("notificationContractExecutionDate") || "2026-09-27", + }, + ]; + + getApplication() + .then((application) => { + original = application; + return updateApplicationDates(application, scenario); + }) + .then(() => getTemplateId()) + .then((templateId) => + getRecipientId().then((recipientIdentifier) => ({ templateId, recipientIdentifier })), + ) + .then(({ templateId, recipientIdentifier }) => + cy.wrap(scenario).each((rawItem) => { + const item = rawItem as unknown as DateScenario; + return ( + createNotification(templateId, recipientIdentifier, item.dateType).then((plan) => { + createdPlans.push(plan); + expect(plan.triggerType).to.eq("Date"); + expect(plan.dateType).to.eq(item.dateType); + }) + ); + }), + ) + .then(() => + cy.request("GET", `/api/form-notifications/${formId()}`), + ) + .then((response) => { + expect(response.status).to.eq(200); + createdPlans.forEach((plan) => { + const persisted = response.body.find((item) => item.id === plan.id); + expect(persisted, `persisted ${plan.dateType} notification`).to.exist; + expect(persisted!.dateType).to.eq(plan.dateType); + expect(persisted!.triggerType).to.eq("Date"); + }); + }) + .then(() => { + createdPlans.forEach((plan) => deleteNotification(plan)); + return restoreApplicationDates(original); + }); + }); +}); \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/e2e/eventNotifications.cy.ts b/applications/Unity.AutoUI/cypress/e2e/eventNotifications.cy.ts new file mode 100644 index 0000000000..fc62e9d4f6 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/e2e/eventNotifications.cy.ts @@ -0,0 +1,223 @@ +/// + +import { loginIfNeeded } from "../support/auth"; + +type NotificationPlan = { + id: string; + formId?: string; + templateId: string; + triggerType: string; + module?: string; + eventStatus?: string; + applicationStatusId?: string; + recipientCategory?: string; + recipientIdentifier?: string; +}; + +type StatusOption = { + id: string; + internalStatus: string; +}; + +const configuredFormId = () => String(Cypress.env("notificationFormId") || ""); +const configuredRecipientGroup = () => + String(Cypress.env("notificationRecipientGroup") || "James"); +const configuredTemplateName = () => + String(Cypress.env("notificationTemplateName") || ""); + +function requireNotificationConfiguration(): void { + if (!configuredFormId()) { + throw new Error( + "Set notificationFormId in cypress/config/.json before running eventNotifications.cy.ts.", + ); + } +} + +function getApplicationTemplate(): Cypress.Chainable<{ id: string; name: string }> { + return cy + .request("GET", "/api/form-notifications/templates?templateType=Application") + .then((response) => { + expect(response.status).to.eq(200); + const templates = response.body as Array<{ id: string; name: string }>; + const configuredName = configuredTemplateName().toLowerCase(); + const template = configuredName + ? templates.find((item) => item.name.toLowerCase() === configuredName) + : templates[0]; + + expect(template, "an application email template").to.exist; + return template!; + }); +} + +function getInternalRecipientGroup(): Cypress.Chainable { + return cy + .request("GET", "/api/form-notifications/recipients?category=Internal") + .then((response) => { + expect(response.status).to.eq(200); + const recipients = response.body as Array<{ + id: string; + displayName: string; + }>; + const configuredName = configuredRecipientGroup().toLowerCase(); + const recipient = recipients.find( + (item) => + item.id.toLowerCase() === configuredName || + item.displayName.toLowerCase() === configuredName, + ); + + expect( + recipient, + `the configured internal recipient group '${configuredRecipientGroup()}'`, + ).to.exist; + return recipient!.id; + }); +} + +function createNotification( + payload: Record, +): Cypress.Chainable { + return cy + .request({ + method: "POST", + url: `/api/form-notifications/${encodeURIComponent(configuredFormId())}`, + body: payload, + failOnStatusCode: false, + }) + .then((response) => { + expect(response.status, JSON.stringify(response.body)).to.eq(201); + return response.body as NotificationPlan; + }); +} + +function verifyPersistedNotification( + plan: NotificationPlan, + expected: Partial, +){ + Object.entries(expected).forEach(([key, value]) => { + expect(plan[key as keyof NotificationPlan], key).to.deep.eq(value); + }); + + return cy + .request("GET", `/api/form-notifications/${encodeURIComponent(configuredFormId())}`) + .then((response) => { + expect(response.status).to.eq(200); + const persisted = (response.body as NotificationPlan[]).find( + (item) => item.id === plan.id, + ); + expect(persisted, "the generated notification plan").to.exist; + Object.entries(expected).forEach(([key, value]) => { + expect(persisted![key as keyof NotificationPlan], `persisted ${key}`).to.deep.eq( + value, + ); + }); + return undefined; + }); +} + +function deleteNotification(plan: NotificationPlan) { + return cy + .request({ + method: "DELETE", + url: `/api/form-notifications/${encodeURIComponent(configuredFormId())}/${plan.id}`, + failOnStatusCode: false, + }) + .then((response) => { + expect([200, 204], JSON.stringify(response.body)).to.include(response.status); + return undefined; + }); +} + +describe("Event-based scheduled notifications", () => { + before(() => { + requireNotificationConfiguration(); + loginIfNeeded({ timeout: 60000 }); + }); + + it("creates and persists an Application status trigger for every available application status", () => { + let templateId: string; + let recipientIdentifier: string; + const createdPlans: NotificationPlan[] = []; + + getApplicationTemplate() + .then((template) => { + templateId = template.id; + return getInternalRecipientGroup(); + }) + .then((recipient) => { + recipientIdentifier = recipient; + return cy.request("GET", "/api/form-notifications/statuses"); + }) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body.length, "available application statuses").to.be.greaterThan(0); + + cy.wrap(response.body).each((status: StatusOption) => { + return createNotification({ + templateId, + triggerType: "Event", + module: "Application", + applicationStatusId: status.id, + recipientCategory: "Internal", + recipientIdentifier, + }).then((plan) => { + createdPlans.push(plan); + return verifyPersistedNotification(plan, { + templateId, + triggerType: "Event", + module: "Application", + applicationStatusId: status.id, + recipientCategory: "Internal", + recipientIdentifier, + }); + }); + }); + }) + .then(() => { + createdPlans.forEach((plan) => deleteNotification(plan)); + }); + }); + + it("creates and persists a Payment status trigger for every available payment status", () => { + let templateId: string; + let recipientIdentifier: string; + const createdPlans: NotificationPlan[] = []; + + getApplicationTemplate() + .then((template) => { + templateId = template.id; + return getInternalRecipientGroup(); + }) + .then((recipient) => { + recipientIdentifier = recipient; + return cy.request("GET", "/api/form-notifications/payment-statuses"); + }) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body.length, "available payment statuses").to.be.greaterThan(0); + + cy.wrap(response.body).each((status: StatusOption) => { + return createNotification({ + templateId, + triggerType: "Event", + module: "Payment", + eventStatus: status.id, + recipientCategory: "Internal", + recipientIdentifier, + }).then((plan) => { + createdPlans.push(plan); + return verifyPersistedNotification(plan, { + templateId, + triggerType: "Event", + module: "Payment", + eventStatus: status.id, + recipientCategory: "Internal", + recipientIdentifier, + }); + }); + }); + }) + .then(() => { + createdPlans.forEach((plan) => deleteNotification(plan)); + }); + }); +}); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs index a72e771925..fdb4afd8a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationHandler.cs @@ -43,26 +43,36 @@ public async Task HandleEventAsync(EmailNotificationEvent eventData) // Create a new UnitOfWork for this tenant to ensure database operations use the correct tenant's connection using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); - if (eventData.Action == EmailAction.SendCustom && eventData.Id != Guid.Empty) + try { - // Fail before changing an existing draft or copying any new S3 objects. - await emailAttachmentService.ValidateEmailAttachmentsAsync(eventData.Id); - } + if (eventData.Action == EmailAction.SendCustom && eventData.Id != Guid.Empty) + { + // Fail before changing an existing draft or copying any new S3 objects. + await emailAttachmentService.ValidateEmailAttachmentsAsync(eventData.Id); + } - var emailLog = await EmailNotificationEventAsync(eventData); + var emailLog = await EmailNotificationEventAsync(eventData); - if (emailLog != null) - { - // Validate before committing the transition out of Draft. If an object is - // missing, the unit of work rolls back so the user can remove/re-upload it. - await emailAttachmentService.ValidateEmailAttachmentsAsync(emailLog.Id); - } + if (emailLog != null) + { + // Validate before committing the transition out of Draft. If an object is + // missing, the unit of work rolls back so the user can remove/re-upload it. + await emailAttachmentService.ValidateEmailAttachmentsAsync(emailLog.Id); + } - await uow.CompleteAsync(); + await uow.CompleteAsync(); - if (emailLog != null) + if (emailLog != null) + { + await emailNotificationService.SendEmailToQueue(emailLog); + } + } + catch (MissingEmailAttachmentsException ex) { - await emailNotificationService.SendEmailToQueue(emailLog); + _logger.LogError( + ex, + "Email notification skipped because one or more attachments are missing for event {EventId}.", + eventData.Id); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css index e86ecc10b2..59aa178bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css @@ -7,9 +7,13 @@ .rt-widget-bubble { position: relative; - width: 40px; - height: 40px; - border-radius: 50%; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + max-width: 34px; + max-height: 34px; + border-radius: 75%; border: none; background-color: #2e5dd7; color: #fff; @@ -22,6 +26,11 @@ touch-action: none; } +.rt-widget-bubble svg { + width: 20px; + height: 20px; +} + .rt-widget-bubble:hover { background-color: #244bb0; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/EventNotificationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/EventNotificationHandler.cs new file mode 100644 index 0000000000..e0b5dba8f8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/EventNotificationHandler.cs @@ -0,0 +1,227 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Notifications; +using Unity.Notifications.EmailGroups; +using Unity.Notifications.Emails; +using Unity.Notifications.Events; +using Unity.Notifications.Settings; +using Unity.Notifications.Templates; +using Unity.Payments.Events; +using Unity.Payments.Enums; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.EventBus; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; +using Volo.Abp.Identity.Integration; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; + +namespace Unity.GrantManager.Events +{ + internal class EventNotificationHandler( + IRepository scheduledNotificationRepository, + IApplicationRepository applicationRepository, + IApplicantAgentRepository applicantAgentRepository, + ILocalEventBus localEventBus, + ITemplateService templateService, + IEmailGroupsAppService emailGroupsAppService, + IEmailGroupUsersAppService emailGroupUsersAppService, + IIdentityUserIntegrationService identityUserIntegrationService, + IFeatureChecker featureChecker, + ISettingProvider settingProvider, + ICurrentTenant currentTenant, + ScheduledNotificationHelper scheduledNotificationHelper, + ILogger logger) + : ILocalEventHandler, ILocalEventHandler, ITransientDependency + { + public async Task HandleEventAsync(ApplicationChangedEvent eventData) + { + if (!await featureChecker.IsEnabledAsync("Unity.Notifications")) + { + return; + } + + try + { + var application = await applicationRepository.GetAsync(eventData.ApplicationId, includeDetails: true); + if (application == null) + { + logger.LogWarning("EventNotificationHandler: Application {ApplicationId} not found.", eventData.ApplicationId); + return; + } + + var notifications = (await scheduledNotificationRepository.GetListAsync( + ApplicationEventNotificationFilter(application.ApplicationFormId, application.ApplicationStatusId))) + .ToList(); + + if (notifications.Count == 0) + { + return; + } + + var emailFrom = await GetDefaultFromAddressAsync(); + 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, "EventNotificationHandler: Error processing event for application {ApplicationId}.", eventData.ApplicationId); + } + } + + internal static Expression> ApplicationEventNotificationFilter( + Guid formId, + Guid applicationStatusId) + { + return n => n.FormId == formId + && n.TriggerType == "Event" + && n.IsActive + && (n.Module == null || n.Module == "Application") + && n.ApplicationStatusId == applicationStatusId; + } + + internal static Expression> PaymentEventNotificationFilter( + Guid formId, + PaymentRequestStatus paymentStatus) + { + return n => n.FormId == formId + && n.TriggerType == "Event" + && n.IsActive + && n.Module == "Payment" + && n.EventType == paymentStatus.ToString(); + } + + 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( + "EventNotificationHandler: Application {ApplicationId} not found for payment {PaymentRequestId}.", + eventData.ApplicationId, + eventData.PaymentRequestId); + return; + } + + var notifications = (await scheduledNotificationRepository.GetListAsync( + PaymentEventNotificationFilter(application.ApplicationFormId, eventData.Status))) + .ToList(); + + if (notifications.Count == 0) + { + return; + } + + var emailFrom = await GetDefaultFromAddressAsync(); + 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, + "EventNotificationHandler: Error processing payment event for payment {PaymentRequestId}.", + eventData.PaymentRequestId); + } + } + + private async Task GetDefaultFromAddressAsync() + { + return await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress) + ?? "NoReply@gov.bc.ca"; + } + + private async Task ProcessNotificationAsync( + ScheduledNotification notification, + Application application, + ApplicantAgent? applicantAgent, + string emailFrom) + { + if (string.IsNullOrWhiteSpace(notification.RecipientCategory) || + string.IsNullOrWhiteSpace(notification.RecipientIdentifier)) + { + logger.LogWarning( + "EventNotificationHandler: Scheduled notification {NotificationId} has no recipient category or identifier, skipping.", + notification.Id); + return; + } + + var template = await templateService.GetTemplateById(notification.EmailTemplateId); + if (template == null) + { + logger.LogWarning( + "EventNotificationHandler: Email template {TemplateId} not found for scheduled notification {NotificationId}, skipping.", + notification.EmailTemplateId, + notification.Id); + return; + } + + var tokenValues = ScheduledNotificationHelper.BuildTokenValues(application, applicantAgent); + var subject = ScheduledNotificationHelper.RenderTemplate(template.Subject, tokenValues); + var body = ScheduledNotificationHelper.RenderTemplate( + string.IsNullOrWhiteSpace(template.BodyHTML) ? template.BodyText : template.BodyHTML, + tokenValues); + + var emailEvent = new EmailNotificationEvent + { + Action = EmailAction.SendEventDriven, + TenantId = currentTenant.Id, + ApplicationId = application.Id, + ScheduledNotificationId = notification.Id, + TemplateId = template.Id, + EmailTemplateName = template.Name, + Subject = subject, + Body = body, + EmailFrom = emailFrom, + RetryAttempts = 0 + }; + + if (string.Equals(notification.RecipientCategory, "Internal", StringComparison.OrdinalIgnoreCase)) + { + await scheduledNotificationHelper.PublishToEmailGroupAsync( + emailGroupsAppService, + emailGroupUsersAppService, + identityUserIntegrationService, + localEventBus, + notification, + emailEvent); + } + else if (string.Equals(notification.RecipientCategory, "External", StringComparison.OrdinalIgnoreCase)) + { + await scheduledNotificationHelper.PublishToExternalRecipientAsync( + localEventBus, + notification, + application, + applicantAgent, + emailEvent); + } + else + { + logger.LogWarning( + "EventNotificationHandler: Unknown RecipientCategory '{Category}' on notification {NotificationId}, skipping.", + notification.RecipientCategory, + notification.Id); + } + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs index 9b1765a7c2..0bda2d8997 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs @@ -59,7 +59,7 @@ public static Dictionary BuildTokenValues( ["organization_name"] = applicant?.OrgName ?? applicant?.NonRegisteredBusinessName ?? string.Empty, ["submission_number"] = application.ReferenceNo, ["submission_date"] = application.SubmissionDate.ToString("yyyy-MM-dd"), - ["status"] = applicationStatus?.StatusCode.ToString() ?? string.Empty, + ["status"] = applicationStatus?.InternalStatus.ToString() ?? string.Empty, ["approved_amount"] = application.ApprovedAmount.ToString("$#,##0.00"), ["requested_amount"] = application.RequestedAmount.ToString("$#,##0.00"), ["recommended_amount"] = application.RecommendedAmount.ToString("$#,##0.00"), 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 ee9a392a99..cf5128eba7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -4,7 +4,6 @@ using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; -using Volo.Abp.Validation; using Microsoft.EntityFrameworkCore; namespace Unity.GrantManager.Notifications @@ -15,8 +14,6 @@ public class AutomatedNotificationAppService(IRepository CreateAsync(CreateUpdateNotificationDto input) { - EnsureDateBasedNotification(input); - var entity = new ScheduledNotification { FormId = input.FormId, @@ -130,8 +127,6 @@ public async Task> GetListAsync(GetNotifications public async Task UpdateAsync(Guid id, CreateUpdateNotificationDto input) { - EnsureDateBasedNotification(input); - var e = await _repository.GetAsync(id); e.EmailTemplateId = input.EmailTemplateId; e.TriggerType = input.TriggerType; @@ -166,12 +161,6 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification }; } - private static void EnsureDateBasedNotification(CreateUpdateNotificationDto input) - { - if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase)) - { - throw new AbpValidationException("Event-based notifications are not supported."); - } - } + } } 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 3a2389c467..262fb3e433 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -223,11 +223,11 @@ public async Task>> GetForForm(strin { if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); - var listResult = await _automatedNotificationAppService.GetListAsync(new Unity.GrantManager.Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); + var listResult = await _automatedNotificationAppService.GetListAsync(new Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); // Resolve template names and status labels var templateIds = listResult.Items.Select(x => x.EmailTemplateId).Where(id => id != Guid.Empty).Distinct().ToList(); - var templateMap = new Dictionary(); + var templateMap = new Dictionary(); foreach (var id in templateIds) { templateMap[id] = await _templateService.GetTemplateById(id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CommentsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CommentsWidget/Default.cshtml index 6e5f8a1342..5041dc9bdf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CommentsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CommentsWidget/Default.cshtml @@ -120,6 +120,7 @@
@L["ApplicationDetails:Comments.Created"].Value @comment.CreationTime.ToString("yyyy-MM-dd h:mm tt")
} +