diff --git a/applications/Unity.AutoUI/cypress/config/dev.json.example b/applications/Unity.AutoUI/cypress/config/dev.json.example
index 48035f0b2..f3797da8f 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 000000000..dd693f935
--- /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 000000000..fc62e9d4f
--- /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 a72e77192..fdb4afd8a 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 e86ecc10b..59aa178be 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 000000000..e0b5dba8f
--- /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 9b1765a7c..0bda2d899 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 ee9a392a9..cf5128eba 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 3a2389c46..262fb3e43 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 6e5f8a134..5041dc9bd 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 @@
}
+ @comment.Badge