From 281846354c8c8d3089f2d1db766a06fa82edc010 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 17 Apr 2026 14:14:02 -0700 Subject: [PATCH 001/259] feature/AB#32634-Enhance email cheduling --- .../Emails/EmailHistoryDto.cs | 1 + .../EmailNotificaions/EmailMessageParams.cs | 15 ++ .../EmailNotificationManager.cs | 86 ++++---- .../EmailNotificationService.cs | 20 +- .../IEmailNotificationManager.cs | 8 +- .../IEmailNotificationService.cs | 8 +- .../Events/EmailNotificationEvent.cs | 5 + .../Events/EmailNotificationHandler.cs | 129 +++++------- .../NotificationsSettingDefinitionProvider.cs | 3 +- .../Settings/NotificationsSettings.cs | 1 + .../Settings/NotificationsSettingsDto.cs | 1 + .../NotificationsSettingGroup/Default.cshtml | 6 + .../NotificationsSettingViewComponent.cs | 5 +- .../NotificationsSettingViewModel.cs | 3 + .../Utils/DateTimeExtensions.cs | 72 ++++--- .../Unity.Theme.UX2/wwwroot/js/DateUtils.js | 118 ++++++++++- .../Notifications/CreateEmailDto.cs | 5 + .../DataHealthCheckWorker.cs | 11 +- .../BackgroundWorkers/IntakeSyncWorker.cs | 11 +- .../Norifications/EmailAppService.cs | 3 +- .../GrantManagerDataSeederContributor.cs | 24 ++- .../AssessmentScoresWidget/Default.cshtml | 2 +- .../EmailHistoryWidget/Default.cshtml | 6 +- .../Components/EmailHistoryWidget/Default.js | 16 +- .../EmailHistoryWidgetViewComponent.cs | 14 +- .../EmailHistoryWidgetViewModel.cs | 6 + .../Components/EmailsWidget/Default.cshtml | 71 ++++++- .../Components/EmailsWidget/Default.css | 71 +++++++ .../Shared/Components/EmailsWidget/Default.js | 186 ++++++++++++++++-- .../EmailsWidget/EmailsWidgetViewComponent.cs | 5 + .../EmailsWidget/EmailsWidgetViewModel.cs | 3 + 31 files changed, 698 insertions(+), 217 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailMessageParams.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailHistoryDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailHistoryDto.cs index be48289392..d34ba6079e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailHistoryDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailHistoryDto.cs @@ -16,6 +16,7 @@ public class EmailHistoryDto : ExtensibleAuditedEntityDto public string Body { get; set; } = string.Empty; public EmailHistoryUserDto? SentBy { get; set; } public string TemplateName { get; set; } = string.Empty; + public DateTime? SendOnDateTime { get; set; } } public class EmailHistoryUserDto : EntityDto diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailMessageParams.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailMessageParams.cs new file mode 100644 index 0000000000..543fde9c2e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailMessageParams.cs @@ -0,0 +1,15 @@ +namespace Unity.Notifications.EmailNotifications +{ + /// + /// Groups common email content fields to reduce method parameter count (S107). + /// + public sealed record EmailMessageParams( + string EmailTo, + string Body, + string Subject, + string? EmailFrom, + string? EmailTemplateName, + string? EmailCC = null, + string? EmailBCC = null, + System.DateTime? SendOnDateTime = null); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs index 77a905aada..a4c7f57b17 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -31,42 +31,44 @@ public class EmailNotificationManager( EmailAttachmentService emailAttachmentService, ISettingProvider settingProvider) : DomainService, IEmailNotificationManager { - public async Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task CreateEmailLogAsync(EmailMessageParams email, Guid applicationId) { - return await CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, EmailStatus.Initialized, emailTemplateName, emailCC, emailBCC); + return await CreateEmailLogAsync(email, applicationId, EmailStatus.Initialized); } [RemoteService(false)] - public async Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task CreateEmailLogAsync(EmailMessageParams email, Guid applicationId, string? status) { - if (string.IsNullOrEmpty(emailTo)) + if (string.IsNullOrEmpty(email.EmailTo)) { return null; } - var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); + var emailObject = await GetEmailObjectAsync(email, "html"); EmailLog emailLog = new(); emailLog = UpdateMappedEmailLog(emailLog, emailObject); emailLog.ApplicationId = applicationId; emailLog.Status = status ?? EmailStatus.Initialized; + emailLog.SendOnDateTime = email.SendOnDateTime; // When being called here the current tenant is in context - verified by looking at the tenant id EmailLog loggedEmail = await emailLogsRepository.InsertAsync(emailLog, autoSave: true); return loggedEmail; } - public async Task UpdateEmailLogAsync(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task UpdateEmailLogAsync(Guid emailId, EmailMessageParams email, Guid applicationId, string? status) { - if (string.IsNullOrEmpty(emailTo)) + if (string.IsNullOrEmpty(email.EmailTo)) { return null; } - var emailObject = await GetEmailObjectAsync(emailTo, body, subject, emailFrom, "html", emailTemplateName, emailCC, emailBCC); + var emailObject = await GetEmailObjectAsync(email, "html"); EmailLog emailLog = await emailLogsRepository.GetAsync(emailId); emailLog = UpdateMappedEmailLog(emailLog, emailObject); emailLog.ApplicationId = applicationId; emailLog.Id = emailId; emailLog.Status = status ?? EmailStatus.Initialized; + emailLog.SendOnDateTime = email.SendOnDateTime; // When being called here the current tenant is in context - verified by looking at the tenant id EmailLog loggedEmail = await emailLogsRepository.UpdateAsync(emailLog, autoSave: true); @@ -131,11 +133,11 @@ public async Task DeleteEmailLogAsync(Guid id) /// CC email addresses /// BCC email addresses /// HttpResponseMessage indicating the result of the operation - public async Task SendEmailAsync(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task SendEmailAsync(EmailMessageParams email, string? emailBodyType = null) { try { - if (string.IsNullOrEmpty(emailTo)) + if (string.IsNullOrEmpty(email.EmailTo)) { Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailTo' parameter is null or empty."); return new HttpResponseMessage(HttpStatusCode.BadRequest) @@ -145,8 +147,7 @@ public async Task SendEmailAsync(string emailTo, string bod } // Send the email using the CHES client service - var emailObject = await GetEmailObjectAsync( - emailTo, body, subject, emailFrom, emailBodyType, emailTemplateName, emailCC, emailBCC, excludeTemplate: true); + var emailObject = await GetEmailObjectAsync(email, emailBodyType, excludeTemplate: true); var response = await chesClientService.SendAsync(emailObject); @@ -173,15 +174,6 @@ public async Task SendEmailAsync(EmailLog emailLog) { try { - if (emailLog == null) - { - Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailLog' parameter is null."); - return new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("'emailLog' cannot be null.") - }; - } - if (string.IsNullOrEmpty(emailLog.ToAddress)) { Logger.LogError("EmailNotificationManager->SendEmailAsync: The 'emailLog.ToAddress' parameter is null or empty."); @@ -201,7 +193,7 @@ public async Task SendEmailAsync(EmailLog emailLog) } catch (Exception ex) { - Logger.LogError(ex, "EmailNotificationManager->SendEmailAsync: Exception occurred while sending email for EmailLog {EmailId}.", emailLog?.Id); + Logger.LogError(ex, "EmailNotificationManager->SendEmailAsync: Exception occurred while sending email for EmailLog {EmailId}.", emailLog.Id); return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent($"An exception occurred while sending the email: {ex.Message}") @@ -237,22 +229,16 @@ public async Task GetPendingEmailsCountAsync() var allEmailLogs = await emailLogsRepository.GetListAsync(); var emailLogs = allEmailLogs.Where(filter.Compile()).ToList(); - // Ensure we're returning 0 if no logs are found - return emailLogs?.Count ?? 0; + return emailLogs.Count; } public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLog) { // Get base email object (without attachments) var emailObject = await GetEmailObjectAsync( - emailLog.ToAddress, - emailLog.Body, - emailLog.Subject, - emailLog.FromAddress, + new EmailMessageParams(emailLog.ToAddress, emailLog.Body, emailLog.Subject, + emailLog.FromAddress, emailLog.TemplateName, emailLog.CC, emailLog.BCC), emailLog.BodyType, - emailLog.TemplateName, - emailLog.CC, - emailLog.BCC, excludeTemplate: true); // Retrieve attachments from S3 @@ -281,35 +267,35 @@ public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLo emailObjectDictionary["attachments"] = attachmentList.ToArray(); } + if (emailLog.SendOnDateTime.HasValue) + { + var emailObjectDictionary = (IDictionary)emailObject; + emailObjectDictionary["delayTS"] = new DateTimeOffset(emailLog.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeSeconds(); + } + return emailObject; } protected virtual async Task GetEmailObjectAsync( - string emailTo, - string body, - string subject, - string? emailFrom, - string? emailBodyType, - string? emailTemplateName, - string? emailCC = null, - string? emailBCC = null, - bool excludeTemplate = false) + EmailMessageParams email, + string? emailBodyType = null, + bool excludeTemplate = false) { - var toList = emailTo.ParseEmailList() ?? []; - var ccList = emailCC.ParseEmailList(); - var bccList = emailBCC.ParseEmailList(); + var toList = email.EmailTo.ParseEmailList() ?? []; + var ccList = email.EmailCC.ParseEmailList(); + var bccList = email.EmailBCC.ParseEmailList(); var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); dynamic emailObject = new ExpandoObject(); var emailObjectDictionary = (IDictionary)emailObject; - emailObjectDictionary["body"] = body; + emailObjectDictionary["body"] = email.Body; emailObjectDictionary["bodyType"] = emailBodyType ?? "text"; emailObjectDictionary["encoding"] = "utf-8"; - emailObjectDictionary["from"] = emailFrom ?? defaultFromAddress ?? "NoReply@gov.bc.ca"; + emailObjectDictionary["from"] = email.EmailFrom ?? defaultFromAddress ?? "NoReply@gov.bc.ca"; emailObjectDictionary["priority"] = "normal"; - emailObjectDictionary["subject"] = subject; + emailObjectDictionary["subject"] = email.Subject; emailObjectDictionary["tag"] = "tag"; emailObjectDictionary["to"] = toList; @@ -323,11 +309,17 @@ protected virtual async Task GetEmailObjectAsync( emailObjectDictionary["bcc"] = bccList; } + // delayTS: desired UTC send time as Unix seconds; 0 = send immediately. + if (email.SendOnDateTime.HasValue) + { + emailObjectDictionary["delayTS"] = new DateTimeOffset(email.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeSeconds(); + } + // templateName is not part of the CHES MessageObject schema // store it on the EmailLog but don't send it to the API. if (!excludeTemplate) { - emailObjectDictionary["templateName"] = emailTemplateName; + emailObjectDictionary["templateName"] = email.EmailTemplateName; } return emailObject; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 5471937cb6..eed6705e03 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -48,20 +48,20 @@ public async Task GetEmailsChesWithNoResponseCountAsync() return await emailNotificationManager.GetPendingEmailsCountAsync(); } - public async Task UpdateEmailLog(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task UpdateEmailLog(Guid emailId, EmailMessageParams email, Guid applicationId, string? status) { - return await emailNotificationManager.UpdateEmailLogAsync(emailId, emailTo, body, subject, applicationId, emailFrom, status, emailTemplateName, emailCC, emailBCC); + return await emailNotificationManager.UpdateEmailLogAsync(emailId, email, applicationId, status); } - public async Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task InitializeEmailLog(EmailMessageParams email, Guid applicationId) { - return await emailNotificationManager.CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, emailTemplateName, emailCC, emailBCC); + return await emailNotificationManager.CreateEmailLogAsync(email, applicationId); } [RemoteService(false)] - public async Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task InitializeEmailLog(EmailMessageParams email, Guid applicationId, string? status) { - return await emailNotificationManager.CreateEmailLogAsync(emailTo, body, subject, applicationId, emailFrom, status, emailTemplateName, emailCC, emailBCC); + return await emailNotificationManager.CreateEmailLogAsync(email, applicationId, status); } protected virtual async Task NotifyTeamsChannel(string chesEmailError) @@ -139,7 +139,8 @@ public async Task SendCommentNotification(EmailCommentDto i foreach (var email in input.MentionNamesEmail) { var toEmail = email; - res = await emailNotificationManager.SendEmailAsync(toEmail, htmlBody, subject, fromEmail, "html", input.EmailTemplateName); + res = await emailNotificationManager.SendEmailAsync( + new EmailMessageParams(toEmail, htmlBody, subject, fromEmail, input.EmailTemplateName), "html"); } } else @@ -174,9 +175,9 @@ public async Task SendCommentNotification(EmailCommentDto i /// CC email addresses /// BCC email addresses /// HttpResponseMessage indicating the result of the operation - public async Task SendEmailNotification(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + public async Task SendEmailNotification(EmailMessageParams email, string? emailBodyType = null) { - return await emailNotificationManager.SendEmailAsync(emailTo, body, subject, emailFrom, emailBodyType, emailTemplateName, emailCC, emailBCC); + return await emailNotificationManager.SendEmailAsync(email, emailBodyType); } /// @@ -243,6 +244,7 @@ public async Task UpdateSettings(NotificationsSettingsDto settingsDto) { await UpdateTenantSettings(NotificationsSettings.Mailing.DefaultFromAddress, settingsDto.DefaultFromAddress); await UpdateTenantSettings(NotificationsSettings.Mailing.EmailMaxRetryAttempts, settingsDto.MaximumRetryAttempts); + await settingManager.SetForCurrentTenantAsync(NotificationsSettings.Mailing.EnableEmailDelay, settingsDto.EnableEmailDelay ? "true" : "false"); } private async Task UpdateTenantSettings(string settingKey, string valueString) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs index cc9fe5a31f..8f069b2ef2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs @@ -14,17 +14,17 @@ public interface IEmailNotificationManager /// /// Creates and initializes a new email log /// - Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task CreateEmailLogAsync(EmailMessageParams email, Guid applicationId); /// /// Creates and initializes a new email log with status /// - Task CreateEmailLogAsync(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task CreateEmailLogAsync(EmailMessageParams email, Guid applicationId, string? status); /// /// Updates an existing email log /// - Task UpdateEmailLogAsync(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task UpdateEmailLogAsync(Guid emailId, EmailMessageParams email, Guid applicationId, string? status); /// /// Retrieves an email log by ID @@ -44,7 +44,7 @@ public interface IEmailNotificationManager /// /// Sends an email notification using CHES /// - Task SendEmailAsync(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task SendEmailAsync(EmailMessageParams email, string? emailBodyType = null); /// /// Sends an email notification from an EmailLog (with S3 attachments support) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs index c598fef948..554e62050f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs @@ -10,12 +10,12 @@ namespace Unity.Notifications.EmailNotifications { public interface IEmailNotificationService : IApplicationService { - Task UpdateEmailLog(Guid emailId, string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); - Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); - Task InitializeEmailLog(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task UpdateEmailLog(Guid emailId, EmailMessageParams email, Guid applicationId, string? status); + Task InitializeEmailLog(EmailMessageParams email, Guid applicationId, string? status); + Task InitializeEmailLog(EmailMessageParams email, Guid applicationId); Task GetEmailLogById(Guid id); Task SendCommentNotification(EmailCommentDto input); - Task SendEmailNotification(string emailTo, string body, string subject, string? emailFrom, string? emailBodyType, string? emailTemplateName, string? emailCC = null, string? emailBCC = null); + Task SendEmailNotification(EmailMessageParams email, string? emailBodyType = null); Task SendEmailNotification(EmailLog emailLog); Task SendEmailToQueue(EmailLog emailLog); Task> GetHistoryByApplicationId(Guid applicationId); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs index adf4b2dac3..d14973807d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Events/EmailNotificationEvent.cs @@ -25,6 +25,11 @@ public class EmailNotificationEvent public string? EmailTemplateName { get; set; } = string.Empty; public List? EmailAttachments { get; set; } public List? PaymentRequestIds { get; set; } + + /// + /// Optional specific UTC date/time to send the email. + /// + public DateTime? SendOnDateTime { get; set; } } public class EmailAttachmentData 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 b1f17f60eb..37ac021592 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 @@ -50,19 +50,21 @@ public async Task HandleEventAsync(EmailNotificationEvent eventData) } } - private async Task InitializeEmailAndUploadAttachments(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string? emailTemplateName, string? emailCC = null, string? emailBCC = null, List? emailAttachments = null) + private sealed record EmailInitParams( + string EmailTo, + string Body, + string Subject, + Guid ApplicationId, + string? EmailFrom, + string? EmailTemplateName, + string? EmailCC = null, + string? EmailBCC = null, + DateTime? SendOnDateTime = null); + + private async Task InitializeEmailAndUploadAttachments(EmailInitParams p, List? emailAttachments = null) { - EmailLog emailLog = await InitializeEmail( - emailTo, - body, - subject, - applicationId, - emailFrom, - EmailStatus.Initialized, - emailTemplateName, - emailCC, - emailBCC); - + EmailLog emailLog = await InitializeEmail(p, EmailStatus.Initialized); + try { // Upload attachments to S3 @@ -90,18 +92,13 @@ await emailAttachmentService.UploadAttachmentAsync( return emailLog; } - private async Task InitializeEmail(string emailTo, string body, string subject, Guid applicationId, string? emailFrom, string status, string? emailTemplateName, string? emailCC = null, string? emailBCC = null) + private async Task InitializeEmail(EmailInitParams p, string status) { EmailLog emailLog = await emailNotificationService.InitializeEmailLog( - emailTo, - body, - subject, - applicationId, - emailFrom, - status, - emailTemplateName, - emailCC, - emailBCC) ?? throw new UserFriendlyException("Unable to Initialize Email Log"); + new EmailMessageParams(p.EmailTo, p.Body, p.Subject, + p.EmailFrom, p.EmailTemplateName, p.EmailCC, p.EmailBCC, p.SendOnDateTime), + p.ApplicationId, + status) ?? throw new UserFriendlyException("Unable to Initialize Email Log"); return emailLog; } @@ -119,12 +116,8 @@ private async Task InitializeEmail(string emailTo, string body, string string emailToAddress = String.Join(",", eventData.EmailAddressList); return await InitializeEmailAndUploadAttachments( - emailToAddress, - eventData.Body, - FAILED_PAYMENTS_SUBJECT, - eventData.ApplicationId, - eventData.EmailFrom, - eventData.EmailTemplateName); + new EmailInitParams(emailToAddress, eventData.Body, FAILED_PAYMENTS_SUBJECT, + eventData.ApplicationId, eventData.EmailFrom, eventData.EmailTemplateName)); } case EmailAction.SendCustom: return await HandleSendCustomEmail(eventData); @@ -137,14 +130,9 @@ private async Task InitializeEmail(string emailTo, string body, string { string fsbEmailToAddress = String.Join(",", eventData.EmailAddressList); var emailLog = await InitializeEmailAndUploadAttachments( - fsbEmailToAddress, - eventData.Body, - eventData.Subject ?? "FSB Payment Notification", - eventData.ApplicationId, - eventData.EmailFrom, - eventData.EmailTemplateName, - null, // emailCC - null, // emailBCC + new EmailInitParams(fsbEmailToAddress, eventData.Body, + eventData.Subject ?? "FSB Payment Notification", + eventData.ApplicationId, eventData.EmailFrom, eventData.EmailTemplateName), eventData.EmailAttachments); // Store payment request IDs for tracking @@ -167,39 +155,32 @@ private async Task InitializeEmail(string emailTo, string body, string string emailToAddress = String.Join(",", eventData.EmailAddressList); string? emailCC = eventData.Cc?.Any() == true ? String.Join(",", eventData.Cc) : null; string? emailBCC = eventData.Bcc?.Any() == true ? String.Join(",", eventData.Bcc) : null; - + + EmailLog? emailLog; if (eventData.Id == Guid.Empty) { - return await InitializeEmailAndUploadAttachments( - emailToAddress, - eventData.Body, - eventData.Subject, - eventData.ApplicationId, - eventData.EmailFrom, - eventData.EmailTemplateName, - emailCC, - emailBCC, + emailLog = await InitializeEmailAndUploadAttachments( + new EmailInitParams(emailToAddress, eventData.Body, eventData.Subject, + eventData.ApplicationId, eventData.EmailFrom, eventData.EmailTemplateName, + emailCC, emailBCC, eventData.SendOnDateTime), eventData.EmailAttachments); } - - EmailLog? emailLog = await emailNotificationService.UpdateEmailLog( - eventData.Id, - emailToAddress, - eventData.Body, - eventData.Subject, - eventData.ApplicationId, - eventData.EmailFrom, - EmailStatus.Initialized, - eventData.EmailTemplateName, - emailCC, - emailBCC); - - if (emailLog != null) + else { - return emailLog; + emailLog = await emailNotificationService.UpdateEmailLog( + eventData.Id, + new EmailMessageParams(emailToAddress, eventData.Body, eventData.Subject, + eventData.EmailFrom, eventData.EmailTemplateName, emailCC, emailBCC, eventData.SendOnDateTime), + eventData.ApplicationId, + EmailStatus.Initialized); + + if (emailLog == null) + { + throw new UserFriendlyException("Unable to update Email Log"); + } } - throw new UserFriendlyException("Unable to update Email Log"); + return emailLog; } private async Task HandleSaveDraftEmail(EmailNotificationEvent eventData) @@ -212,28 +193,18 @@ private async Task HandleSaveDraftEmail(EmailNotificationEvent eventData) { await emailNotificationService.UpdateEmailLog( eventData.Id, - emailToAddress, - eventData.Body, - eventData.Subject, + new EmailMessageParams(emailToAddress, eventData.Body, eventData.Subject, + eventData.EmailFrom, eventData.EmailTemplateName, emailCC, emailBCC, eventData.SendOnDateTime), eventData.ApplicationId, - eventData.EmailFrom, - EmailStatus.Draft, - eventData.EmailTemplateName, - emailCC, - emailBCC); + EmailStatus.Draft); } else { await InitializeEmail( - emailToAddress, - eventData.Body, - eventData.Subject, - eventData.ApplicationId, - eventData.EmailFrom, - EmailStatus.Draft, - eventData.EmailTemplateName, - emailCC, - emailBCC); + new EmailInitParams(emailToAddress, eventData.Body, eventData.Subject, + eventData.ApplicationId, eventData.EmailFrom, eventData.EmailTemplateName, + emailCC, emailBCC, eventData.SendOnDateTime), + EmailStatus.Draft); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingDefinitionProvider.cs index ca28101aec..a1353aadc3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingDefinitionProvider.cs @@ -12,7 +12,8 @@ public override void Define(ISettingDefinitionContext context) var notificationsSettings = new Dictionary { { NotificationsSettings.Mailing.DefaultFromAddress, "NoReply.Unity@gov.bc.ca"}, - { NotificationsSettings.Mailing.EmailMaxRetryAttempts, "3"} + { NotificationsSettings.Mailing.EmailMaxRetryAttempts, "3"}, + { NotificationsSettings.Mailing.EnableEmailDelay, "false"} }; foreach (var notificationSetting in notificationsSettings) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettings.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettings.cs index 031488fb6f..9449238d62 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettings.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettings.cs @@ -9,5 +9,6 @@ public static class Mailing public const string Default = "GrantManager.Notifications.Mailing"; public const string DefaultFromAddress = "GrantManager.Notifications.Mailing.DefaultFromAddress"; public const string EmailMaxRetryAttempts = "GrantManager.Notifications.Mailing.EmailMaxRetryAttempts"; + public const string EnableEmailDelay = "GrantManager.Notifications.Mailing.EnableEmailDelay"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingsDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingsDto.cs index 27ec7d6c72..6978f72861 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingsDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Settings/NotificationsSettingsDto.cs @@ -3,4 +3,5 @@ public class NotificationsSettingsDto { public string DefaultFromAddress { get; set; } = string.Empty; public string MaximumRetryAttempts { get; set; } = string.Empty; + public bool EnableEmailDelay { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml index 081a16a1f6..5daf8d9815 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml @@ -42,6 +42,12 @@ + + + + + +
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs index 7796a666de..a9351dea10 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs @@ -24,7 +24,10 @@ public virtual async Task InvokeAsync() var model = new NotificationsSettingViewModel { DefaultFromAddress = await settingProvider.GetOrNullAsync(Notifications.Settings.NotificationsSettings.Mailing.DefaultFromAddress) ?? "", - MaximumRetryAttempts = maximumRetryAttempts + MaximumRetryAttempts = maximumRetryAttempts, + EnableEmailDelay = string.Equals( + await settingProvider.GetOrNullAsync(Notifications.Settings.NotificationsSettings.Mailing.EnableEmailDelay), + "true", System.StringComparison.OrdinalIgnoreCase) }; return View("~/Views/Settings/NotificationsSettingGroup/Default.cshtml", model); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs index 1e4d39d8d1..3d055f82c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs @@ -14,4 +14,7 @@ public class NotificationsSettingViewModel [MaxValue(10)] [MaxLength(2)] public int MaximumRetryAttempts { get; set; } = 3; + + [Display(Name = "Enable Email Delay")] + public bool EnableEmailDelay { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs index 3eb2e66341..86466a1d01 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs @@ -5,20 +5,17 @@ namespace Unity.Modules.Shared.Utils; public static class DateTimeExtensions { - private const string WindowsPacificId = "Pacific Standard Time"; - private const string IanaPacificId = "America/Los_Angeles"; + // BC Pacific timezone: PST does NOT observe DST in 2026 — fixed UTC-8 year-round. + private static readonly TimeSpan BcPstOffset = TimeSpan.FromHours(-8); - // Lazy-initialized cached timezone to avoid repeated OS lookups. - private static readonly Lazy PacificTimeZone = new(GetPacificTimeZone, isThreadSafe: true); + // BC Mountain timezone: Peace River / NE BC region — MST/MDT, DST still applies. + private const string WindowsMountainId = "Mountain Standard Time"; + private const string IanaMountainId = "America/Edmonton"; + private static readonly Lazy MountainTimeZone = new(GetMountainTimeZone, isThreadSafe: true); /// /// Formats a nullable value as an ISO 8601-compliant UTC timestamp. /// - /// If the provided is not already in UTC, it will be treated as a - /// local time and converted to UTC before formatting. - /// The nullable to format. If the value is not in UTC, it will be converted to UTC. - /// A string representation of the in ISO 8601 format, or an empty string if is . public static string FormatTimestamp(DateTime? utcTime) { if (!utcTime.HasValue) @@ -32,16 +29,13 @@ public static string FormatTimestamp(DateTime? utcTime) } /// - /// Converts a given UTC time to Pacific Time and formats it as a string with the appropriate time zone - /// abbreviation. + /// Converts a given UTC time to BC Pacific Standard Time and formats it as a string. + /// BC's Pacific timezone does NOT observe Daylight Saving Time in 2026; PST (UTC-8) + /// is applied year-round. For the Peace River / NE BC region (Mountain Time), use + /// instead. /// - /// The method ensures that the input is treated as UTC. If the input - /// time is not explicitly marked as UTC, it is converted to UTC before performing the time zone - /// conversion. /// The UTC time to convert. If , an empty string is returned. - /// A string representing the Pacific Time equivalent of the provided UTC time, formatted as "yyyy-MM-dd h:mm tt" - /// followed by the time zone abbreviation "(PDT)" for daylight saving time or "(PST)" for standard time. Returns an - /// empty string if is . + /// A string formatted as "yyyy-MM-dd h:mm tt (PST)". public static string FormatPacificTime(DateTime? utcTime) { if (!utcTime.HasValue) @@ -51,30 +45,52 @@ public static string FormatPacificTime(DateTime? utcTime) ? utcTime.Value : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); - var pacificTimeZone = PacificTimeZone.Value; - var pacificDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, pacificTimeZone); + // BC PST is a fixed UTC-8 offset — no DST adjustment in 2026. + var bcPstDateTime = new DateTimeOffset(utcTimeValue, TimeSpan.Zero).ToOffset(BcPstOffset); - string timeZoneAbbreviation = pacificTimeZone.IsDaylightSavingTime(pacificDateTime) ? "(PDT)" : "(PST)"; - return $"{pacificDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {timeZoneAbbreviation}"; + return $"{bcPstDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} (PST)"; } - private static TimeZoneInfo GetPacificTimeZone() + /// + /// Converts a given UTC time to BC Mountain Time (Peace River / NE BC region) and formats it. + /// Unlike BC's Pacific zone, the Mountain timezone in BC DOES observe Daylight Saving Time in 2026: + /// MST (UTC-7) in winter, MDT (UTC-6) in summer. + /// + /// The UTC time to convert. If , an empty string is returned. + /// A string formatted as "yyyy-MM-dd h:mm tt (MST)" or "yyyy-MM-dd h:mm tt (MDT)". + public static string FormatMountainTime(DateTime? utcTime) + { + if (!utcTime.HasValue) + return string.Empty; + + var utcTimeValue = utcTime.Value.Kind == DateTimeKind.Utc + ? utcTime.Value + : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); + + var mountainTz = MountainTimeZone.Value; + var mtDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, mountainTz); + string abbr = mountainTz.IsDaylightSavingTime(mtDateTime) ? "(MDT)" : "(MST)"; + + return $"{mtDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; + } + + private static TimeZoneInfo GetMountainTimeZone() { - // If running on Windows, attempt Windows ID first; otherwise attempt IANA first. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (TryFindTimeZone(WindowsPacificId, out var tz)) return tz; - if (TryFindTimeZone(IanaPacificId, out tz)) return tz; + if (TryFindTimeZone(WindowsMountainId, out var tz)) return tz; + if (TryFindTimeZone(IanaMountainId, out tz)) return tz; } else { - if (TryFindTimeZone(IanaPacificId, out var tz)) return tz; - if (TryFindTimeZone(WindowsPacificId, out tz)) return tz; + if (TryFindTimeZone(IanaMountainId, out var tz)) return tz; + if (TryFindTimeZone(WindowsMountainId, out tz)) return tz; } throw new TimeZoneNotFoundException( - $"Neither '{WindowsPacificId}' nor '{IanaPacificId}' time zone IDs were found on this system."); + $"Neither '{WindowsMountainId}' nor '{IanaMountainId}' time zone IDs were found on this system."); } + private static bool TryFindTimeZone(string id, out TimeZoneInfo tz) { try diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index 651e25ebc5..021550aceb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -1,11 +1,21 @@ -/** +/** * Date utility functions for Grant Manager application + * + * BC TIMEZONE NOTES (2026): + * - BC Pacific zones (Vancouver, Victoria, etc.) do NOT observe DST in 2026. + * They remain on PST (UTC-8) year-round. Use formatUtcToBcPacificDateTime / bcPstInputToUtcIso. + * - BC Mountain zones (Peace River / NE BC) DO observe DST: MST (UTC-7) in winter, + * MDT (UTC-6) in summer. Use formatUtcToBcMountainDateTime for those. */ const DateUtils = (function () { 'use strict'; + // BC PST is a fixed UTC-8 offset -- no DST in 2026. + const BC_PST_OFFSET_MS = -8 * 60 * 60 * 1000; + /** - * Formats a UTC date string to local date format + * Formats a UTC date string to the browser's local date format. + * NOTE: Uses the browser's system timezone. For BC PST (no DST) use formatUtcToBcPacificDateTime. * @param {string|Date} dateUtc - The UTC date to format * @param {string} type - The type of formatting (for DataTables compatibility) * @param {object} options - Additional formatting options @@ -34,8 +44,108 @@ const DateUtils = (function () { ); } + /** + * Formats a UTC date/time string as a BC Pacific date (date only). + * BC PST is fixed at UTC-8 -- no DST adjustment in 2026. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + * @param {object} options - Additional Intl.DateTimeFormat options + */ + function formatUtcToBcPacificDate(dateUtc, type, options) { + if (!dateUtc) return null; + const date = new Date(dateUtc); + if (type === 'sort' || type === 'type') return date.getTime(); + // Etc/GMT+8 is a fixed UTC-8 zone with no DST -- matches BC PST in 2026. + return date.toLocaleDateString(abp.localization.currentCulture.name, { + timeZone: 'Etc/GMT+8', + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...options + }); + } + + /** + * Formats a UTC date/time string as a BC Pacific date+time string with "PST" label. + * BC PST is fixed at UTC-8 -- no DST adjustment in 2026. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + */ + function formatUtcToBcPacificDateTime(dateUtc, type) { + if (!dateUtc) return null; + const date = new Date(dateUtc); + if (type === 'sort' || type === 'type') return date.getTime(); + const formatted = date.toLocaleString(abp.localization.currentCulture.name, { + timeZone: 'Etc/GMT+8', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: '2-digit' + }); + return formatted + ' PST'; + } + + /** + * Formats a UTC date/time string for the BC Mountain timezone (Peace River / NE BC). + * Mountain Time observes DST in 2026: MST (UTC-7) in winter, MDT (UTC-6) in summer. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + */ + function formatUtcToBcMountainDateTime(dateUtc, type) { + if (!dateUtc) return null; + const date = new Date(dateUtc); + if (type === 'sort' || type === 'type') return date.getTime(); + // America/Edmonton follows MST/MDT -- DST applies in NE BC. + return date.toLocaleString(abp.localization.currentCulture.name, { + timeZone: 'America/Edmonton', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); + } + + /** + * Converts a datetime-local input string (YYYY-MM-DDTHH:mm) to a UTC ISO string, + * treating the input as BC Pacific Standard Time (fixed UTC-8, no DST in 2026). + * Use this instead of new Date(localString).toISOString() which relies on the + * browser's potentially incorrect DST-adjusted timezone offset. + * @param {string} localDatetimeString - Value from a datetime-local input + * @returns {string|null} UTC ISO 8601 string, or null if input is empty/invalid + */ + function bcPstInputToUtcIso(localDatetimeString) { + if (!localDatetimeString) return null; + // Append the BC PST fixed offset so Date parses it as UTC-8, not browser-local. + const withOffset = localDatetimeString + '-08:00'; + const date = new Date(withOffset); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + /** + * Converts a UTC timestamp (ms since epoch) to a datetime-local string (YYYY-MM-DDTHH:mm) + * in BC Pacific Standard Time (fixed UTC-8, no DST in 2026). + * Use this to populate datetime-local inputs with the correct BC PST time. + * @param {number} utcMs - Milliseconds since Unix epoch + * @returns {string} datetime-local string in BC PST + */ + function utcMsToBcPstDatetimeLocal(utcMs) { + // Shift UTC ms by -8h to get BC PST, then read UTC getters (which now represent PST). + const shifted = new Date(utcMs + BC_PST_OFFSET_MS); + const pad = n => String(n).padStart(2, '0'); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` + + `T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`; + } + // Public API return { - formatUtcDateToLocal: formatUtcDateToLocal + formatUtcDateToLocal: formatUtcDateToLocal, + formatUtcToBcPacificDate: formatUtcToBcPacificDate, + formatUtcToBcPacificDateTime: formatUtcToBcPacificDateTime, + formatUtcToBcMountainDateTime: formatUtcToBcMountainDateTime, + bcPstInputToUtcIso: bcPstInputToUtcIso, + utcMsToBcPstDatetimeLocal: utcMsToBcPstDatetimeLocal }; -})(); \ No newline at end of file +})(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs index b2fc692506..3086c01c4a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs @@ -27,5 +27,10 @@ public class CreateEmailDto public Guid EmailId { get; set; } = Guid.Empty; public Guid CurrentUserId { get; set; } public string EmailTemplateName { get; set; } = string.Empty; + + /// + /// Optional specific UTC date/time to send the email. Leave null to send immediately. + /// + public DateTime? SendOnDateTime { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 055ebe647d..23b42e4cdb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -118,11 +118,12 @@ private async Task SendEmailAlert(string emailBody, string subject) "; await _emailNotificationService.SendEmailNotification( - "grantmanagementsupport@gov.bc.ca", - htmlBody, - subject, - "NoReply@gov.bc.ca", "html", - ""); + new EmailMessageParams( + "grantmanagementsupport@gov.bc.ca", + htmlBody, + subject, + "NoReply@gov.bc.ca", + ""), "html"); Logger.LogInformation("Missing Alerts Sent..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index 5f90f6eb81..dd35d8aaf0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -92,11 +92,12 @@ public override async Task Execute(IJobExecutionContext context) "; await _emailNotificationService.SendEmailNotification( - "grantmanagementsupport@gov.bc.ca", - htmlBody, - "Unity Failed Submissions Notification", - "NoReply@gov.bc.ca", "html", - ""); + new EmailMessageParams( + "grantmanagementsupport@gov.bc.ca", + htmlBody, + "Unity Failed Submissions Notification", + "NoReply@gov.bc.ca", + ""), "html"); Logger.LogInformation("Missing Submissions Email Sent..."); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs index 7e8ad607fd..75d91f158a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs @@ -55,7 +55,8 @@ private EmailNotificationEvent GetEmailNotificationEvent(CreateEmailDto dto) Bcc = bccList, Subject = dto.EmailSubject, Body = dto.EmailBody, - EmailTemplateName = dto.EmailTemplateName + EmailTemplateName = dto.EmailTemplateName, + SendOnDateTime = dto.SendOnDateTime }; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index e469c96074..1f3126014f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.Assessments; @@ -68,16 +70,30 @@ private async Task SeedApplicationStatusAsync() new() { StatusCode = GrantApplicationState.ON_HOLD, ExternalStatus = GrantApplicationStates.ON_HOLD, InternalStatus = GrantApplicationStates.ON_HOLD }, }; - foreach (var status in statuses) + var existingCodes = (await applicationStatusRepository.GetListAsync()) + .Select(s => s.StatusCode) + .ToHashSet(); + + foreach (var status in statuses.Where(s => !existingCodes.Contains(s.StatusCode))) { - var existing = await applicationStatusRepository.FirstOrDefaultAsync(s => s.StatusCode == status.StatusCode); - if (existing == null) + try + { + await applicationStatusRepository.InsertAsync(status, autoSave: true); + } + catch (Exception ex) when (IsDuplicateStatusCodeException(ex)) { - await applicationStatusRepository.InsertAsync(status); + // Another concurrent seeder instance inserted this status first; safe to ignore. } } } + private static bool IsDuplicateStatusCodeException(Exception ex) + { + var full = ex.ToString(); + return full.Contains("IX_ApplicationStatuses_StatusCode") + || (full.Contains("23505") && full.Contains("ApplicationStatuses")); + } + private async Task SeedAiScoringPersonAsync(System.Guid? tenantId) { var existing = await personRepository.FirstOrDefaultAsync(p => p.Id == AIScoringConstants.AiPersonId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index f630006bea..b077808192 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -237,7 +237,7 @@ else
- +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml index 1e13c3f457..9f1514583c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml @@ -1,3 +1,5 @@ -
- +@model Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget.EmailHistoryWidgetViewModel +
+
\ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js index 02d9cdaaba..8b564de8cb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js @@ -18,6 +18,9 @@ }; }; + const enableEmailDelay = $('#EmailHistoryTable').data('enable-email-delay') === true + || $('#EmailHistoryTable').data('enable-email-delay') === 'true'; + let emailHistoryDataTable = $('#EmailHistoryTable').DataTable( abp.libs.datatables.normalizeConfiguration({ serverSide: false, @@ -74,11 +77,22 @@ title: 'Sent By', data: 'sentBy', className: 'data-table-header', - width: '16%', + width: enableEmailDelay ? '10%' : '16%', render: function (data) { return data ? data.name + ' ' + data.surname : '—'; }, }, + { + title: 'Scheduled Send', + data: 'sendOnDateTime', + className: 'data-table-header text-center', + width: '10%', + visible: enableEmailDelay, + render: function (data, type) { + if (!data) return '—'; + return DateUtils.formatUtcToBcPacificDateTime(data, type) || '—'; + } + }, { title: 'To Address', data: 'toAddress', diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs index 706c878735..e4653f23e2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs @@ -1,19 +1,27 @@ using Microsoft.AspNetCore.Mvc; +using System; using System.Collections.Generic; +using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Settings; +using Unity.Notifications.Settings; namespace Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget; [Widget( ScriptTypes = new [] {typeof(EmailHistoryScriptBundleContributor)}, StyleTypes = new [] {typeof(EmailHistoryStyleBundleContributor)})] -public class EmailHistoryWidgetViewComponent : AbpViewComponent +public class EmailHistoryWidgetViewComponent(ISettingProvider settingProvider) : AbpViewComponent { - public IViewComponentResult Invoke() + public async Task InvokeAsync() { - return View(); + var enableEmailDelay = string.Equals( + await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.EnableEmailDelay), + "true", StringComparison.OrdinalIgnoreCase); + + return View(new EmailHistoryWidgetViewModel { EnableEmailDelay = enableEmailDelay }); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs new file mode 100644 index 0000000000..6c3fa2b21f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget; + +public class EmailHistoryWidgetViewModel +{ + public bool EnableEmailDelay { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml index 3cd452a9c6..ae1ccdfdca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml @@ -79,12 +79,12 @@ - + - + @@ -112,6 +112,29 @@ + @if (Model.EnableEmailDelay) + { + + + +
+ + + +
+
+ + +
+
+ }
- - + +
@@ -247,7 +247,7 @@ else
- +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 428d691d21..0d85f7104b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -607,3 +607,27 @@ function queueApplicationScoring(triggerButton = null) { $button.html(existingHtml).prop('disabled', false); }); } + +$(function () { + // Static buttons + $(document).on('click', '#regenerateAiScoresheetBtn', function () { + queueApplicationScoring(); + }); + $(document).on('click', '#btn-expand-all', function () { + expandAllAccordions('assessment-scoresheet'); + }); + $(document).on('click', '#btn-collapse-all', function () { + collapseAllAccordions('assessment-scoresheet'); + }); + $(document).on('click', '#saveAssessmentScoresBtn', function () { + saveAssessmentScores(); + }); + + // Dynamically-generated section buttons (event delegation) + $(document).on('click', '[id^="scoresheet-section-save-"]', function () { + saveScoresSection($(this).data('form-id'), $(this).data('section-id')); + }); + $(document).on('click', '[id^="scoresheet-section-discard-"]', function () { + discardChangesScoresSection($(this).data('form-id'), $(this).data('section-id')); + }); +}); From 88aabd3a3eeb0e1314ebf7740be64691f7e2b30d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 17 Apr 2026 15:00:28 -0700 Subject: [PATCH 003/259] feature/AB#32634-FixSonar --- .../Unity.Theme.UX2/wwwroot/js/DateUtils.js | 30 ++++++++++--------- .../Shared/Components/EmailsWidget/Default.js | 8 ++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index 021550aceb..9208d8dff3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -52,11 +52,11 @@ const DateUtils = (function () { * @param {object} options - Additional Intl.DateTimeFormat options */ function formatUtcToBcPacificDate(dateUtc, type, options) { - if (!dateUtc) return null; - const date = new Date(dateUtc); - if (type === 'sort' || type === 'type') return date.getTime(); - // Etc/GMT+8 is a fixed UTC-8 zone with no DST -- matches BC PST in 2026. - return date.toLocaleDateString(abp.localization.currentCulture.name, { + if (type === 'sort' || type === 'type') { + return dateUtc ? new Date(dateUtc).getTime() : 0; + } + if (!dateUtc) return ''; + return new Date(dateUtc).toLocaleDateString(abp.localization.currentCulture.name, { timeZone: 'Etc/GMT+8', year: 'numeric', month: '2-digit', @@ -72,10 +72,11 @@ const DateUtils = (function () { * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) */ function formatUtcToBcPacificDateTime(dateUtc, type) { - if (!dateUtc) return null; - const date = new Date(dateUtc); - if (type === 'sort' || type === 'type') return date.getTime(); - const formatted = date.toLocaleString(abp.localization.currentCulture.name, { + if (type === 'sort' || type === 'type') { + return dateUtc ? new Date(dateUtc).getTime() : 0; + } + if (!dateUtc) return ''; + const formatted = new Date(dateUtc).toLocaleString(abp.localization.currentCulture.name, { timeZone: 'Etc/GMT+8', year: 'numeric', month: '2-digit', @@ -93,11 +94,12 @@ const DateUtils = (function () { * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) */ function formatUtcToBcMountainDateTime(dateUtc, type) { - if (!dateUtc) return null; - const date = new Date(dateUtc); - if (type === 'sort' || type === 'type') return date.getTime(); + if (type === 'sort' || type === 'type') { + return dateUtc ? new Date(dateUtc).getTime() : 0; + } + if (!dateUtc) return ''; // America/Edmonton follows MST/MDT -- DST applies in NE BC. - return date.toLocaleString(abp.localization.currentCulture.name, { + return new Date(dateUtc).toLocaleString(abp.localization.currentCulture.name, { timeZone: 'America/Edmonton', year: 'numeric', month: '2-digit', @@ -121,7 +123,7 @@ const DateUtils = (function () { // Append the BC PST fixed offset so Date parses it as UTC-8, not browser-local. const withOffset = localDatetimeString + '-08:00'; const date = new Date(withOffset); - return isNaN(date.getTime()) ? null : date.toISOString(); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); } /** 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 a8df70fdea..44fb5ead1a 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 @@ -149,8 +149,8 @@ // Shortcut chip buttons — set picker to now + N days in BC PST $(document).on('click', '.delay-shortcut', function () { - const days = parseInt($(this).data('days'), 10); - if (!isNaN(days) && days > 0) { + const days = Number.parseInt($(this).data('days'), 10); + if (!Number.isNaN(days) && days > 0) { const targetUtcMs = Date.now() + days * 24 * 60 * 60 * 1000; UIElements.sendOnDateTimePicker.val(DateUtils.utcMsToBcPstDatetimeLocal(targetUtcMs)); UIElements.delayDaysHelper.val(days); @@ -160,8 +160,8 @@ // Manual days helper — sets the datetime picker UIElements.delayDaysHelper.on('input', function () { - const days = parseInt(this.value, 10); - if (!isNaN(days) && days > 0) { + const days = Number.parseInt(this.value, 10); + if (!Number.isNaN(days) && days > 0) { const targetUtcMs = Date.now() + days * 24 * 60 * 60 * 1000; UIElements.sendOnDateTimePicker.val(DateUtils.utcMsToBcPstDatetimeLocal(targetUtcMs)); UIElements.scheduleModalValidation.hide(); From 780beceb372e183a27fda26f27d8fcc49c7634b1 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 17 Apr 2026 15:49:19 -0700 Subject: [PATCH 004/259] feature/AB#32634-Enhance email Sonar --- .../src/Unity.Theme.UX2/wwwroot/js/DateUtils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index 9208d8dff3..309bb345c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -53,7 +53,7 @@ const DateUtils = (function () { */ function formatUtcToBcPacificDate(dateUtc, type, options) { if (type === 'sort' || type === 'type') { - return dateUtc ? new Date(dateUtc).getTime() : 0; + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; } if (!dateUtc) return ''; return new Date(dateUtc).toLocaleDateString(abp.localization.currentCulture.name, { @@ -73,7 +73,7 @@ const DateUtils = (function () { */ function formatUtcToBcPacificDateTime(dateUtc, type) { if (type === 'sort' || type === 'type') { - return dateUtc ? new Date(dateUtc).getTime() : 0; + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; } if (!dateUtc) return ''; const formatted = new Date(dateUtc).toLocaleString(abp.localization.currentCulture.name, { @@ -95,7 +95,7 @@ const DateUtils = (function () { */ function formatUtcToBcMountainDateTime(dateUtc, type) { if (type === 'sort' || type === 'type') { - return dateUtc ? new Date(dateUtc).getTime() : 0; + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; } if (!dateUtc) return ''; // America/Edmonton follows MST/MDT -- DST applies in NE BC. From 90d0d0546885105a99dbccaaac9dc854e112a54a Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 17 Apr 2026 15:50:35 -0700 Subject: [PATCH 005/259] feature/AB#32634-Enhance email Sonar --- .../Unity.Theme.UX2/wwwroot/js/DateUtils.js | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index 309bb345c3..295c713aea 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -19,21 +19,14 @@ const DateUtils = (function () { * @param {string|Date} dateUtc - The UTC date to format * @param {string} type - The type of formatting (for DataTables compatibility) * @param {object} options - Additional formatting options - * @returns {string|number|null} Formatted date string or timestamp for sorting, null if input is invalid + * @returns {string} Formatted date string, or numeric timestamp string for sorting, empty string if input is invalid */ function formatUtcDateToLocal(dateUtc, type, options) { - if (!dateUtc) { - return null; - } - - const date = new Date(dateUtc); - - // Required for DataTables sorting & filtering if (type === 'sort' || type === 'type') { - return date.getTime(); + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; } - - return date.toLocaleDateString( + if (!dateUtc) return ''; + return new Date(dateUtc).toLocaleDateString( abp.localization.currentCulture.name, { year: 'numeric', @@ -116,14 +109,14 @@ const DateUtils = (function () { * Use this instead of new Date(localString).toISOString() which relies on the * browser's potentially incorrect DST-adjusted timezone offset. * @param {string} localDatetimeString - Value from a datetime-local input - * @returns {string|null} UTC ISO 8601 string, or null if input is empty/invalid + * @returns {string} UTC ISO 8601 string, or empty string if input is empty/invalid */ function bcPstInputToUtcIso(localDatetimeString) { - if (!localDatetimeString) return null; + if (!localDatetimeString) return ''; // Append the BC PST fixed offset so Date parses it as UTC-8, not browser-local. const withOffset = localDatetimeString + '-08:00'; const date = new Date(withOffset); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); } /** From 6ccbae82822634eadf81d1b90f39fa108a3e8de7 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 21 Apr 2026 09:20:17 -0700 Subject: [PATCH 006/259] feature/AB#32634-AddNotificationsSettingsDataSeedContributor --- ...otificationsSettingsDataSeedContributor.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsSettingsDataSeedContributor.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsSettingsDataSeedContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsSettingsDataSeedContributor.cs new file mode 100644 index 0000000000..46c304a482 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsSettingsDataSeedContributor.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Unity.Notifications.Settings; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.SettingManagement; +using Volo.Abp.Settings; + +namespace Unity.Notifications; + +/// +/// Data seed contributor that explicitly writes the default value of EnableEmailDelay +/// into the Settings table for each tenant that does not already have an explicit row. +/// This ensures existing tenants get the correct default on deploy rather than relying +/// on the runtime fallback from SettingDefinitionProvider. +/// +public class NotificationsSettingsDataSeedContributor(ISettingManager settingManager) + : IDataSeedContributor, ITransientDependency +{ + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId == null) + { + return; + } + + var existingValue = await settingManager.GetOrNullAsync( + NotificationsSettings.Mailing.EnableEmailDelay, + TenantSettingValueProvider.ProviderName, + context.TenantId.Value.ToString(), + fallback: false); + + if (string.IsNullOrEmpty(existingValue)) + { + await settingManager.SetForTenantAsync( + context.TenantId.Value, + NotificationsSettings.Mailing.EnableEmailDelay, + "false"); + } + } +} From a349ef86b00ad0ae8f361a3505f44a1fadbd04af Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 24 Apr 2026 09:43:59 -0700 Subject: [PATCH 007/259] feature/AB#32634-DelayDays Co-authored-by: Copilot --- .../EmailNotificaions/EmailNotificationManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs index a4c7f57b17..537706ea3f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -270,7 +270,7 @@ public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLo if (emailLog.SendOnDateTime.HasValue) { var emailObjectDictionary = (IDictionary)emailObject; - emailObjectDictionary["delayTS"] = new DateTimeOffset(emailLog.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeSeconds(); + emailObjectDictionary["delayTS"] = new DateTimeOffset(emailLog.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeMilliseconds(); } return emailObject; @@ -309,10 +309,10 @@ protected virtual async Task GetEmailObjectAsync( emailObjectDictionary["bcc"] = bccList; } - // delayTS: desired UTC send time as Unix seconds; 0 = send immediately. + // delayTS: desired UTC send time as Unix milliseconds; 0 = send immediately. if (email.SendOnDateTime.HasValue) { - emailObjectDictionary["delayTS"] = new DateTimeOffset(email.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeSeconds(); + emailObjectDictionary["delayTS"] = new DateTimeOffset(email.SendOnDateTime.Value, TimeSpan.Zero).ToUnixTimeMilliseconds(); } // templateName is not part of the CHES MessageObject schema From f9f5188a5099f6d2181623149ef27868118c9fb2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 4 Jun 2026 14:18:04 -0700 Subject: [PATCH 008/259] AB#33333 tenant creation improvements --- .../PaymentsQueryableExtensions.cs | 2 +- .../ITenantConnectionStringBuilder.cs | 7 +- .../TenantDbCredentials.cs | 16 +++ .../TenantAppService.cs | 7 +- .../TenantConnectionStringBuilder.cs | 95 +++++++++++-- .../UnityTenantManagementConsts.cs | 2 +- .../TenantAppService_Tests.cs | 5 +- ...ameworkCoreGrantManagerDbSchemaMigrator.cs | 131 ++++++++++++++++-- 8 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs index 5d5212de66..49292689f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs @@ -11,7 +11,7 @@ public static IQueryable IncludeDetails(this IQueryable s.Site) - .ThenInclude(site => site.Supplier) + .ThenInclude(site => site!.Supplier) .Include(p => p.AccountCoding) .Include(y => y.ExpenseApprovals); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs index 10d36e8ec5..9a2d7ad422 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs @@ -1,9 +1,12 @@ -using Volo.Abp.Application.Services; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; namespace Unity.TenantManagement.Application.Contracts { public interface ITenantConnectionStringBuilder : IApplicationService { - string Build(string tenantName); + Task GenerateCredentialsAsync(); + + string Build(string tenantName, TenantDbCredentials credentials); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs new file mode 100644 index 0000000000..3c2fa35c8e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs @@ -0,0 +1,16 @@ +namespace Unity.TenantManagement.Application.Contracts +{ + public class TenantDbCredentials + { + public TenantDbCredentials(string dbName, string username, string password) + { + DbName = dbName; + Username = username; + Password = password; + } + + public string DbName { get; } + public string Username { get; } + public string Password { get; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 4d1e1b01b2..84080c7d6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -137,15 +137,18 @@ public virtual async Task CreateAsync(TenantCreateDto input) Tenant tenant = null; using (var uow = unitOfWorkManager.Begin(true, false)) - { + { tenant = await tenantManager.CreateAsync(input.Name); + var credentials = await tenantConnectionStringBuilder.GenerateCredentialsAsync(); + tenant.ConnectionStrings .Add(new TenantConnectionString(tenant.Id, UnityTenantManagementConsts.TenantConnectionStringName, - tenantConnectionStringBuilder.Build(tenant.Name))); + tenantConnectionStringBuilder.Build(tenant.Name, credentials))); // Set ExtraProperties from input + tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index 1e83ae5238..d0ea5acde4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -1,33 +1,108 @@ -using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Unity.TenantManagement.Application.Contracts; using Volo.Abp; using Volo.Abp.Application.Services; +using Volo.Abp.TenantManagement; namespace Unity.TenantManagement.Application { [RemoteService(false)] public class TenantConnectionStringBuilder : ApplicationService, ITenantConnectionStringBuilder { + private static readonly char[] Letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".ToCharArray(); + private static readonly char[] Digits = "0123456789".ToCharArray(); + private static readonly char[] Alphanumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray(); + private readonly IConfiguration _configuration; + private readonly ITenantRepository _tenantRepository; - public TenantConnectionStringBuilder(IConfiguration configuration) + public TenantConnectionStringBuilder(IConfiguration configuration, ITenantRepository tenantRepository) { _configuration = configuration; + _tenantRepository = tenantRepository; } - public string Build(string tenantName) + public string Build(string tenantName, TenantDbCredentials credentials) { - var connectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName); + var baseConnectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName) + ?? throw new UserFriendlyException("Connection string configuration error"); + + return ReplaceKeyValues(baseConnectionString, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Database"] = credentials.DbName, + ["Username"] = credentials.Username, + ["Password"] = credentials.Password + }); + } - return connectionString == null - ? throw new UserFriendlyException("Connection string configuration error") - : connectionString - .Replace(UnityTenantManagementConsts.TenantConnectionStringTenantDb, PrepTenantName(tenantName)); + // Replaces connection string values by key (case-insensitive) while preserving + // the original key casing from the template (e.g. "Host" stays "Host", not "host"). + private static string ReplaceKeyValues(string connectionString, Dictionary replacements) + { + var parts = connectionString.Split(';'); + for (int i = 0; i < parts.Length; i++) + { + var eq = parts[i].IndexOf('='); + if (eq > 0 && replacements.TryGetValue(parts[i][..eq].Trim(), out var newValue)) + { + parts[i] = $"{parts[i][..eq]}={newValue}"; + } + } + return string.Join(";", parts); + } + + public async Task GenerateCredentialsAsync() + { + var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); + + var existingConnectionStrings = allTenants + .SelectMany(t => t.ConnectionStrings.Select(cs => cs.Value)) + .ToList(); + + string dbName; + do + { + dbName = GenerateDbName(); + } + while (existingConnectionStrings.Any(cs => cs.Contains(dbName, StringComparison.OrdinalIgnoreCase))); + + return new TenantDbCredentials(dbName, dbName, GeneratePassword()); + } + + private static string GenerateDbName() + { + // Format: T_XXX999 where X is A-Z and 9 is 0-9, e.g. T_ABC123 + Span chars = + [ + 'T', + '_', + Letters[Random.Shared.Next(Letters.Length)], + Letters[Random.Shared.Next(Letters.Length)], + Letters[Random.Shared.Next(Letters.Length)], + Digits[Random.Shared.Next(Digits.Length)], + Digits[Random.Shared.Next(Digits.Length)], + Digits[Random.Shared.Next(Digits.Length)] + ]; + return new string(chars); } - private static string PrepTenantName(string tenantName) + private static string GeneratePassword() { - return tenantName.Trim().Replace(" ", ""); + // 6 random alphanumeric characters (A-Z, 0-9) + Span chars = + [ + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)], + Alphanumeric[Random.Shared.Next(Alphanumeric.Length)] + ]; + return new string(chars); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs index 0c6d6833fe..fc934df332 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs @@ -4,6 +4,6 @@ public static class UnityTenantManagementConsts { public const string TenantConnectionStringName = "Tenant"; - public const string TenantConnectionStringTenantDb = "UnityGrantTenant"; + public const string TenantLicencePlateExtraPropertyKey = "LicencePlate"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 37dcc34065..e1cafe5057 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -19,7 +19,10 @@ protected override void AfterAddApplication(IServiceCollection services) // Create a Substitute and replace original one in Service Collection var tenantConnectionStringBuilder = Substitute.For(); - tenantConnectionStringBuilder.Build(Arg.Any()).Returns("acme test connection"); + tenantConnectionStringBuilder.GenerateCredentialsAsync() + .Returns(Task.FromResult(new TenantDbCredentials("T_ABC123", "T_ABC123", "XYZ789"))); + tenantConnectionStringBuilder.Build(Arg.Any(), Arg.Any()) + .Returns("acme test connection"); services.AddSingleton(tenantConnectionStringBuilder); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs index 78ec9708fd..1c8e8a1c43 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -1,7 +1,12 @@ -using System; +using System; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Npgsql; using Unity.GrantManager.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.TenantManagement; @@ -20,10 +25,9 @@ public EntityFrameworkCoreGrantManagerDbSchemaMigrator(IServiceProvider serviceP public async Task MigrateAsync(Tenant? tenant) { - /* We intentionally resolving the GrantManagerDbContext - * from IServiceProvider (instead of directly injecting it) - * to properly get the connection string of the current tenant in the - * current scope. + /* We intentionally resolve GrantManagerDbContext / GrantTenantDbContext + * from IServiceProvider (instead of directly injecting) to properly get + * the connection string of the current tenant in the current scope. */ if (tenant != null) @@ -31,17 +35,57 @@ public async Task MigrateAsync(Tenant? tenant) var connectionString = tenant.ConnectionStrings[0]; if (connectionString != null) { + var configuration = _serviceProvider.GetRequiredService(); + var adminConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName) + ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured."); + + // Parse the stored tenant connection string to get the DB name and role credentials + var tenantCsb = new NpgsqlConnectionStringBuilder(connectionString.Value); + var dbName = tenantCsb.Database + ?? throw new InvalidOperationException("Tenant connection string is missing the Database value."); + var roleName = tenantCsb.Username + ?? throw new InvalidOperationException("Tenant connection string is missing the Username value."); + var rolePassword = tenantCsb.Password + ?? throw new InvalidOperationException("Tenant connection string is missing the Password value."); + + // Build an admin-level connection string targeting the tenant database + var adminCsb = new NpgsqlConnectionStringBuilder(adminConnectionString) { Database = dbName }; + var adminTenantConnectionString = adminCsb.ToString(); + + // Create the PostgreSQL role (idempotent via DO block) + await CreateRoleIfNotExistsAsync(adminConnectionString, roleName, rolePassword); + + // Create the database if it does not exist; use the admin connection so + // MigrateAsync connects cleanly and avoids logging a ConnectionError. var tenantDb = _serviceProvider - .GetRequiredService() - .Database; + .GetRequiredService() + .Database; + + tenantDb.SetConnectionString(adminTenantConnectionString); + + if (!await tenantDb.CanConnectAsync()) + { + await tenantDb.GetService().CreateAsync(); + } - tenantDb.SetConnectionString(connectionString.Value); + // Grant database and schema privileges to the role (idempotent) + await GrantDatabasePrivilegesAsync(adminConnectionString, dbName, roleName); + await GrantSchemaPrivilegesAsync(adminTenantConnectionString, roleName); + + // Ensure __EFMigrationsHistory exists so MigrateAsync does not log a CommandError + await tenantDb.ExecuteSqlRawAsync( + tenantDb.GetService().GetCreateIfNotExistsScript()); + + // Run migrations as admin against the tenant database await tenantDb.MigrateAsync(); + // Grant table and sequence privileges after migrations have created all objects + await GrantTablePrivilegesAsync(adminTenantConnectionString, roleName); + /* The payments module is also migrated. - Currently the payments module also reference the tenant connection string. - Changes to that, may require an inspection in the connections string here and resolve the correct one. - */ + Currently the payments module also references the tenant connection string. + Changes to that may require inspecting the connection string here to resolve + the correct one. */ } } else @@ -52,4 +96,69 @@ await _serviceProvider .MigrateAsync(); } } + + private static async Task CreateRoleIfNotExistsAsync(string adminConnectionString, string roleName, string password) + { + await using var conn = new NpgsqlConnection(adminConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{roleName}') THEN + CREATE ROLE "{roleName}" WITH LOGIN PASSWORD '{password}'; + END IF; + END + $$; + """; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantDatabasePrivilegesAsync(string adminConnectionString, string dbName, string roleName) + { + await using var conn = new NpgsqlConnection(adminConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"GRANT ALL PRIVILEGES ON DATABASE \"{dbName}\" TO \"{roleName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantSchemaPrivilegesAsync(string adminTenantConnectionString, string roleName) + { + await using var conn = new NpgsqlConnection(adminTenantConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"GRANT ALL PRIVILEGES ON SCHEMA public TO \"{roleName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task GrantTablePrivilegesAsync(string adminTenantConnectionString, string roleName) + { + await using var conn = new NpgsqlConnection(adminTenantConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + + // Iterate over every non-system schema so schemas like "Notifications" created + // by migrations are covered in addition to the default "public" schema. + cmd.CommandText = $""" + DO $$ + DECLARE + r RECORD; + BEGIN + FOR r IN + SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND nspname NOT LIKE 'pg_%' + LOOP + EXECUTE format('GRANT ALL PRIVILEGES ON SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO "{roleName}"', r.nspname); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO "{roleName}"', r.nspname); + END LOOP; + END + $$; + """; + await cmd.ExecuteNonQueryAsync(); + } } From 90f6eba2a794a45818c54b8cb17e41a34447bd3a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 5 Jun 2026 13:08:56 -0700 Subject: [PATCH 009/259] AB#333333 encrypted tenant connection strings --- ...EncryptedTenantConnectionStringResolver.cs | 41 ++++++++++++ .../TenantAppService.cs | 9 ++- .../TenantConnectionStringBuilder.cs | 9 +-- .../Navigation/TenantManagementMenuNames.cs | 1 + .../Decrypt-TenantConnectionString.ps1 | 65 +++++++++++++++++++ .../Encrypt-TenantConnectionString.ps1 | 65 +++++++++++++++++++ .../Data/GrantManagerDbMigrationService.cs | 7 +- ...enantConnectionStringEncryptionMigrator.cs | 48 ++++++++++++++ ...ameworkCoreGrantManagerDbSchemaMigrator.cs | 25 ++++++- 9 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs create mode 100644 applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 create mode 100644 applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs new file mode 100644 index 0000000000..06ad027ff7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; + +namespace Unity.TenantManagement.Application; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IConnectionStringResolver))] +public class EncryptedTenantConnectionStringResolver : MultiTenantConnectionStringResolver +{ + private readonly IStringEncryptionService _encryptionService; + + public EncryptedTenantConnectionStringResolver( + IOptionsMonitor options, + ICurrentTenant currentTenant, + IServiceProvider serviceProvider, + IStringEncryptionService encryptionService) + : base(options, currentTenant, serviceProvider) + { + _encryptionService = encryptionService; + } + + public override async Task ResolveAsync(string connectionStringName = null) + { + var value = await base.ResolveAsync(connectionStringName); + if (string.IsNullOrEmpty(value)) return value; + try + { + var decrypted = _encryptionService.Decrypt(value); + return (decrypted != null && decrypted.Contains('=')) ? decrypted : value; + } + catch + { + return value; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 84080c7d6c..612b475f9c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -11,6 +11,7 @@ using Volo.Abp.Data; using Volo.Abp.EventBus.Local; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; using Volo.Abp.Uow; using Volo.Abp.DependencyInjection; @@ -25,7 +26,8 @@ public class TenantAppService( ITenantManager tenantManager, ILocalEventBus localEventBus, IUnitOfWorkManager unitOfWorkManager, - ITenantConnectionStringBuilder tenantConnectionStringBuilder) : TenantManagementAppServiceBase, ITenantAppService + ITenantConnectionStringBuilder tenantConnectionStringBuilder, + IStringEncryptionService stringEncryptionService) : TenantManagementAppServiceBase, ITenantAppService { private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; @@ -142,10 +144,13 @@ public virtual async Task CreateAsync(TenantCreateDto input) var credentials = await tenantConnectionStringBuilder.GenerateCredentialsAsync(); + var plainConnectionString = tenantConnectionStringBuilder.Build(tenant.Name, credentials); + var encryptedConnectionString = stringEncryptionService.Encrypt(plainConnectionString); + tenant.ConnectionStrings .Add(new TenantConnectionString(tenant.Id, UnityTenantManagementConsts.TenantConnectionStringName, - tenantConnectionStringBuilder.Build(tenant.Name, credentials))); + encryptedConnectionString)); // Set ExtraProperties from input tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index d0ea5acde4..256c2ba724 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -59,16 +59,17 @@ public async Task GenerateCredentialsAsync() { var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); - var existingConnectionStrings = allTenants - .SelectMany(t => t.ConnectionStrings.Select(cs => cs.Value)) - .ToList(); + var existingDbNames = allTenants + .Where(t => t.ExtraProperties.ContainsKey(UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey)) + .Select(t => t.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey]?.ToString() ?? "") + .ToHashSet(StringComparer.OrdinalIgnoreCase); string dbName; do { dbName = GenerateDbName(); } - while (existingConnectionStrings.Any(cs => cs.Contains(dbName, StringComparison.OrdinalIgnoreCase))); + while (existingDbNames.Contains(dbName)); return new TenantDbCredentials(dbName, dbName, GeneratePassword()); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs index 007c2d6ab7..9c44cbbddd 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs @@ -7,4 +7,5 @@ public static class TenantManagementMenuNames public const string Tenants = GroupName + ".Tenants"; public const string Endpoints = GroupName + ".Endpoints"; public const string Reconciliation = GroupName + ".Reconciliation"; + public const string Onboarding = GroupName + ".Onboarding"; } diff --git a/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 b/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 new file mode 100644 index 0000000000..32ab789e89 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Decrypt-TenantConnectionString.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + Decrypts an ABP-encrypted tenant connection string. + +.DESCRIPTION + Mirrors ABP's StringEncryptionService (AES-256-CBC, PBKDF2/SHA-1, 1000 iterations). + Use this to recover a plain-text connection string from the value stored in the + AbpTenantConnectionStrings table. + +.PARAMETER EncryptedText + The base64-encoded encrypted value from the database. + +.PARAMETER PassPhrase + The pass phrase from appsettings.json -> StringEncryption -> DefaultPassPhrase. + +.PARAMETER Salt + The salt used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER InitVector + The AES initialisation vector used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER KeySize + AES key size in bits. Must match the value configured in AbpStringEncryptionOptions (default: 256). + +.EXAMPLE + .\Decrypt-TenantConnectionString.ps1 ` + -EncryptedText "abc123==" ` + -PassPhrase "g2IuZx7PwXDvCmlW" +#> +param( + [Parameter(Mandatory)][string] $EncryptedText, + [Parameter(Mandatory)][string] $PassPhrase, + [string] $Salt = "hgt!16kl", + [string] $InitVector = "jkE49230Tf093b42", + [int] $KeySize = 256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$cipherBytes = [Convert]::FromBase64String($EncryptedText) +$saltBytes = [Text.Encoding]::ASCII.GetBytes($Salt) +$ivBytes = [Text.Encoding]::ASCII.GetBytes($InitVector) + +$deriveBytes = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PassPhrase, $saltBytes, 1000) +$keyBytes = $deriveBytes.GetBytes($KeySize / 8) + +$aes = [System.Security.Cryptography.Aes]::Create() +$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC +$aes.Key = $keyBytes +$aes.IV = $ivBytes + +$ms = New-Object System.IO.MemoryStream(, $cipherBytes) +$cs = New-Object System.Security.Cryptography.CryptoStream($ms, $aes.CreateDecryptor(), [System.Security.Cryptography.CryptoStreamMode]::Read) +$sr = New-Object System.IO.StreamReader($cs) + +try { + Write-Output $sr.ReadToEnd() +} finally { + $sr.Dispose() + $cs.Dispose() + $ms.Dispose() + $aes.Dispose() + $deriveBytes.Dispose() +} diff --git a/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 b/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 new file mode 100644 index 0000000000..450a53fca3 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Encrypt-TenantConnectionString.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + Encrypts a plain-text tenant connection string using ABP's StringEncryptionService algorithm. + +.DESCRIPTION + Mirrors ABP's StringEncryptionService (AES-256-CBC, PBKDF2/SHA-1, 1000 iterations). + Use this to produce the encrypted value that should be stored in the + AbpTenantConnectionStrings table, or to verify an existing encrypted value. + +.PARAMETER PlainText + The plain-text connection string to encrypt. + +.PARAMETER PassPhrase + The pass phrase from appsettings.json -> StringEncryption -> DefaultPassPhrase. + +.PARAMETER Salt + The salt used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER InitVector + The AES initialisation vector used by ABP's StringEncryptionService (default matches ABP's built-in default). + +.PARAMETER KeySize + AES key size in bits. Must match the value configured in AbpStringEncryptionOptions (default: 256). + +.EXAMPLE + .\Encrypt-TenantConnectionString.ps1 ` + -PlainText "Host=localhost;Database=T_ABC123;Username=T_ABC123;Password=XYZ789" ` + -PassPhrase "g2IuZx7PwXDvCmlW" +#> +param( + [Parameter(Mandatory)][string] $PlainText, + [Parameter(Mandatory)][string] $PassPhrase, + [string] $Salt = "hgt!16kl", + [string] $InitVector = "jkE49230Tf093b42", + [int] $KeySize = 256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$plainBytes = [Text.Encoding]::UTF8.GetBytes($PlainText) +$saltBytes = [Text.Encoding]::ASCII.GetBytes($Salt) +$ivBytes = [Text.Encoding]::ASCII.GetBytes($InitVector) + +$deriveBytes = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PassPhrase, $saltBytes, 1000) +$keyBytes = $deriveBytes.GetBytes($KeySize / 8) + +$aes = [System.Security.Cryptography.Aes]::Create() +$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC +$aes.Key = $keyBytes +$aes.IV = $ivBytes + +$ms = New-Object System.IO.MemoryStream +$cs = New-Object System.Security.Cryptography.CryptoStream($ms, $aes.CreateEncryptor(), [System.Security.Cryptography.CryptoStreamMode]::Write) + +try { + $cs.Write($plainBytes, 0, $plainBytes.Length) + $cs.FlushFinalBlock() + Write-Output ([Convert]::ToBase64String($ms.ToArray())) +} finally { + $cs.Dispose() + $ms.Dispose() + $aes.Dispose() + $deriveBytes.Dispose() +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs index 5d46e99d1e..f300cb738c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs @@ -24,17 +24,20 @@ public class GrantManagerDbMigrationService : ITransientDependency private readonly IEnumerable _dbSchemaMigrators; private readonly ITenantRepository _tenantRepository; private readonly ICurrentTenant _currentTenant; + private readonly TenantConnectionStringEncryptionMigrator _connectionStringEncryptionMigrator; public GrantManagerDbMigrationService( IDataSeeder dataSeeder, IEnumerable dbSchemaMigrators, ITenantRepository tenantRepository, - ICurrentTenant currentTenant) + ICurrentTenant currentTenant, + TenantConnectionStringEncryptionMigrator connectionStringEncryptionMigrator) { _dataSeeder = dataSeeder; _dbSchemaMigrators = dbSchemaMigrators; _tenantRepository = tenantRepository; _currentTenant = currentTenant; + _connectionStringEncryptionMigrator = connectionStringEncryptionMigrator; Logger = NullLogger.Instance; } @@ -53,6 +56,8 @@ public async Task MigrateAsync() await MigrateDatabaseSchemaAsync(); await SeedDataAsync(); + await _connectionStringEncryptionMigrator.MigrateAsync(); + var tenants = await _tenantRepository.GetListAsync(includeDetails: true); var migratedDatabaseSchemas = new HashSet(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs new file mode 100644 index 0000000000..46597cdde6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/TenantConnectionStringEncryptionMigrator.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.Data; + +public class TenantConnectionStringEncryptionMigrator( + ITenantRepository tenantRepository, + IStringEncryptionService encryptionService) : ITransientDependency +{ + public async Task MigrateAsync() + { + var tenants = await tenantRepository.GetListAsync(includeDetails: true); + + foreach (var tenant in tenants) + { + // IsPlainText returns true when the value is not valid ciphertext (not yet encrypted) + var plainTextStrings = tenant.ConnectionStrings + .Where(cs => IsPlainText(cs.Value)) + .ToList(); + + foreach (var cs in plainTextStrings) + { + tenant.SetConnectionString(cs.Name, encryptionService.Encrypt(cs.Value)); + } + + if (plainTextStrings.Count > 0) + { + await tenantRepository.UpdateAsync(tenant); + } + } + } + + private bool IsPlainText(string value) + { + try + { + encryptionService.Decrypt(value); + return false; + } + catch + { + return true; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs index 1c8e8a1c43..b7bba5eeb2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs @@ -9,6 +9,7 @@ using Npgsql; using Unity.GrantManager.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; namespace Unity.GrantManager.EntityFrameworkCore; @@ -17,10 +18,14 @@ public class EntityFrameworkCoreGrantManagerDbSchemaMigrator : IGrantManagerDbSchemaMigrator, ITransientDependency { private readonly IServiceProvider _serviceProvider; + private readonly IStringEncryptionService _encryptionService; - public EntityFrameworkCoreGrantManagerDbSchemaMigrator(IServiceProvider serviceProvider) + public EntityFrameworkCoreGrantManagerDbSchemaMigrator( + IServiceProvider serviceProvider, + IStringEncryptionService encryptionService) { _serviceProvider = serviceProvider; + _encryptionService = encryptionService; } public async Task MigrateAsync(Tenant? tenant) @@ -39,8 +44,24 @@ public async Task MigrateAsync(Tenant? tenant) var adminConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName) ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured."); + // Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value. + // A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage) + // is also rejected by the Contains('=') check so we surface a meaningful error rather than a + // cryptic NpgsqlConnectionStringBuilder format exception. + var rawValue = connectionString.Value; + string plainValue; + try + { + var decrypted = _encryptionService.Decrypt(rawValue); + plainValue = (decrypted != null && decrypted.Contains('=')) ? decrypted : rawValue; + } + catch + { + plainValue = rawValue; + } + // Parse the stored tenant connection string to get the DB name and role credentials - var tenantCsb = new NpgsqlConnectionStringBuilder(connectionString.Value); + var tenantCsb = new NpgsqlConnectionStringBuilder(plainValue); var dbName = tenantCsb.Database ?? throw new InvalidOperationException("Tenant connection string is missing the Database value."); var roleName = tenantCsb.Username From c90ba24b8ed7d4d96bebc7d493c5dc89fb4c8c9e Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 8 Jun 2026 09:14:22 -0700 Subject: [PATCH 010/259] feature/AB#24674-ScheduledEmails --- .../EmailNotificationManager.cs | 12 + .../EmailNotificationService.cs | 6 + .../IEmailNotificationService.cs | 1 + .../Integrations/RabbitMQ/EmailConsumer.cs | 2 +- .../TeamsNotificationService.cs | 3 +- .../Teams/MessageCard.cs | 2 +- .../Emails/EmailStatus.cs | 1 + .../Repositories/PaymentRequestRepository.cs | 4 +- .../wwwroot/themes/ux2/layout.css | 49 +- .../CreateUpdateNotificationDto.cs | 27 + .../{ => Email}/CreateEmailDto.cs | 2 +- .../Notifications/{ => Email}/EmailDto.cs | 2 +- .../{ => Email}/IEmailAppService.cs | 2 +- .../{ => Email}/IEmailsService.cs | 2 +- .../{ => Email}/UpdateEmailDto.cs | 2 +- .../Notifications/GetNotificationsInput.cs | 11 + .../IAutomatedNotificationAppService.cs | 17 + .../Notifications/INotificationsAppService.cs | 2 +- .../Notifications/NotificationDto.cs | 19 + .../Notifications/NotificationTemplateDto.cs | 9 + .../Notifications/{ => Teams}/Facts.cs | 2 +- .../ApplicationFormSycnronizationService.cs | 8 +- .../AutomatedNotificationAppService.cs | 149 + .../Norifications/EmailAppService.cs | 3 +- .../Norifications/NotificationsAppService.cs | 17 +- .../SubmissionsDynamicViewGeneratorHandler.cs | 12 +- .../Notifications/ScheduledNotification.cs | 32 + .../GrantManagerDbContext.cs | 44 +- .../GrantTenantDbContext.cs | 22 +- ...9215129_ScheduledNotifications.Designer.cs | 5032 +++++++++++++++++ .../20260529215129_ScheduledNotifications.cs | 69 + .../GrantTenantDbContextModelSnapshot.cs | 96 + ...ty.GrantManager.EntityFrameworkCore.csproj | 4 +- .../FormNotificationsApiController.cs | 209 + .../Pages/ApplicationForms/Mapping.cshtml | 15 +- .../Pages/ApplicationForms/Mapping.css | 6 +- .../Pages/ApplicationForms/Mapping.js | 39 +- .../FormConfiguration/Notifications.cshtml | 141 + .../FormConfiguration/Notifications.cshtml.cs | 16 + .../Components/Notifications/Default.cshtml | 106 + .../Components/Notifications/Default.css | 23 + .../Components/Notifications/Default.js | 276 + .../Components/Notifications/Notifications.cs | 28 + .../Notifications/NotificationsController.cs | 20 + .../Notifications/NotificationsViewModel.cs | 6 + .../js/formConfiguration/Notifications.js | 203 + 46 files changed, 6652 insertions(+), 101 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Email}/CreateEmailDto.cs (95%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Email}/EmailDto.cs (86%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Email}/IEmailAppService.cs (81%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Email}/IEmailsService.cs (89%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Email}/UpdateEmailDto.cs (87%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/GetNotificationsInput.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/{ => Teams}/Facts.cs (84%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsController.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsViewModel.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs index 537706ea3f..df6ef399a2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -121,6 +121,18 @@ public async Task DeleteEmailLogAsync(Guid id) await emailLogsRepository.DeleteAsync(id); } + public async Task CancelEmailLogAsync(Guid id) + { + var emailLog = await emailLogsRepository.GetAsync(id); + if (emailLog.Status == EmailStatus.Sent) + { + throw new UserFriendlyException("Sent emails cannot be cancelled."); + } + + emailLog.Status = EmailStatus.Cancelled; + await emailLogsRepository.UpdateAsync(emailLog, autoSave: true); + } + /// /// Send Email Notification /// diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index e7bf9a58ce..1cd1489413 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -43,6 +43,12 @@ public async Task DeleteEmail(Guid id) await emailNotificationManager.DeleteEmailLogAsync(id); } + [Authorize(NotificationsPermissions.Email.Send)] + public async Task CancelEmail(Guid id) + { + await emailNotificationManager.CancelEmailLogAsync(id); + } + public async Task GetEmailsChesWithNoResponseCountAsync() { return await emailNotificationManager.GetPendingEmailsCountAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs index 554e62050f..a4353ed0f1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs @@ -22,6 +22,7 @@ public interface IEmailNotificationService : IApplicationService Task UpdateSettings(NotificationsSettingsDto settingsDto); Task InitializeDraftAsync(Guid applicationId); Task DeleteEmail(Guid id); + Task CancelEmail(Guid id); Task GetEmailsChesWithNoResponseCountAsync(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs index a8a7123614..323a32e012 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailConsumer.cs @@ -278,5 +278,5 @@ private static void ValidateMessage(EmailNotificationEvent evt) } private static bool ShouldProcessEmail(EmailLog log) - => log != null && log.Status != EmailStatus.Sent; + => log != null && log.Status != EmailStatus.Sent && log.Status != EmailStatus.Cancelled; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs index f4d319906b..cd89042b76 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs @@ -6,9 +6,10 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using Unity.GrantManager.Notifications.Teams; -namespace Unity.Notifications.TeamsNotifications +namespace Unity.Notifications.Teams { public class TeamsNotificationService { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Teams/MessageCard.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Teams/MessageCard.cs index b605337fca..c50c59e8e7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Teams/MessageCard.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Teams/MessageCard.cs @@ -1,5 +1,5 @@ using System.IO; -namespace Unity.Notifications.TeamsNotifications; +namespace Unity.Notifications.Teams; public static class MessageCard { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailStatus.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailStatus.cs index f9ae83a736..e93bea514e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailStatus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/EmailStatus.cs @@ -7,4 +7,5 @@ public static class EmailStatus public const string Failed = "Failed"; public const string Draft = "Draft"; public const string Initialized = "Initialized"; + public const string Cancelled = "Cancelled"; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index c46f71e5a3..98ac8546ee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -103,10 +103,10 @@ public async Task> GetPaymentPendingListByCorrelationIdAsyn public async Task> GetPaymentPendingListByCorrelationIdsAsync(IEnumerable correlationIds) { - var idList = correlationIds?.ToList() ?? new List(); + var idList = correlationIds?.ToList() ?? []; if (idList.Count == 0) { - return new List(); + return []; } var dbSet = await GetDbSetAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css index 6fb5270b48..10037a60d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css @@ -31,24 +31,25 @@ body { white-space: nowrap; text-overflow: ellipsis; font-weight: 700; + justify-content: center; } - .btn i { - font-size: 1.25rem; - } +.btn i { + font-size: 1.25rem; +} - .btn i:first-child { - margin-right: 0.375rem; - } +.btn i:first-child { + margin-right: 0.375rem; +} - .btn i:last-child { - margin-left: 0.375rem; - } +.btn i:last-child { + margin-left: 0.375rem; +} - .btn:hover { - box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.18); - border-color: var(--bc-colors-blue-primary); - } +.btn:hover { + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.18); + border-color: var(--bc-colors-blue-primary); +} .btn-light { --bs-btn-color: var(--bc-colors-grey-text-500); @@ -137,10 +138,6 @@ div.dt-container.dt-scroll-resize { overflow-y: visible; } -.dt-container .dt-scroll-head { - min-height: 44px; -} - .dt-container .dt-scroll-head table { margin-top: 0px !important; } @@ -348,15 +345,15 @@ ul.pagination { padding-bottom: 0.4rem } - .navbar .dropdown-menu a { - font-size: 1rem; - padding: 10px 15px; - display: block; - min-width: 210px; - text-align: left; - border-radius: 0.25rem; - min-height: 44px; - } +.navbar .dropdown-menu a { + font-size: 1rem; + padding: 10px 15px; + display: block; + min-width: 210px; + text-align: left; + border-radius: 0.25rem; + min-height: 44px; +} .navbar .dropdown-submenu a::after { transform: rotate(-90deg); 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 new file mode 100644 index 0000000000..9f5d6cd9cb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateUpdateNotificationDto.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.Notifications +{ + public class CreateUpdateNotificationDto + { + [Required] + public Guid FormId { get; set; } + + [Required] + public Guid EmailTemplateId { get; set; } + + [Required] + public string TriggerType { get; set; } = "Event"; + + public string? TriggerDetail { get; set; } + public bool IsActive { get; set; } = true; + + public string? EventType { get; set; } + public Guid? ApplicationStatusId { get; set; } + + public string? ApplicationStatus { get; set; } + + public string? DateField { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/CreateEmailDto.cs similarity index 95% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/CreateEmailDto.cs index 3086c01c4a..82823a2a4b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/CreateEmailDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/CreateEmailDto.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications.Email { public class CreateEmailDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/EmailDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/EmailDto.cs similarity index 86% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/EmailDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/EmailDto.cs index 5a7e071820..36e8129a68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/EmailDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/EmailDto.cs @@ -1,7 +1,7 @@ using System; using Volo.Abp.Application.Dtos; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications.Email { public class EmailDto : EntityDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailAppService.cs similarity index 81% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailAppService.cs index 37ef649a82..734572db86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailAppService.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications.Email { public interface IEmailAppService { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailsService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailsService.cs similarity index 89% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailsService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailsService.cs index 326c73229b..e59dfef4ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailsService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/IEmailsService.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications.Email { public interface IEmailsService { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/UpdateEmailDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/UpdateEmailDto.cs similarity index 87% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/UpdateEmailDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/UpdateEmailDto.cs index 013962807d..43df079df4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/UpdateEmailDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Email/UpdateEmailDto.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications.Email { public class UpdateEmailDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/GetNotificationsInput.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/GetNotificationsInput.cs new file mode 100644 index 0000000000..4ff590e3aa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/GetNotificationsInput.cs @@ -0,0 +1,11 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.GrantManager.Notifications +{ + public class GetNotificationsInput : PagedAndSortedResultRequestDto + { + public Guid? FormId { get; set; } + public string? Filter { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs new file mode 100644 index 0000000000..5bd472af4f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.Notifications +{ + public interface IAutomatedNotificationAppService : IApplicationService + { + Task> GetListAsync(GetNotificationsInput input); + Task GetAsync(Guid id); + Task CreateAsync(CreateUpdateNotificationDto input); + Task UpdateAsync(Guid id, CreateUpdateNotificationDto input); + Task DeleteAsync(Guid id); + Task GetTemplatesAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs index 24b50ac1c5..aedf23e936 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/INotificationsAppService.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Unity.Notifications.TeamsNotifications; +using Unity.GrantManager.Notifications.Teams; namespace Unity.GrantManager.Notifications { 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 new file mode 100644 index 0000000000..8d277cb16c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationDto.cs @@ -0,0 +1,19 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.GrantManager.Notifications +{ + public class NotificationDto : EntityDto + { + public Guid FormId { get; set; } + public Guid EmailTemplateId { get; set; } + public string? TemplateName { get; set; } + public string TriggerType { get; set; } = string.Empty; + public string? TriggerDetail { get; set; } + public bool IsActive { get; set; } + public string? EventType { get; set; } + public Guid? ApplicationStatusId { get; set; } + public string? ApplicationStatus { get; set; } + public string? DateField { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs new file mode 100644 index 0000000000..8960dd9dac --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.Notifications +{ + public class NotificationTemplateDto + { + public string Name { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Facts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs similarity index 84% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Facts.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs index 6569cfe5ec..4fc1001d1a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Facts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/Teams/Facts.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Unity.Notifications.TeamsNotifications +namespace Unity.GrantManager.Notifications.Teams { public class Fact { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs index 1516292892..29491c9f2f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormSycnronizationService.cs @@ -9,7 +9,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Unity.Notifications.TeamsNotifications; using Unity.GrantManager.Applications; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; @@ -22,6 +21,7 @@ using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; using Unity.GrantManager.Notifications; +using Unity.GrantManager.Notifications.Teams; namespace Unity.GrantManager.ApplicationForms { @@ -200,7 +200,7 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj { HashSet newChefsSubmissions = await GetChefsSubmissions(applicationFormDto, numberOfDaysToCheck); HashSet existingSubmissions = GetSubmissionsByForm(applicationFormDto.Id); - missingSubmissions = newChefsSubmissions.Except(existingSubmissions).ToHashSet(); + missingSubmissions = [.. newChefsSubmissions.Except(existingSubmissions)]; if (missingSubmissions.Count > 0) { formsMissingSubmissions++; @@ -247,7 +247,7 @@ private async Task ProcessSubmission(ApplicationFormDto applicationFormDto, JObj await _notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, _facts); } - return (missingSubmissions ?? new HashSet(), missingSubmissionsReportBuilder.ToString()); + return (missingSubmissions ?? [], missingSubmissionsReportBuilder.ToString()); } private async Task GetTenantNameAsync() @@ -277,7 +277,7 @@ public async Task> GetConnectedApplicationFormsAsync() { IQueryable queryableApplicationForms = _applicationFormRepository.GetQueryableAsync().Result; var forms = queryableApplicationForms.Where(x => (x.ApiKey ?? string.Empty) != string.Empty).ToList(); - return await Task.FromResult>(ObjectMapper.Map, List>(forms.ToList())); + return await Task.FromResult>(ObjectMapper.Map, List>([.. forms])); } public async Task> GetChefsSubmissions(ApplicationFormDto applicationFormDto, int numberOfDaysToCheck) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs new file mode 100644 index 0000000000..99f71a3ddb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs @@ -0,0 +1,149 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Unity.GrantManager.Notifications +{ + public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService + { + private readonly IRepository _repository = repository; + + public async Task CreateAsync(CreateUpdateNotificationDto input) + { + var entity = new ScheduledNotification + { + FormId = input.FormId, + EmailTemplateId = input.EmailTemplateId, + TriggerType = input.TriggerType, + TriggerDetail = input.TriggerDetail, + IsActive = input.IsActive, + EventType = input.EventType, + ApplicationStatusId = input.ApplicationStatusId, + ApplicationStatus = input.ApplicationStatus, + DateField = input.DateField, + IsDeleted = false + }; + + await _repository.InsertAsync(entity, autoSave: true); + + return new NotificationDto + { + Id = entity.Id, + FormId = entity.FormId, + EmailTemplateId = entity.EmailTemplateId, + TemplateName = null, + TriggerType = entity.TriggerType, + TriggerDetail = entity.TriggerDetail, + IsActive = entity.IsActive, + EventType = entity.EventType, + ApplicationStatusId = entity.ApplicationStatusId, + ApplicationStatus = entity.ApplicationStatus, + DateField = entity.DateField + }; + } + + public async Task DeleteAsync(Guid id) + { + await _repository.DeleteAsync(id, autoSave: true); + } + + public async Task GetAsync(Guid id) + { + var e = await _repository.GetAsync(id); + return new NotificationDto + { + Id = e.Id, + FormId = e.FormId, + EmailTemplateId = e.EmailTemplateId, + TemplateName = null, + TriggerType = e.TriggerType, + TriggerDetail = e.TriggerDetail, + IsActive = e.IsActive, + EventType = e.EventType, + ApplicationStatusId = e.ApplicationStatusId, + ApplicationStatus = e.ApplicationStatus, + DateField = e.DateField + }; + } + + public async Task> GetListAsync(GetNotificationsInput input) + { + var query = await _repository.GetQueryableAsync(); + if (input.FormId.HasValue) + { + query = query.Where(x => x.FormId == input.FormId.Value); + } + // Filtering by template name not supported here because templates are stored in the Notifications module. + + var total = await AsyncExecuter.CountAsync(query); + + var list = await query + .OrderByDescending(x => x.CreationTime) + .Skip(input.SkipCount) + .Take(input.MaxResultCount) + .ToListAsync(); + + var items = list.Select(e => new NotificationDto + { + Id = e.Id, + FormId = e.FormId, + EmailTemplateId = e.EmailTemplateId, + TemplateName = null, + TriggerType = e.TriggerType, + TriggerDetail = e.TriggerDetail, + IsActive = e.IsActive, + EventType = e.EventType, + ApplicationStatusId = e.ApplicationStatusId, + ApplicationStatus = e.ApplicationStatus, + DateField = e.DateField + }).ToList(); + + return new PagedResultDto(total, items); + } + + public async Task UpdateAsync(Guid id, CreateUpdateNotificationDto input) + { + var e = await _repository.GetAsync(id); + e.EmailTemplateId = input.EmailTemplateId; + e.TriggerType = input.TriggerType; + e.TriggerDetail = input.TriggerDetail; + e.IsActive = input.IsActive; + e.EventType = input.EventType; + e.ApplicationStatusId = input.ApplicationStatusId; + e.ApplicationStatus = input.ApplicationStatus; + e.DateField = input.DateField; + + await _repository.UpdateAsync(e, autoSave: true); + + return new NotificationDto + { + Id = e.Id, + FormId = e.FormId, + EmailTemplateId = e.EmailTemplateId, + TemplateName = null, + TriggerType = e.TriggerType, + TriggerDetail = e.TriggerDetail, + IsActive = e.IsActive, + EventType = e.EventType, + ApplicationStatusId = e.ApplicationStatusId, + ApplicationStatus = e.ApplicationStatus, + DateField = e.DateField + }; + } + + public Task GetTemplatesAsync() + { + // For now return demo templates; replace with tenant-settings-backed templates later + var templates = new[] + { + new NotificationTemplateDto { Name = "WelcomeTemplate", Subject = "Welcome", Body = "Hello {{applicantName}}" }, + new NotificationTemplateDto { Name = "ReminderTemplate", Subject = "Reminder", Body = "Reminder for {{applicationId}}" } + }; + return Task.FromResult(templates); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs index 75d91f158a..9c0134110f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using System; using System.Threading.Tasks; +using Unity.GrantManager.Notifications.Email; using Unity.Modules.Shared.Utils; using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; @@ -9,7 +10,7 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus.Local; -namespace Unity.GrantManager.Emails +namespace Unity.GrantManager.Notifications { [Authorize] [Dependency(ReplaceServices = true)] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs index 908079048e..8e50a4188e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs @@ -3,7 +3,8 @@ using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.Integrations; -using Unity.Notifications.TeamsNotifications; +using Unity.GrantManager.Notifications.Teams; +using Unity.Notifications.Teams; using Volo.Abp; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -14,16 +15,10 @@ namespace Unity.GrantManager.Notifications // This class is responsible for first lookup up the Teams channel URL from the database and then posting notifications to the Teams Service. [Dependency(ReplaceServices = true)] [ExposeServices(typeof(NotificationsAppService), typeof(INotificationsAppService))] - public class NotificationsAppService : INotificationsAppService, ITransientDependency + public class NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) : INotificationsAppService, ITransientDependency { - private readonly IDynamicUrlRepository _dynamicUrlRepository; - private readonly TeamsNotificationService _teamsNotificationService; - - public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) - { - _dynamicUrlRepository = dynamicUrlRepository; - _teamsNotificationService = new TeamsNotificationService(); - } + private readonly IDynamicUrlRepository _dynamicUrlRepository = dynamicUrlRepository; + private readonly TeamsNotificationService _teamsNotificationService = new(); [UnitOfWork] public async Task InitializeTeamsChannelAsync(string keyName) @@ -72,7 +67,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle { return; } - List facts = new() { }; + List facts = []; string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs index 9ffb415a5d..02b9f2b665 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Reporting/SubmissionsDynamicViewGeneratorHandler.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.Notifications; -using Unity.Notifications.TeamsNotifications; +using Unity.GrantManager.Notifications.Teams; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -73,7 +73,15 @@ private async Task NotifyTeamsAsync(SubmissionsDynamicViewGenerationArgs viewGen var activityTitle = "Reporting view generation failed"; var activitySubtitle = $"Form version {viewGenerationEvent.ApplicationFormVersionId}"; - await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + // Ensure a fresh UnitOfWork and tenant context are created when calling + // into services that use repositories/DbContext. The original UoW + // may have been disposed when this method is invoked from a catch. + using (currentTenant.Change(viewGenerationEvent.TenantId)) + { + using var notifyUow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + await notifyUow.CompleteAsync(); + } } catch (Exception notifyEx) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs new file mode 100644 index 0000000000..6cd43e72fc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Notifications/ScheduledNotification.cs @@ -0,0 +1,32 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Notifications +{ + public class ScheduledNotification : FullAuditedAggregateRoot, IMultiTenant + { + public Guid? TenantId { get; set; } + public Guid FormId { get; set; } + + public Guid EmailTemplateId { get; set; } + + public Guid? ApplicationStatusId { get; set; } + + public string? ApplicationStatus { get; set; } + + public string TriggerType { get; set; } = string.Empty; // Date or Event + + public string? TriggerDetail { get; set; } + + public bool IsActive { get; set; } = true; + + // Event specific + public string? EventType { get; set; } + public string? RecipientCategory { get; set; } + public string? RecipientIdentifier { get; set; } + + // Date specific + public string? DateField { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 31dd890f01..aabd9ae426 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -2,9 +2,9 @@ using System.Linq; using Unity.AI.Domain; using Unity.AI.EntityFrameworkCore; -using Unity.GrantManager.Applicants; -using Unity.GrantManager.GrantApplications; -using Unity.GrantManager.Locality; +using Unity.GrantManager.Applicants; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Locality; using Unity.GrantManager.Tokens; using Volo.Abp.AuditLogging.EntityFrameworkCore; using Volo.Abp.BackgroundJobs.EntityFrameworkCore; @@ -47,9 +47,9 @@ public class GrantManagerDbContext : public DbSet RegionalDistricts { get; set; } public DbSet TenantTokens { get; set; } public DbSet Communities { get; set; } - public DbSet InboxMessages { get; set; } - public DbSet OutboxMessages { get; set; } - public DbSet AIGenerationRequests { get; set; } + public DbSet InboxMessages { get; set; } + public DbSet OutboxMessages { get; set; } + public DbSet AIGenerationRequests { get; set; } // Unity.AI entities public DbSet AIPrompts { get; set; } @@ -216,8 +216,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => new { x.Source, x.Status }); }); - modelBuilder.Entity(b => - { + modelBuilder.Entity(b => + { b.ToTable(GrantManagerConsts.DbTablePrefix + "OutboxMessages", GrantManagerConsts.DbSchema); @@ -235,20 +235,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .IsRequired() .HasConversion(new EnumToStringConverter()); - b.HasIndex(x => new { x.Source, x.Status }); - }); - - modelBuilder.Entity(b => - { - b.ToTable(GrantManagerConsts.DbTablePrefix + "AIRequests", AIDbProperties.DbSchema); - b.ConfigureByConvention(); - b.Property(x => x.OperationType).IsRequired().HasMaxLength(64); - b.Property(x => x.RequestKey).IsRequired().HasMaxLength(256); - b.Property(x => x.FailureReason).HasMaxLength(2000); - b.Property(x => x.Status).IsRequired(); - b.HasIndex(x => x.RequestKey); - b.HasIndex(x => new { x.TenantId, x.ApplicationId, x.OperationType, x.Status }); - }); + b.HasIndex(x => new { x.Source, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "AIRequests", AIDbProperties.DbSchema); + b.ConfigureByConvention(); + b.Property(x => x.OperationType).IsRequired().HasMaxLength(64); + b.Property(x => x.RequestKey).IsRequired().HasMaxLength(256); + b.Property(x => x.FailureReason).HasMaxLength(2000); + b.Property(x => x.Status).IsRequired(); + b.HasIndex(x => x.RequestKey); + b.HasIndex(x => new { x.TenantId, x.ApplicationId, x.OperationType, x.Status }); + }); var allEntityTypes = modelBuilder.Model.GetEntityTypes(); 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 eed60c4bbd..73b2ebe30b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -16,6 +16,7 @@ using Unity.Reporting.EntityFrameworkCore; using Unity.GrantManager.GlobalTag; using Unity.GrantManager.Contacts; +using Unity.GrantManager.Notifications; namespace Unity.GrantManager.EntityFrameworkCore { @@ -52,6 +53,7 @@ public class GrantTenantDbContext : AbpDbContext public DbSet IssueTrackings { get; set; } public DbSet AuditHistories { get; set; } public DbSet ReportsHistories { get; set; } + public DbSet ScheduledNotifications { get; set; } #endregion public GrantTenantDbContext(DbContextOptions options) : base(options) @@ -409,8 +411,26 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasOne().WithMany().HasForeignKey(x => x.ApplicantId).IsRequired(false); }); + modelBuilder.Entity(b => + { + b.ToTable("ScheduledNotifications", "Notifications"); + b.ConfigureByConvention(); + b.HasKey(x => x.Id); + b.Property(x => x.FormId).IsRequired(); + b.Property(x => x.EmailTemplateId).IsRequired(); + b.Property(x => x.TriggerType).IsRequired().HasMaxLength(64); + b.Property(x => x.TriggerDetail).HasMaxLength(1000); + b.Property(x => x.EventType).HasMaxLength(128); + b.Property(x => x.ApplicationStatus).HasMaxLength(128); + b.Property(x => x.DateField).HasMaxLength(128); + b.Property(x => x.TenantId).HasColumnName("TenantId"); + b.HasIndex(x => x.TenantId); + // Exclude ExtraProperties from automatic configuration + b.Ignore(x => x.ExtraProperties); + }); + var allEntityTypes = modelBuilder.Model.GetEntityTypes(); - foreach (var entityType in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary) && !t.IsOwned())) + foreach (var entityType in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary) && !t.IsOwned() && t.ClrType != typeof(ScheduledNotification))) { var entityBuilder = modelBuilder.Entity(entityType.ClrType); entityBuilder.TryConfigureExtraProperties(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs new file mode 100644 index 0000000000..c3939c3241 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs @@ -0,0 +1,5032 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260529215129_ScheduledNotifications")] + partial class ScheduledNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("TenantId"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditStatus") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("AuditorName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs new file mode 100644 index 0000000000..7fd3b6e1f8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class ScheduledNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Notifications"); + + migrationBuilder.CreateTable( + name: "ScheduledNotifications", + schema: "Notifications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FormId = table.Column(type: "uuid", nullable: false), + EmailTemplateId = table.Column(type: "uuid", nullable: false), + TriggerType = table.Column(type: "text", maxLength: 64, nullable: false), + TriggerDetail = table.Column(type: "text", maxLength: 1000, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + EventType = table.Column(type: "text", maxLength: 128, nullable: true), + ApplicationStatusId = table.Column(type: "uuid", nullable: true), + ApplicationStatus = table.Column(type: "text", maxLength: 128, nullable: true), + RecipientCategory = table.Column(type: "text", nullable: true), + RecipientIdentifier = table.Column(type: "text", nullable: true), + DateField = table.Column(type: "text", maxLength: 128, nullable: true), + TenantId = table.Column(type: "uuid", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeleterId = table.Column(type: "uuid", nullable: true), + DeletionTime = table.Column(type: "timestamp without time zone", nullable: true), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduledNotifications", x => x.Id); + table.ForeignKey( + name: "FK_ScheduledNotifications_EmailTemplates_EmailTemplateId", + column: x => x.EmailTemplateId, + principalSchema: "Notifications", + principalTable: "EmailTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledNotifications_EmailTemplateId", + schema: "Notifications", + table: "ScheduledNotifications", + column: "EmailTemplateId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "ScheduledNotifications", schema: "Notifications"); + } + } +} 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 aeb709113a..a123fd8cfc 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 @@ -3325,6 +3325,102 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EmailLogAttachments", "Notifications"); }); + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormId") + .HasColumnType("uuid"); + + b.Property("EmailTemplateId") + .HasColumnType("uuid"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("text"); + + b.Property("TriggerDetail") + .HasMaxLength(1000) + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("EventType") + .HasMaxLength(128) + .HasColumnType("text"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatus") + .HasMaxLength(128) + .HasColumnType("text"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("DateField") + .HasMaxLength(128) + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("ScheduledNotifications", "Notifications"); + }); + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => { b.Property("Id") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj index 40700cd5bb..e8383a75d5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj @@ -94,7 +94,5 @@ - - - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs new file mode 100644 index 0000000000..2cdad904e1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -0,0 +1,209 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.GrantManager.GrantApplications; +using Unity.Notifications.EmailGroups; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Web.Controllers +{ + [Route("api/form-notifications")] + [ApiController] + public class FormNotificationsApiController : ControllerBase + { + private readonly IApplicationStatusService _statusService; + private readonly IEmailGroupsAppService _emailGroupsAppService; + private readonly Unity.Notifications.Templates.ITemplateService _templateService; + private readonly Notifications.IAutomatedNotificationAppService _automatedNotificationAppService; + + public FormNotificationsApiController(IApplicationStatusService statusService, IEmailGroupsAppService emailGroupsAppService, Unity.Notifications.Templates.ITemplateService templateService, Unity.GrantManager.Notifications.IAutomatedNotificationAppService automatedNotificationAppService) + { + _statusService = statusService; + _emailGroupsAppService = emailGroupsAppService; + _templateService = templateService; + _automatedNotificationAppService = automatedNotificationAppService; + } + // In-memory storage removed; persisting to ScheduledNotifications table via IAutomatedNotificationAppService + + + [HttpGet("templates")] + public async Task>> GetTemplates() + { + var templates = await _templateService.GetTemplatesByTenent(); + var list = templates.Select(t => new EmailTemplateDto + { + Id = t.Id, + Name = t.Name, + Subject = t.Subject, + Body = t.BodyText + }).ToList(); + + return Ok(list); + } + + [HttpGet("statuses")] + public async Task>> GetApplicationStatuses() + { + var statuses = await _statusService.GetListAsync(); + var list = statuses.Select(s => new { id = s.Id, internalStatus = s.InternalStatus }).ToList(); + return Ok(list); + } + + [HttpGet("recipients")] + public async Task>> GetRecipients([FromQuery] string category) + { + // For internal recipients, load EmailGroups from the notifications module and expose their Name + if (string.Equals(category, "Internal", StringComparison.OrdinalIgnoreCase)) + { + var groups = await _emailGroupsAppService.GetListAsync(); + var list = groups.Select(g => new RecipientDto { Id = g.Name, DisplayName = g.Name }).ToList(); + return Ok(list); + } + + // For external recipients we expose the two choices required by the UI + var externalContacts = new List + { + new() { Id = "ApplicationContact", DisplayName = "Application Contact" }, + new() { Id = "SigningAuthority", DisplayName = "Signing Authority" } + }; + + if (string.Equals(category, "External", StringComparison.OrdinalIgnoreCase)) return Ok(externalContacts); + + return Ok(new List()); + } + + [HttpGet("{formId}")] + public async Task>> GetForForm(string formId) + { + 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 }); + + // Resolve template names and status labels + var templateIds = listResult.Items.Select(x => x.EmailTemplateId).Where(id => id != Guid.Empty).Distinct().ToList(); + var templateMap = new Dictionary(); + foreach (var id in templateIds) + { + templateMap[id] = await _templateService.GetTemplateById(id); + } + + var statuses = await _statusService.GetListAsync(); + var statusMap = statuses.ToDictionary(s => s.Id, s => s.InternalStatus); + + var items = listResult.Items.Select(e => new ScheduledNotificationDto + { + Id = e.Id, + TemplateId = e.EmailTemplateId, + TemplateName = templateMap.TryGetValue(e.EmailTemplateId, out var t) && t != null ? t.Name : string.Empty, + TriggerType = e.TriggerType, + DateType = e.DateField, + EventStatus = e.ApplicationStatus, + RecipientCategory = null, + RecipientIdentifier = null, + CreatedAt = DateTime.UtcNow + }).ToList(); + + return Ok(items); + } + + [HttpPost("{formId}")] + public async Task> CreateForForm(string formId, [FromBody] CreateScheduledNotificationInput input) + { + if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + + if (string.Equals(input.TriggerType, "Event", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(input.RecipientIdentifier)) + { + return BadRequest("RecipientIdentifier required for 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"); + + // Resolve status label if an ApplicationStatusId was provided + string? statusLabel = null; + if (input.ApplicationStatusId.HasValue) + { + var statuses = await _statusService.GetListAsync(); + var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); + if (match != null) statusLabel = match.InternalStatus; + } + + var createDto = new Notifications.CreateUpdateNotificationDto + { + FormId = parsedFormId, + EmailTemplateId = template.Id, + TriggerType = input.TriggerType, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + IsActive = true, + EventType = null, + ApplicationStatusId = input.ApplicationStatusId, + ApplicationStatus = statusLabel, + DateField = input.DateType + }; + + var created = await _automatedNotificationAppService.CreateAsync(createDto); + + var dto = new ScheduledNotificationDto + { + Id = created.Id, + TemplateId = input.TemplateId, + TemplateName = template.Name, + TriggerType = created.TriggerType, + DateType = created.DateField, + EventStatus = created.ApplicationStatus, + RecipientCategory = null, + RecipientIdentifier = null, + CreatedAt = DateTime.UtcNow + }; + + return CreatedAtAction(nameof(GetForForm), new { formId }, dto); + } + + [HttpDelete("{formId}/{id:guid}")] + public async Task Delete(string formId, Guid id) + { + await _automatedNotificationAppService.DeleteAsync(id); + return NoContent(); + } + } + + public record EmailTemplateDto + { + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Subject { get; init; } = string.Empty; + public string Body { get; init; } = string.Empty; + } + + public record ScheduledNotificationDto + { + public Guid Id { get; init; } + public Guid TemplateId { get; init; } + public string TemplateName { get; init; } = string.Empty; + public string TriggerType { get; init; } = string.Empty; + public string? DateType { get; init; } + public string? EventStatus { get; init; } + public string? RecipientCategory { get; init; } + public string? RecipientIdentifier { get; init; } + public DateTime CreatedAt { get; init; } + } + + public record CreateScheduledNotificationInput + { + public Guid TemplateId { get; init; } + public string TriggerType { get; init; } = "Date"; + public string? DateType { get; init; } + public Guid? ApplicationStatusId { get; init; } + public string? EventStatus { get; init; } + public string? RecipientCategory { get; init; } + public string? RecipientIdentifier { get; init; } + } + + public record RecipientDto + { + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index e79ea92c37..8f8d4ff3ad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -4,8 +4,9 @@ @using Volo.Abp.AspNetCore.Mvc.UI.Layout; @using Unity.GrantManager.Web.Pages.ApplicationForms; @using Unity.GrantManager.Permissions; -@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal; + @using Volo.Abp.Authorization.Permissions; +@using Unity.GrantManager.Web.Views.Shared.Components.Notifications; @using Unity.GrantManager.Web.Views.Shared.Components.ApplicationFormConfigWidget; @using Volo.Abp.Features @@ -73,6 +74,7 @@ } + @if (Model?.ShowAITab == true) { + + + + + + + + + + + + + +
TemplateTrigger TypeTrigger DetailStatusActions
+ + + +
+
Create Automated Email Notification
+
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+ +
+
+
+
+ +
+ + +
+
+
+ + +
+ @* Placeholder for any extra widgets *@ + @await Component.InvokeAsync("Notifications", new { formid = Model.FormId }) +
+ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml.cs new file mode 100644 index 0000000000..8c9075af26 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Unity.GrantManager.Web.Pages.FormConfiguration +{ + public class NotificationsModel(ILogger logger) : PageModel + { + private readonly ILogger _logger = logger; + public required string FormId { get; set; } + + public void OnGet(string formId) + { + FormId = formId; + } + } +} 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 new file mode 100644 index 0000000000..83737832bf --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml @@ -0,0 +1,106 @@ +@model Unity.GrantManager.Web.Views.Shared.Components.Notifications.NotificationsViewModel + +@section scripts { + +} + +@section styles { + +} + +
+
+
Scheduled & Event Based Notifications
+ +
+ +
+ + +
+
+ + + +
+ + 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 new file mode 100644 index 0000000000..077da420f5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.css @@ -0,0 +1,23 @@ +/* Notifications widget styles */ +.notifications-widget { + padding: 1rem; + background: #f8f9fb; + font-family: var(--bs-body-font-family, 'BCSans', sans-serif); + font-size: var(--bc-font-size, 1rem); +} +.notifications-widget .card { border: 0; } +.notifications-widget .card .card-body { background: #fff; } + +/* Suppress Bootstrap's invalid !-circle icon on selects; keep red border only */ +#notificationForm .form-select.is-invalid, +#notificationForm .form-select.is-invalid:focus { + background-image: var(--bs-form-select-bg-img); + background-size: 16px 12px; + background-position: right 0.75rem center; + padding-right: 2.25rem; +} + + /* Modal column layout */ +#modalColumns { align-items: stretch; gap: 60px; } +.left-col { flex: 0 0 33%; min-width: 320px; } +.right-col { flex: 1 1 0; } 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 new file mode 100644 index 0000000000..6764d5276c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js @@ -0,0 +1,276 @@ +(function () { + let formId; + + function fetchTemplates() { + return fetch('/api/form-notifications/templates').then(r => r.json()); + } + + function fetchNotifications() { + return fetch('/api/form-notifications/' + encodeURIComponent(formId)).then(r => r.json()); + } + + function fetchStatuses() { + return fetch('/api/form-notifications/statuses').then(r => r.json()); + } + + function fetchRecipients(category) { + return fetch('/api/form-notifications/recipients?category=' + encodeURIComponent(category)).then(r => r.json()); + } + + function renderTriggerDetail(data, type, row) { + if (row.triggerType === 'Date') { + return row.dateType ? row.dateType : ''; + } + return row.eventStatus ? row.eventStatus : ''; + } + + function renderActions(data, type, row) { + return ''; + } + + function refreshTable(list) { + if (notificationsTable) { + notificationsTable.clear().rows.add(list || []).draw(); + } else { + renderList(list); + } + } + + function onDeleteNotification(ev) { + ev.preventDefault(); + const id = $(this).data('id'); + if (!id) return; + if (!confirm('Delete this scheduled notification?')) return; + fetch('/api/form-notifications/' + encodeURIComponent(formId) + '/' + encodeURIComponent(id), { method: 'DELETE' }) + .then(r => { + if (!r.ok) throw new Error('Failed to delete'); + abp.notify.success('Notification deleted'); + return fetchNotifications(); + }) + .then(list => refreshTable(list)) + .catch(err => { + console.error(err); + abp.notify.error('Failed to delete notification'); + }); + } + + let notificationsTable; + function renderList(items) { + const container = document.getElementById('notifications-list'); + if (!container) return; + + // Initialize DataTable if not already + if (notificationsTable == null) { + notificationsTable = $('#NotificationsTable').DataTable( + abp.libs.datatables.normalizeConfiguration({ + serverSide: false, + paging: true, + searching: true, + data: items || [], + columns: [ + { title: 'Template', data: 'templateName' }, + { title: 'Trigger Type', data: 'triggerType' }, + { title: 'Trigger Detail', data: null, render: renderTriggerDetail }, + { title: 'Actions', data: null, orderable: false, render: renderActions } + ], + lengthMenu: [10, 25, 50] + }) + ); + + // delete handler (named function reduces nesting) + $('#NotificationsTable').on('click', '.delete-notification', onDeleteNotification); + } else { + // update existing table + notificationsTable.clear().rows.add(items || []).draw(); + } + } + + function populateTemplates(templates) { + const sel = document.getElementById('templateSelect'); + sel.innerHTML = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.text = '-- Select --'; + sel.appendChild(blank); + templates.forEach(t => { + const opt = document.createElement('option'); + // Use template id as the option value so we can reference templates reliably + opt.value = t.id; + opt.text = t.name + ' — ' + t.subject; + sel.appendChild(opt); + }); + updatePreview(); + } + + function populateStatuses(statuses) { + const sel = document.getElementById('statusSelect'); + if (!sel) return; + sel.innerHTML = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.text = '-- Select --'; + sel.appendChild(blank); + statuses.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.text = s.internalStatus; + sel.appendChild(opt); + }); + } + + function populateRecipients(list) { + const sel = document.getElementById('recipientSelect'); + if (!sel) return; + sel.innerHTML = ''; + // Add a blank default option first (required by spec) + const empty = document.createElement('option'); + empty.value = ''; + empty.text = ''; + sel.appendChild(empty); + list.forEach(r => { + const opt = document.createElement('option'); + opt.value = r.id; + opt.text = r.displayName; + sel.appendChild(opt); + }); + } + + function updatePreview() { + const sel = document.getElementById('templateSelect'); + const preview = document.getElementById('templatePreview'); + if (!sel || !preview) return; + const val = sel.value; + fetch('/api/form-notifications/templates').then(r=>r.json()).then(list => { + const t = list.find(x => String(x.id) === String(val)); + preview.innerText = t ? `${t.subject}\n\n${t.body}` : ''; + }); + } + + + function showModal() { + // Reset form fields + ['templateSelect', 'triggerType', 'dateType', 'statusSelect', 'recipientCategory', 'recipientSelect'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.value = ''; el.classList.remove('is-invalid'); } + }); + document.getElementById('dateOptions').style.display = 'none'; + document.getElementById('eventOptions').style.display = 'none'; + document.getElementById('templatePreview').innerText = ''; + const modalEl = document.getElementById('notificationModal'); + if (!modalEl) return; + const modal = new bootstrap.Modal(modalEl); + modal.show(); + // modal shown; columns are fixed 33%/66% + } + + function validateForm(triggerType) { + let valid = true; + function check(id) { + const el = document.getElementById(id); + if (!el) return; + const ok = (el.value || '').trim() !== ''; + el.classList.toggle('is-invalid', !ok); + if (!ok) valid = false; + } + check('templateSelect'); + check('triggerType'); + if (triggerType === 'Date') { + check('dateType'); + ['statusSelect', 'recipientCategory', 'recipientSelect'].forEach(id => { + document.getElementById(id)?.classList.remove('is-invalid'); + }); + } else if (triggerType === 'Event') { + check('statusSelect'); + check('recipientCategory'); + check('recipientSelect'); + document.getElementById('dateType')?.classList.remove('is-invalid'); + } + return valid; + } + + function init() { + formId = document.getElementById('applicationFormId')?.value; + if (!formId) return; + fetchTemplates().then(populateTemplates); + fetchNotifications().then(list => renderList(list)); + + document.getElementById('btn-add-notification')?.addEventListener('click', () => showModal()); + + document.getElementById('templateSelect')?.addEventListener('change', (e) => { + e.target.classList.remove('is-invalid'); + updatePreview(); + }); + ['dateType', 'statusSelect', 'recipientSelect'].forEach(id => { + document.getElementById(id)?.addEventListener('change', (e) => { + e.target.classList.remove('is-invalid'); + }); + }); + document.getElementById('triggerType')?.addEventListener('change', (e) => { + const val = e.target.value; + document.getElementById('dateOptions').style.display = val === 'Date' ? 'block' : 'none'; + document.getElementById('eventOptions').style.display = val === 'Event' ? 'block' : 'none'; + e.target.classList.remove('is-invalid'); + }); + + document.getElementById('recipientCategory')?.addEventListener('change', (e) => { + const cat = e.target.value; + e.target.classList.remove('is-invalid'); + if (cat) fetchRecipients(cat).then(populateRecipients); + }); + + document.getElementById('btn-save-notification')?.addEventListener('click', () => { + const triggerType = document.getElementById('triggerType').value; + + if (!formId) { + abp.notify.error('Form identifier not found on page. Cannot create notification.'); + console.error('Missing formId for notification POST', { formId }); + return; + } + + if (!validateForm(triggerType)) return; + + const templateId = (document.getElementById('templateSelect').value || '').trim(); + const dateType = document.getElementById('dateType').value; + const applicationStatusId = document.getElementById('statusSelect')?.value; + const recipientCategory = document.getElementById('recipientCategory')?.value; + const recipientIdentifier = document.getElementById('recipientSelect')?.value; + + const bodyObj = { + templateId: templateId, + triggerType: triggerType, + dateType: triggerType === 'Date' ? dateType : null, + applicationStatusId: triggerType === 'Event' ? (applicationStatusId ? applicationStatusId : null) : null, + recipientCategory: triggerType === 'Event' ? recipientCategory : null, + recipientIdentifier: triggerType === 'Event' ? recipientIdentifier : null + }; + + console.debug('POST /api/form-notifications payload', { formId, bodyObj }); + + fetch('/api/form-notifications/' + encodeURIComponent(formId), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'RequestVerificationToken': abp.security.antiForgery.getToken() }, + body: JSON.stringify(bodyObj) + }).then(async (r) => { + if (!r.ok) { + const text = await r.text(); + console.warn('Create notification failed', r.status, text); + throw new Error(text || ('HTTP ' + r.status)); + } + return r.json(); + }).then(() => { + let modalEl = document.getElementById('notificationModal'); + bootstrap.Modal.getInstance(modalEl)?.hide(); + fetchNotifications().then(refreshTable); + }).catch(err => { + console.error(err); + abp.notify.error('Failed to save notification: ' + err.message); + }); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + init(); + // Load statuses and initial recipients + fetchStatuses().then(populateStatuses); + }); +})(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.cs new file mode 100644 index 0000000000..ac945ebb12 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; + +namespace Unity.GrantManager.Web.Views.Shared.Components.Notifications; + +[ViewComponent(Name = "Notifications")] +[Widget( + ScriptFiles = ["/Views/Shared/Components/Notifications/Default.js"], + StyleFiles = ["/Views/Shared/Components/Notifications/Default.css"], + RefreshUrl = "Widgets/Notifications/Refresh", + AutoInitialize = true +)] +public class Notifications : AbpViewComponent +{ + public async Task InvokeAsync(string? formid) + { + await Task.CompletedTask; + + var viewModel = new NotificationsViewModel() + { + FormId = formid + }; + + return View(viewModel); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsController.cs new file mode 100644 index 0000000000..abd84a2168 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.GrantManager.Web.Views.Shared.Components.Notifications; + +[ApiExplorerSettings(IgnoreApi = true)] +[Route("GrantApplications/Widgets/Notifications")] +public class NotificationsController : AbpController +{ + [HttpGet] + [Route("Refresh")] + public IActionResult Refresh(string? formid) + { + if (!ModelState.IsValid) + { + return ViewComponent("Notifications"); + } + return ViewComponent(typeof(Notifications), new { formid }); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsViewModel.cs new file mode 100644 index 0000000000..c0574fac61 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/NotificationsViewModel.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.Web.Views.Shared.Components.Notifications; + +public class NotificationsViewModel +{ + public string? FormId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js new file mode 100644 index 0000000000..e32a6ea3de --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/Notifications.js @@ -0,0 +1,203 @@ +(function () { + const formId = document.getElementById('applicationFormId')?.value || null; + + const $table = $('#NotificationsTable'); + let dataTable; + + function fetchList() { + return fetch('/api/form-notifications/' + encodeURIComponent(formId ?? 'global')).then(r => r.json()); + } + + function loadTemplates() { + return fetch('/api/form-notifications/templates').then(r => r.json()).then(handleTemplatesList); + } + + function loadStatuses() { + return fetch('/api/form-notifications/statuses').then(r => r.json()).then(list => { + const sel = document.getElementById('cf_appStatus'); + sel.innerHTML = ''; + // Expecting list of status DTOs { id, internalStatus } + list.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.text = s.internalStatus; + sel.appendChild(opt); + }); + return list; + }); + } + + function loadRecipients(category) { + return fetch('/api/form-notifications/recipients?category=' + encodeURIComponent(category)).then(r => r.json()).then(list => { + const sel = document.getElementById('cf_recipient'); + sel.innerHTML = ''; + list.forEach(rp => { + const opt = document.createElement('option'); + opt.value = rp.id; + opt.text = rp.displayName; + sel.appendChild(opt); + }); + return list; + }); + } + + function updatePreview() { + const sel = document.getElementById('cf_template'); + const preview = document.getElementById('previewCard'); + if (!sel || !preview) return; + fetch('/api/form-notifications/templates').then(r => r.json()).then(list => { + const t = list.find(x => String(x.id) === sel.value); + preview.innerText = t ? `Subject: ${t.subject}\n\n${t.body}` : ''; + }); + } + + function renderTable(items) { + if (dataTable) { + dataTable.clear(); + dataTable.rows.add(items); + dataTable.draw(); + return; + } + + dataTable = $table.DataTable(abp.libs.datatables.normalizeConfiguration({ + serverSide: false, + paging: true, + searching: true, + responsive: true, + data: items, + columns: [ + { data: 'templateName', title: 'Template' }, + { data: 'triggerType', title: 'Trigger Type' }, + { data: 'eventStatus', title: 'Trigger Detail', render: function (d, t, r) { + return formatTriggerDetail(r); + } }, + { data: 'isActive', title: 'Status', render: function (d) { return d ? 'Active' : 'Inactive'; } }, + { + data: null, + orderable: false, + render: function (data, type, row) { + return `
+ + +
`; + } + } + ] + })); + } + + function init() { + loadTemplates(); + loadStatuses(); + loadRecipients(document.getElementById('cf_recipientCategory')?.value || 'Internal'); + + document.getElementById('cf_template')?.addEventListener('change', updatePreview); + document.getElementById('cf_recipientCategory')?.addEventListener('change', (e) => loadRecipients(e.target.value)); + + document.getElementById('triggerEvent')?.addEventListener('change', () => toggleTriggerSections()); + document.getElementById('triggerDate')?.addEventListener('change', () => toggleTriggerSections()); + toggleTriggerSections(); + + document.getElementById('btn-open-create')?.addEventListener('click', () => { + document.getElementById('cf_template').focus(); + window.scrollTo({ top: document.getElementById('cf_template').offsetTop - 100, behavior: 'smooth' }); + }); + + document.getElementById('btn-save')?.addEventListener('click', onSave); + document.getElementById('btn-cancel')?.addEventListener('click', onCancel); + + $table.on('click', '.btn-delete', onDeleteButtonClick); + + loadList(); + } + + function toggleTriggerSections() { + const isDate = document.getElementById('triggerDate').checked; + document.getElementById('dateSection').style.display = isDate ? 'block' : 'none'; + document.getElementById('eventSection').style.display = isDate ? 'none' : 'block'; + } + + function formatTriggerDetail(r) { + if (!r) return ''; + const offset = Number(r.offsetDays) || 0; + const offsetDisplay = offset > 0 ? ('+' + offset) : offset; + return r.triggerType === 'Date' ? `${r.dateType} ${offsetDisplay}` : (r.eventStatus || ''); + } + + function handleTemplatesList(list) { + const sel = document.getElementById('cf_template'); + sel.innerHTML = ''; + list.forEach(t => { + const opt = document.createElement('option'); + opt.value = String(t.id); + opt.text = `${t.name} — ${t.subject}`; + sel.appendChild(opt); + }); + updatePreview(); + return list; + } + + function loadList() { + fetchList().then(renderTable).catch(err => console.error(err)); + } + + function onSave() { + const btn = document.getElementById('btn-save'); + btn.disabled = true; + + const templateId = document.getElementById('cf_template').value; + const triggerType = document.querySelector('input[name="triggerType"]:checked').value; + const dateType = document.getElementById('cf_dateField').value; + const offset = Number.parseInt(document.getElementById('cf_offset').value || '0', 10); + const offsetType = document.getElementById('cf_offsetType').value; + const eventType = document.getElementById('cf_eventType').value; + const appStatusId = document.getElementById('cf_appStatus').value; + const recipientCategory = document.getElementById('cf_recipientCategory').value; + const recipient = document.getElementById('cf_recipient').value; + + const body = { + templateId: templateId, + triggerType: triggerType, + dateType: triggerType === 'Date' ? dateType : null, + offsetDays: triggerType === 'Date' ? (offsetType === 'Before' ? -Math.abs(offset) : Math.abs(offset)) : 0, + applicationStatusId: triggerType === 'Event' ? (appStatusId ? appStatusId : null) : null, + eventStatus: triggerType === 'Event' ? null : null, + recipientCategory: triggerType === 'Event' ? recipientCategory : null, + recipientIdentifier: triggerType === 'Event' ? recipient : null + }; + + fetch('/api/form-notifications/' + encodeURIComponent(formId ?? 'global'), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }).then(r => { + if (!r.ok) throw new Error('Save failed'); + return r.json(); + }).then(() => { + abp.notify.success('Notification saved'); + loadList(); + }).catch(err => { console.error(err); abp.notify.error('Save failed'); }).finally(() => btn.disabled = false); + } + + function onCancel() { + // reset form + document.getElementById('cf_offset').value = '0'; + } + + function handleDeleteConfirmed(id) { + fetch('/api/form-notifications/' + encodeURIComponent(formId ?? 'global') + '/' + id, { method: 'DELETE' }).then(r => { + if (!r.ok) throw new Error('Delete failed'); + abp.notify.success('Deleted'); + loadList(); + }).catch(err => { console.error(err); abp.notify.error('Delete failed'); }); + } + + function onDeleteButtonClick(event) { + const id = $(this).data('id'); + if (!id) return; + abp.message.confirm(`Delete this notification?`).then(function (confirmed) { + if (!confirmed) return; + handleDeleteConfirmed(id); + }); + } + + document.addEventListener('DOMContentLoaded', init); +})(); From 7c893d97dd02589e7c6311ad1ce323d9c4c8f355 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 8 Jun 2026 11:47:28 -0700 Subject: [PATCH 011/259] feature/AB#24674-ScheduledEmails --- ...tificationsPermissionDefinitionProvider.cs | 30 +- .../Permissions/NotificationsPermissions.cs | 6 + .../Localization/Notifications/en.json | 10 +- .../ReportingConfiguration/Default.css | 5 +- .../CreateUpdateNotificationDto.cs | 4 + .../IAutomatedNotificationAppService.cs | 2 +- .../Notifications/NotificationDto.cs | 2 + .../Notifications/NotificationTemplateDto.cs | 9 - .../AutomatedNotificationAppService.cs | 31 +- .../GrantTenantDbContext.cs | 5 +- ...9215129_ScheduledNotifications.Designer.cs | 5 + .../20260529215129_ScheduledNotifications.cs | 3 +- .../FormNotificationsApiController.cs | 78 +++- .../FormConfiguration/Notifications.cshtml | 2 +- .../Components/Notifications/Default.cshtml | 45 +- .../Components/Notifications/Default.css | 50 +++ .../Components/Notifications/Default.js | 392 ++++++++++++++---- .../Components/Notifications/Notifications.js | 210 ++++++++++ 18 files changed, 752 insertions(+), 137 deletions(-) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Notifications.js diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs index 328573d47c..f97b9ac004 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs @@ -12,13 +12,39 @@ public override void Define(IPermissionDefinitionContext context) var notificationsPermissionsGroup = context.AddGroup(NotificationsPermissions.GroupName, L("Permission:Notifications")); var notificationsPermissions = notificationsPermissionsGroup.AddPermission( - NotificationsPermissions.Email.Default, + NotificationsPermissions.Email.Default, L($"Permission:{NotificationsPermissions.Email.Default}")); - + notificationsPermissions.AddChild( NotificationsPermissions.Email.Send, L($"Permission:{NotificationsPermissions.Email.Send}")); + notificationsPermissions.AddChild( + NotificationsPermissions.Email.Delete, + L($"Permission:{NotificationsPermissions.Email.Delete}")); + + notificationsPermissions.AddChild( + NotificationsPermissions.Email.Schedule, + L($"Permission:{NotificationsPermissions.Email.Schedule}")); + + + var scheduleNotificationsPermissions = notificationsPermissionsGroup.AddPermission( + NotificationsPermissions.Email.NotificationsTab, + L($"Permission:{NotificationsPermissions.Email.NotificationsTab}")); + + scheduleNotificationsPermissions.AddChild( + NotificationsPermissions.Email.ScheduleCreate, + L($"Permission:{NotificationsPermissions.Email.ScheduleCreate}")); + + scheduleNotificationsPermissions.AddChild( + NotificationsPermissions.Email.ScheduleDelete, + L($"Permission:{NotificationsPermissions.Email.ScheduleDelete}")); + + scheduleNotificationsPermissions.AddChild( + NotificationsPermissions.Email.ScheduleCancel, + L($"Permission:{NotificationsPermissions.Email.ScheduleCancel}")); + + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); settingManagement.AddPermission(NotificationsPermissions.Settings, L("Permission:NotificationsPermissions.Settings")); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs index acb1db42af..2569dfaa40 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs @@ -11,6 +11,12 @@ public static class Email { public const string Default = "Notifications.Email"; public const string Send = "Notifications.Email.Send"; + public const string Delete = "Notifications.Email.Delete"; + public const string Schedule = "Notifications.Email.Schedule"; + public const string NotificationsTab = "Notifications.Form.Tab"; + public const string ScheduleCreate = "Notifications.Form.Email.Schedule.Create"; + public const string ScheduleDelete = "Notifications.Form.Email.Schedule.Delete"; + public const string ScheduleCancel = "Notifications.Form.Email.Schedule.Cancel"; } public static string[] GetAll() diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json index 394b584da3..678bc86d50 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json @@ -9,6 +9,12 @@ "Setting:Notifications.Mailing.DefaultFromAddress.Description": "The default From sender address", "Permission:Notifications": "Notifications", "Permission:Notifications.Email": "Email", - "Permission:Notifications.Email.Send": "Send Email" + "Permission:Notifications.Email.Send": "Send Email", + "Permission:Notifications.Email.Delete": "Delete Email", + "Permission:Notifications.Email.Schedule": "Schedule Email", + "Permission:Notifications.Form.Tab": "Form Notifications Tab", + "Permission:Notifications.Form.Email.Schedule.Create": "Create Scheduled Email", + "Permission:Notifications.Form.Email.Schedule.Delete": "Delete Scheduled Email", + "Permission:Notifications.Form.Email.Schedule.Cancel": "Cancel Scheduled Email" } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css index 11439605b3..b21985ce79 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css @@ -111,7 +111,7 @@ } .column-name-input.is-invalid:focus { - border-color: #dc3545; + border-color: var(--bs-danger); box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); } @@ -120,8 +120,7 @@ width: 100%; margin-top: 0.25rem; font-size: 0.875rem; - color: #dc3545; - display: block; + color: var(--bs-danger); } .valid-feedback { 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 9f5d6cd9cb..9c8659e0d7 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 @@ -23,5 +23,9 @@ public class CreateUpdateNotificationDto public string? ApplicationStatus { get; set; } public string? DateField { get; set; } + + public string? RecipientCategory { get; set; } + + public string? RecipientIdentifier { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs index 5bd472af4f..48900f935a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IAutomatedNotificationAppService.cs @@ -12,6 +12,6 @@ public interface IAutomatedNotificationAppService : IApplicationService Task CreateAsync(CreateUpdateNotificationDto input); Task UpdateAsync(Guid id, CreateUpdateNotificationDto input); Task DeleteAsync(Guid id); - Task GetTemplatesAsync(); + Task CancelAsync(Guid id); } } 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 8d277cb16c..815c3fadbe 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 @@ -15,5 +15,7 @@ public class NotificationDto : EntityDto public Guid? ApplicationStatusId { get; set; } public string? ApplicationStatus { get; set; } public string? DateField { get; set; } + public string? RecipientCategory { get; set; } + public string? RecipientIdentifier { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs deleted file mode 100644 index 8960dd9dac..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/NotificationTemplateDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Unity.GrantManager.Notifications -{ - public class NotificationTemplateDto - { - public string Name { get; set; } = string.Empty; - public string Subject { get; set; } = string.Empty; - public string Body { get; set; } = string.Empty; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs index 99f71a3ddb..fba613275c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/AutomatedNotificationAppService.cs @@ -25,7 +25,8 @@ public async Task CreateAsync(CreateUpdateNotificationDto input ApplicationStatusId = input.ApplicationStatusId, ApplicationStatus = input.ApplicationStatus, DateField = input.DateField, - IsDeleted = false + RecipientCategory = input.RecipientCategory, + RecipientIdentifier = input.RecipientIdentifier }; await _repository.InsertAsync(entity, autoSave: true); @@ -51,6 +52,13 @@ public async Task DeleteAsync(Guid id) await _repository.DeleteAsync(id, autoSave: true); } + public async Task CancelAsync(Guid id) + { + var entity = await _repository.GetAsync(id); + entity.IsActive = false; + await _repository.UpdateAsync(entity, autoSave: true); + } + public async Task GetAsync(Guid id) { var e = await _repository.GetAsync(id); @@ -66,7 +74,9 @@ public async Task GetAsync(Guid id) EventType = e.EventType, ApplicationStatusId = e.ApplicationStatusId, ApplicationStatus = e.ApplicationStatus, - DateField = e.DateField + DateField = e.DateField, + RecipientCategory = e.RecipientCategory, + RecipientIdentifier = e.RecipientIdentifier }; } @@ -99,7 +109,9 @@ public async Task> GetListAsync(GetNotifications EventType = e.EventType, ApplicationStatusId = e.ApplicationStatusId, ApplicationStatus = e.ApplicationStatus, - DateField = e.DateField + DateField = e.DateField, + RecipientCategory = e.RecipientCategory, + RecipientIdentifier = e.RecipientIdentifier }).ToList(); return new PagedResultDto(total, items); @@ -116,6 +128,8 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification e.ApplicationStatusId = input.ApplicationStatusId; e.ApplicationStatus = input.ApplicationStatus; e.DateField = input.DateField; + e.RecipientCategory = input.RecipientCategory; + e.RecipientIdentifier = input.RecipientIdentifier; await _repository.UpdateAsync(e, autoSave: true); @@ -134,16 +148,5 @@ public async Task UpdateAsync(Guid id, CreateUpdateNotification DateField = e.DateField }; } - - public Task GetTemplatesAsync() - { - // For now return demo templates; replace with tenant-settings-backed templates later - var templates = new[] - { - new NotificationTemplateDto { Name = "WelcomeTemplate", Subject = "Welcome", Body = "Hello {{applicantName}}" }, - new NotificationTemplateDto { Name = "ReminderTemplate", Subject = "Reminder", Body = "Reminder for {{applicationId}}" } - }; - return Task.FromResult(templates); - } } } 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 73b2ebe30b..2c97e4e88b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -425,12 +425,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.Property(x => x.DateField).HasMaxLength(128); b.Property(x => x.TenantId).HasColumnName("TenantId"); b.HasIndex(x => x.TenantId); - // Exclude ExtraProperties from automatic configuration - b.Ignore(x => x.ExtraProperties); + b.TryConfigureExtraProperties(); }); var allEntityTypes = modelBuilder.Model.GetEntityTypes(); - foreach (var entityType in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary) && !t.IsOwned() && t.ClrType != typeof(ScheduledNotification))) + foreach (var entityType in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary) && !t.IsOwned())) { var entityBuilder = modelBuilder.Entity(entityType.ClrType); entityBuilder.TryConfigureExtraProperties(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs index c3939c3241..5eb5da2cbb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.Designer.cs @@ -91,6 +91,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Value") .IsRequired() .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); b.HasKey("Id"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs index 7fd3b6e1f8..401bf2259d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260529215129_ScheduledNotifications.cs @@ -39,7 +39,8 @@ protected override void Up(MigrationBuilder migrationBuilder) ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), CreatorId = table.Column(type: "uuid", nullable: true), LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), - LastModifierId = table.Column(type: "uuid", nullable: true) + LastModifierId = table.Column(type: "uuid", nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false, defaultValue: "{}") }, constraints: table => { 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 2cdad904e1..0301167db0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -99,9 +99,11 @@ public async Task>> GetForForm(strin TriggerType = e.TriggerType, DateType = e.DateField, EventStatus = e.ApplicationStatus, - RecipientCategory = null, - RecipientIdentifier = null, - CreatedAt = DateTime.UtcNow + ApplicationStatusId = e.ApplicationStatusId, + RecipientCategory = e.RecipientCategory, + RecipientIdentifier = e.RecipientIdentifier, + CreatedAt = DateTime.UtcNow, + IsActive = e.IsActive }).ToList(); return Ok(items); @@ -140,7 +142,9 @@ public async Task> CreateForForm(string f EventType = null, ApplicationStatusId = input.ApplicationStatusId, ApplicationStatus = statusLabel, - DateField = input.DateType + DateField = input.DateType, + RecipientCategory = input.TriggerType == "Event" ? input.RecipientCategory : null, + RecipientIdentifier = input.TriggerType == "Event" ? input.RecipientIdentifier : null }; var created = await _automatedNotificationAppService.CreateAsync(createDto); @@ -153,8 +157,9 @@ public async Task> CreateForForm(string f TriggerType = created.TriggerType, DateType = created.DateField, EventStatus = created.ApplicationStatus, - RecipientCategory = null, - RecipientIdentifier = null, + ApplicationStatusId = created.ApplicationStatusId, + RecipientCategory = created.RecipientCategory, + RecipientIdentifier = created.RecipientIdentifier, CreatedAt = DateTime.UtcNow }; @@ -167,6 +172,65 @@ public async Task Delete(string formId, Guid id) await _automatedNotificationAppService.DeleteAsync(id); return NoContent(); } + + [HttpPatch("{formId}/{id:guid}/cancel")] + public async Task CancelNotification(string formId, Guid id) + { + if (!Guid.TryParse(formId, out _)) return BadRequest("Invalid form id"); + await _automatedNotificationAppService.CancelAsync(id); + return NoContent(); + } + + [HttpPut("{formId}/{id:guid}")] + public async Task> UpdateForForm(string formId, Guid id, [FromBody] CreateScheduledNotificationInput input) + { + if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); + if (input.TemplateId == Guid.Empty) return BadRequest("TemplateId required"); + + var template = await _templateService.GetTemplateById(input.TemplateId); + if (template == null) return BadRequest("Template not found"); + + string? statusLabel = null; + if (input.ApplicationStatusId.HasValue) + { + var statuses = await _statusService.GetListAsync(); + var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); + if (match != null) statusLabel = match.InternalStatus; + } + + var updateDto = new Notifications.CreateUpdateNotificationDto + { + FormId = parsedFormId, + EmailTemplateId = template.Id, + TriggerType = input.TriggerType, + TriggerDetail = input.TriggerType == "Date" ? input.DateType : statusLabel, + IsActive = true, + EventType = null, + ApplicationStatusId = input.ApplicationStatusId, + ApplicationStatus = statusLabel, + DateField = input.DateType, + RecipientCategory = input.TriggerType == "Event" ? input.RecipientCategory : null, + RecipientIdentifier = input.TriggerType == "Event" ? input.RecipientIdentifier : null + }; + + var updated = await _automatedNotificationAppService.UpdateAsync(id, updateDto); + + var dto = new ScheduledNotificationDto + { + Id = updated.Id, + TemplateId = input.TemplateId, + TemplateName = template.Name, + TriggerType = updated.TriggerType, + DateType = updated.DateField, + EventStatus = updated.ApplicationStatus, + ApplicationStatusId = updated.ApplicationStatusId, + RecipientCategory = updated.RecipientCategory, + RecipientIdentifier = updated.RecipientIdentifier, + CreatedAt = DateTime.UtcNow + }; + + return Ok(dto); + } } public record EmailTemplateDto @@ -185,9 +249,11 @@ public record ScheduledNotificationDto public string TriggerType { get; init; } = string.Empty; public string? DateType { get; init; } public string? EventStatus { get; init; } + public Guid? ApplicationStatusId { get; init; } public string? RecipientCategory { get; init; } public string? RecipientIdentifier { get; init; } public DateTime CreatedAt { get; init; } + public bool IsActive { get; init; } } public record CreateScheduledNotificationInput diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml index 28f4b881b4..e579932daf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/FormConfiguration/Notifications.cshtml @@ -5,7 +5,7 @@ } @section Scripts { - + } 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 83737832bf..a126ff3160 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 @@ -1,4 +1,7 @@ @model Unity.GrantManager.Web.Views.Shared.Components.Notifications.NotificationsViewModel +@using Volo.Abp.Authorization.Permissions +@using Unity.Notifications.Permissions; +@inject IPermissionChecker PermissionChecker @section scripts { @@ -10,8 +13,11 @@
-
Scheduled & Event Based Notifications
+
+ @if (await PermissionChecker.IsGrantedAsync(NotificationsPermissions.Email.ScheduleCreate)) + { + }
@@ -20,6 +26,29 @@
+ +