From 281846354c8c8d3089f2d1db766a06fa82edc010 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 17 Apr 2026 14:14:02 -0700 Subject: [PATCH 001/385] 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/385] 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/385] 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/385] 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/385] 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/385] 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 46f6e00fe30f85e5c446922cb7593ce09e13502d Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:00:34 -0700 Subject: [PATCH 008/385] [ARCH-SPIKE] Refactor identity permissions and claims handling Co-authored-by: Copilot --- .../PermissionOrAuthorizationHandler.cs | 40 +++ .../RoleOrPermissionAuthorizationHandler.cs | 44 +++ .../Identity/CurrentUser.cs | 2 +- .../Identity/IdentityExtensionMethods.cs | 19 -- .../IdentityProfileLoginAdminHandler.cs | 21 -- .../LoginHandlers/IdentityProfileLoginBase.cs | 5 +- .../IdentityProfileLoginUserHandler.cs | 40 +-- .../Identity/PermissionChecker.cs | 118 -------- .../Identity/PolicyRegistrant.cs | 251 ++---------------- 9 files changed, 116 insertions(+), 424 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs new file mode 100644 index 0000000000..7adb9b86e1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class PermissionOrRequirement : IAuthorizationRequirement +{ + public string[] Permissions { get; } + + public PermissionOrRequirement(params string[] permissions) + { + Permissions = permissions; + } +} + +public class PermissionOrAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public PermissionOrAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionOrRequirement requirement) + { + foreach (var permission in requirement.Permissions) + { + if (await _permissionChecker.IsGrantedAsync(context.User, permission)) + { + context.Succeed(requirement); + return; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs new file mode 100644 index 0000000000..2701b9ba35 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class RoleOrPermissionRequirement : IAuthorizationRequirement +{ + public string RoleName { get; } + public string PermissionName { get; } + + public RoleOrPermissionRequirement(string roleName, string permissionName) + { + RoleName = roleName; + PermissionName = permissionName; + } +} + +public class RoleOrPermissionAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public RoleOrPermissionAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + RoleOrPermissionRequirement requirement) + { + if (context.User.IsInRole(requirement.RoleName)) + { + context.Succeed(requirement); + return; + } + + if (await _permissionChecker.IsGrantedAsync(context.User, requirement.PermissionName)) + { + context.Succeed(requirement); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index ab17dea528..6037b9f5c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,7 +75,7 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { - var userId = userClaims.FirstOrDefault(s => s.Type == "UserId"); + var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { return Guid.Parse(userId.Value); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs deleted file mode 100644 index 3eb02c46cb..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs +++ /dev/null @@ -1,19 +0,0 @@ -using OpenIddict.Abstractions; -using System.Collections.Immutable; -using System.Security.Claims; - -namespace Unity.GrantManager.Web.Identity -{ - public static class IdentityExtensionMethods - { - public static ClaimsPrincipal AddPermission(this ClaimsPrincipal principal, string value) - { - return principal.AddClaim(UnityClaimsTypes.Permission, value); - } - - public static ClaimsPrincipal AddPermissions(this ClaimsPrincipal principal, ImmutableArray values) - { - return principal.AddClaims(UnityClaimsTypes.Permission, values); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs index c80469dbd5..3949150b68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs @@ -1,13 +1,10 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; -using Unity.Modules.Shared.Permissions; -using Unity.TenantManagement; using Volo.Abp; using Volo.Abp.Data; using Volo.Abp.Identity; @@ -16,18 +13,6 @@ namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginAdminHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _adminPermissions = ImmutableArray.Create( - TenantManagementPermissions.Tenants.Default, - TenantManagementPermissions.Tenants.Create, - TenantManagementPermissions.Tenants.Update, - TenantManagementPermissions.Tenants.Delete, - TenantManagementPermissions.Tenants.ManageFeatures, - TenantManagementPermissions.Tenants.ManageConnectionStrings, - IdentityPermissions.Users.Create, - IdentityPermissions.UserLookup.Default, - IdentityConsts.ITAdminPermissionName - ); - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList userTenantAccounts, string? idp) @@ -43,7 +28,6 @@ internal async Task Handle(TokenValidatedContext validated userTenantAccount = userTenantAccounts.First(s => s.TenantId == null); } - AssignAdminHostPermissions(validatedTokenContext.Principal!); AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); return userTenantAccount; } @@ -63,11 +47,6 @@ private static bool AdminHasUserAccount(IList? userTenantA return false; } - private void AssignAdminHostPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_adminPermissions); - } - private async Task CreateAdminAccountAsync(TokenValidatedContext validatedTokenContext, string? idp) { var token = validatedTokenContext.SecurityToken; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index 0810de9d47..f152afb7f8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -7,7 +7,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; +using Volo.Abp.Security.Claims; using Microsoft.Extensions.Configuration; using Volo.Abp.TenantManagement; @@ -19,7 +19,6 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); protected IdentityUserManager IdentityUserManager => LazyServiceProvider.LazyGetRequiredService(); protected IdentityRoleManager IdentityRoleManager => LazyServiceProvider.LazyGetRequiredService(); - protected PermissionManager PermissionManager => LazyServiceProvider.LazyGetRequiredService(); protected IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); protected IConfiguration Configuration => LazyServiceProvider.LazyGetRequiredService(); protected IUserImportAppService UserImportAppService => LazyServiceProvider.LazyGetRequiredService(); @@ -29,7 +28,7 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, string displayName, Guid userId) { claimsPrinicipal.AddClaim("DisplayName", displayName); - claimsPrinicipal.AddClaim("UserId", userId.ToString()); + claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs index a13314a84e..d04f1dc4aa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs @@ -4,32 +4,18 @@ using OpenIddict.Abstractions; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; -using Unity.GrantManager.Permissions; using Unity.GrantManager.Web.Exceptions; -using Unity.Modules.Shared.Permissions; using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginUserHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _userPermissions = [ - GrantManagerPermissions.Default, - IdentityPermissions.UserLookup.Default - ]; - - internal readonly ImmutableArray _itOperationsPermissions = [ - GrantManagerPermissions.Endpoints.ManageEndpoints, - IdentityConsts.ITOperationsPermissionName - ]; - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList? userTenantAccounts, string? idp) @@ -57,11 +43,6 @@ internal async Task Handle(TokenValidatedContext validated } } - if (validatedTokenContext.Principal != null && validatedTokenContext.Principal.IsInRole(IdentityConsts.ITOperationsRoleName)) - { - AssignITOperationsPermissions(validatedTokenContext.Principal); - } - UserTenantAccountDto? userTenantAccount = null; var setTenant = validatedTokenContext.Request.Cookies["set_tenant"]; if (setTenant != null && setTenant != Guid.Empty.ToString()) @@ -84,20 +65,11 @@ internal async Task Handle(TokenValidatedContext validated { var dbRole = await IdentityRoleManager.GetByIdAsync(role.Id); principal.AddClaim(UnityClaimsTypes.Role, dbRole.Name); + principal.AddClaim(AbpClaimTypes.Role, dbRole.Name); } } - - var userPermissions = (await PermissionManager.GetAllForUserAsync(userTenantAccount.Id)).Where(s => s.IsGranted); - - foreach (var permissionName in userPermissions - .Select(s => s.Name) - .Where(permissionName => !principal.HasClaim(UnityClaimsTypes.Permission, permissionName))) - { - principal.AddClaim(UnityClaimsTypes.Permission, permissionName); - } } - AssignDefaultPermissions(validatedTokenContext.Principal!); AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); validatedTokenContext.Principal!.AddClaim(AbpClaimTypes.TenantId, userTenantAccount.TenantId?.ToString() ?? Guid.Empty.ToString()); @@ -105,11 +77,6 @@ internal async Task Handle(TokenValidatedContext validated return userTenantAccount; } - private void AssignITOperationsPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_itOperationsPermissions); - } - private async Task> AutoRegisterUserWithDefaultAsync(string userIdentifier, string username, string firstName, @@ -151,10 +118,5 @@ private bool IsAutoRegisterFlagSet() { return Configuration.GetValue("IdentityProfileLogin:AutoCreateUser"); } - - private void AssignDefaultPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_userPermissions); - } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs deleted file mode 100644 index 53ff40213a..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Linq; -using System.Security.Claims; -using System.Security.Principal; -using System.Threading.Tasks; -using Volo.Abp; -using Volo.Abp.Authorization.Permissions; -using Volo.Abp.DependencyInjection; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Security.Claims; -using Volo.Abp.SimpleStateChecking; - -namespace Unity.GrantManager.Web.Identity -{ - [Dependency(ReplaceServices = true)] - [ExposeServices(typeof(PermissionChecker), typeof(IPermissionChecker))] - public class PermissionChecker : IPermissionChecker, ITransientDependency - { - protected IPermissionDefinitionManager PermissionDefinitionManager { get; } - protected ICurrentPrincipalAccessor PrincipalAccessor { get; } - protected ICurrentTenant CurrentTenant { get; } - protected IPermissionValueProviderManager PermissionValueProviderManager { get; } - protected ISimpleStateCheckerManager StateCheckerManager { get; } - - public PermissionChecker( - ICurrentPrincipalAccessor principalAccessor, - IPermissionDefinitionManager permissionDefinitionManager, - ICurrentTenant currentTenant, - IPermissionValueProviderManager permissionValueProviderManager, - ISimpleStateCheckerManager stateCheckerManager) - { - PrincipalAccessor = principalAccessor; - PermissionDefinitionManager = permissionDefinitionManager; - CurrentTenant = currentTenant; - PermissionValueProviderManager = permissionValueProviderManager; - StateCheckerManager = stateCheckerManager; - } - - public virtual async Task IsGrantedAsync(string name) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, name); - } - - public virtual async Task IsGrantedAsync( - ClaimsPrincipal? claimsPrincipal, - string name) - { - Check.NotNull(name, nameof(name)); - - var permission = await PermissionDefinitionManager.GetOrNullAsync(name); - if (permission == null) - { - return false; - } - - if (!permission.IsEnabled) - { - return false; - } - - if (!await StateCheckerManager.IsEnabledAsync(permission)) - { - return false; - } - - var multiTenancySide = claimsPrincipal?.GetMultiTenancySide() - ?? CurrentTenant.GetMultiTenancySide(); - - if (!permission.MultiTenancySide.HasFlag(multiTenancySide)) - { - return false; - } - - var isGranted = false; - - if (claimsPrincipal != null - && claimsPrincipal.Claims.Any(s => s.Type == "Permission" && s.Value == name)) - { - isGranted = true; - } - - return isGranted; - } - - public async Task IsGrantedAsync(string[] names) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, names); - } - - public async Task IsGrantedAsync(ClaimsPrincipal? claimsPrincipal, string[] names) - { - Check.NotNull(names, nameof(names)); - - var result = new MultiplePermissionGrantResult(); - if (names.Length == 0) - { - return result; - } - - if (claimsPrincipal != null) - { - var permissions = claimsPrincipal.Claims.Where(s => s.Type == "Permission"); - foreach (var name in names) - { - if (permissions.Select(s => s.Value).Contains(name)) - { - result.Result.Add(name, PermissionGrantResult.Granted); - } - else - { - result.Result.Add(name, PermissionGrantResult.Prohibited); - } - } - } - - return await Task.FromResult(result); - } - } -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 10e9446c70..10a51884d1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -1,244 +1,49 @@ using Microsoft.Extensions.DependencyInjection; -using Unity.GrantManager.Permissions; +using Unity.GrantManager.Web.Identity.Authorization; using Unity.Modules.Shared; using Unity.Modules.Shared.Permissions; -using Unity.Reporting.Permissions; -using Unity.TenantManagement; -using Volo.Abp.Identity; using Volo.Abp.Modularity; namespace Unity.GrantManager.Web.Identity.Policy; internal static class PolicyRegistrant { - internal const string PermissionConstant = "Permission"; - internal static void Register(ServiceConfigurationContext context) { - // Using AddAuthorizationBuilder to register authorization services and construct policies - var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - - // Identity Role Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.ManagePermissions)); - - // Identity User Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.ManagePermissions)); - - // User Lookup Policies - authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.UserLookup.Default)); - - // Grant Manager Policies - authorizationBuilder.AddPolicy(GrantManagerPermissions.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.Intakes.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Intakes.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.ApplicationForms.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.ApplicationForms.Default)); - - // Grant Application Policies - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applications.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applications.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Edit, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Edit)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.AssignApplicant, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.AssignApplicant)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.AssignInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.AssignInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.StartInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.StartInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.CompleteInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.CompleteInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Complete, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Complete)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Add, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Add)); + // Simple permission-based policies (e.g., [Authorize("PermissionName")]) are handled + // automatically by ABP's AbpAuthorizationPolicyProvider + PermissionRequirementHandler. + // Only register custom composite policies here. - // R&A Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Default)); - - // R&A - Approval Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.UpdateFinalStateFields)); - - // R&A - Assessment Results Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields)); - - // R&A - Assessment Review List Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.SendBack, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.SendBack)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.Complete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.Complete)); - - //-- APPLICANT INFO - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update)); - - // Applicant Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Location.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Default) - )); - - //-- PAYMENT INFO - authorizationBuilder.AddPolicy(UnitySelector.Payment.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.PaymentList.Default)); - - // Tenancy Policies - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Delete)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageConnectionStrings)); - - // Setting Management - Tag Management - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Default)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Create)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Update)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Delete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Delete)); + var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - // IT Administrator Policies + // IT Administrator Policy: granted by role OR permission authorizationBuilder.AddPolicy(IdentityConsts.ITAdminPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITAdminRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITAdminPermissionName) - )); + policy => policy.AddRequirements( + new RoleOrPermissionRequirement(IdentityConsts.ITAdminRoleName, IdentityConsts.ITAdminPermissionName))); - // IT Operations Policies + // IT Operations Policy: granted by role OR permission authorizationBuilder.AddPolicy(IdentityConsts.ITOperationsPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Project Info Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Default)); + policy => policy.AddRequirements( + new RoleOrPermissionRequirement(IdentityConsts.ITOperationsRoleName, IdentityConsts.ITOperationsPermissionName))); - // Project Info Logical OR policy + // Applicant Info Logical OR policy: any update sub-permission grants access + authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, + policy => policy.AddRequirements( + new PermissionOrRequirement( + UnitySelector.Applicant.Summary.Update, + UnitySelector.Applicant.Contact.Update, + UnitySelector.Applicant.Authority.Update, + UnitySelector.Applicant.Location.Update, + UnitySelector.Applicant.AdditionalContact.Update, + UnitySelector.Applicant.Default))); + + // Project Info Logical OR policy: any update sub-permission grants access authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default) || - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Default) - )); - - // Project Info - Summary Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.UpdateFinalStateFields)); - - // Project Info - Location Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.UpdateFinalStateFields)); - - - // Reporting Configuration - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Default, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Default)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Update, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Update)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Delete, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); + policy => policy.AddRequirements( + new PermissionOrRequirement( + UnitySelector.Project.Location.Update.Default, + UnitySelector.Project.Summary.Update.Default, + UnitySelector.Project.Default))); } } From 3c52090763ce785127071ac7fd59d308c245180a Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:36:42 -0700 Subject: [PATCH 009/385] [AB#32738] Update claim handling for user ID compatibility --- .../Assessments/AssessmentAuthorizationHandler.cs | 4 +++- .../Identity/LoginHandlers/IdentityProfileLoginBase.cs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs index 3a8a4bbffd..0458eee55e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs @@ -8,6 +8,7 @@ using Volo.Abp; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Assessments; public class AssessmentAuthorizationHandler : AuthorizationHandler, ISingletonDependency @@ -81,7 +82,8 @@ protected virtual async Task CheckPolicyAsync(string permissionName, Autho { Check.NotNull(principal, nameof(principal)); - var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); + var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == AbpClaimTypes.UserId) + ?? principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); if (userIdOrNull == null || userIdOrNull.Value.IsNullOrWhiteSpace()) { return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index f152afb7f8..4ca8271e8b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -29,6 +29,7 @@ protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, stri { claimsPrinicipal.AddClaim("DisplayName", displayName); claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); + claimsPrinicipal.AddClaim("UserId", userId.ToString()); // Legacy claim for backward compatibility claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } From 68192ca4e76dfd96bfe9e5e1c4774ef2de70f826 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:36:54 -0700 Subject: [PATCH 010/385] [AB#32738] Simplify permission check logic in handler Co-authored-by: Copilot --- .../Authorization/PermissionOrAuthorizationHandler.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs index 7adb9b86e1..90f1034f54 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; using Volo.Abp.Authorization.Permissions; @@ -28,13 +29,11 @@ protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionOrRequirement requirement) { - foreach (var permission in requirement.Permissions) + var result = await _permissionChecker.IsGrantedAsync(context.User, requirement.Permissions); + + if (result.Result.Any(r => r.Value == PermissionGrantResult.Granted)) { - if (await _permissionChecker.IsGrantedAsync(context.User, permission)) - { - context.Succeed(requirement); - return; - } + context.Succeed(requirement); } } } From cad2330125bacf5f7e356e7f51b52c74ccb99cc3 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:54:35 -0700 Subject: [PATCH 011/385] [AB#32738] Seed host-level ITAdmin/ITOps permissions Co-authored-by: Copilot --- .../Permissions/PermissionGrantsDataSeeder.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index d92d21a3dd..d2678d8078 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -4,6 +4,7 @@ using Unity.Flex.Permissions; using Unity.GrantManager.Identity; using Unity.Modules.Shared; +using Unity.Modules.Shared.Permissions; using Unity.Notifications.Permissions; using Unity.Payments.Permissions; using Volo.Abp.Authorization.Permissions; @@ -99,6 +100,12 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) public async Task SeedAsync(DataSeedContext context) { + if (context.TenantId == null) + { + await SeedHostPermissionsAsync(); + return; + } + // Default permission grants based on role // - Program Manager @@ -332,6 +339,30 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, ], context.TenantId); } + + private async Task SeedHostPermissionsAsync() + { + // ITAdministrator host-level permissions (previously stamped at login) + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, IdentityConsts.ITAdminRoleName, + [ + "UnityTenantManagement.Tenants", + "UnityTenantManagement.Tenants.Create", + "UnityTenantManagement.Tenants.Update", + "UnityTenantManagement.Tenants.Delete", + "AbpTenantManagement.Tenants.ManageFeatures", + "UnityTenantManagement.Tenants.ManageConnectionStrings", + IdentitySeedPermissions.Users.Create, + IdentitySeedPermissions.UserLookup.Default, + IdentityConsts.ITAdminPermissionName + ], tenantId: null); + + // ITOperations host-level permissions (previously stamped at login) + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, IdentityConsts.ITOperationsRoleName, + [ + GrantManagerPermissions.Endpoints.ManageEndpoints, + IdentityConsts.ITOperationsPermissionName + ], tenantId: null); + } } } From 3c404b8dc01d99127c2f2085f5dccf7b2ee7f94e Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:07:59 -0700 Subject: [PATCH 012/385] [AB#32738] Add role claim for ITAdmin in login handler Co-authored-by: Copilot --- .../Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs index 3949150b68..8b0ff2641d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs @@ -5,9 +5,11 @@ using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.Data; using Volo.Abp.Identity; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Identity.LoginHandlers { @@ -29,6 +31,7 @@ internal async Task Handle(TokenValidatedContext validated } AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); + (validatedTokenContext.Principal!.Identity as ClaimsIdentity)?.AddClaim(new Claim(AbpClaimTypes.Role, IdentityConsts.ITAdminRoleName)); return userTenantAccount; } From bf8761c83b0092a3f6802977680682ab37d77095 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:37:59 -0700 Subject: [PATCH 013/385] [AB#32738] Add unit tests for user ID claim handling --- .../Assessments/FindUserIdirIdTests.cs | 131 ++++++++++++++ .../PermissionOrAuthorizationHandlerTests.cs | 166 ++++++++++++++++++ ...leOrPermissionAuthorizationHandlerTests.cs | 145 +++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs new file mode 100644 index 0000000000..8c0caf808e --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Security.Claims; +using Shouldly; +using Unity.GrantManager.Assessments; +using Volo.Abp.Security.Claims; +using Xunit; + +namespace Unity.GrantManager.Assessments; + +public class FindUserIdirIdTests +{ + private static ClaimsPrincipal CreatePrincipal(params Claim[] claims) + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaims(claims); + return new ClaimsPrincipal(identity); + } + + [Fact] + public void ShouldReturnGuid_WhenAbpUserIdClaimPresent() + { + // Arrange + var userId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, userId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void ShouldReturnGuid_WhenLegacyUserIdClaimPresent() + { + // Arrange + var userId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim("UserId", userId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void ShouldPreferAbpClaim_WhenBothPresent() + { + // Arrange + var abpUserId = Guid.NewGuid(); + var legacyUserId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, abpUserId.ToString()), + new Claim("UserId", legacyUserId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(abpUserId); + } + + [Fact] + public void ShouldFallbackToLegacy_WhenAbpClaimMissing() + { + // Arrange + var legacyUserId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim("DisplayName", "Test User"), + new Claim("UserId", legacyUserId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(legacyUserId); + } + + [Fact] + public void ShouldReturnNull_WhenNeitherClaimPresent() + { + // Arrange + var principal = CreatePrincipal( + new Claim("DisplayName", "Test User")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldReturnNull_WhenClaimValueIsEmpty() + { + // Arrange + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, ""), + new Claim("UserId", "")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldReturnNull_WhenClaimValueIsNotValidGuid() + { + // Arrange + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, "not-a-guid")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldThrow_WhenPrincipalIsNull() + { + Should.Throw(() => + AssessmentAuthorizationHandler.FindUserIdirId(null!)); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..afe984730c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs @@ -0,0 +1,166 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Web.Identity.Authorization; +using Volo.Abp.Authorization.Permissions; +using Xunit; + +namespace Unity.GrantManager.Identity.Authorization; + +public class PermissionOrAuthorizationHandlerTests +{ + private readonly IPermissionChecker _permissionChecker; + private readonly PermissionOrAuthorizationHandler _handler; + + public PermissionOrAuthorizationHandlerTests() + { + _permissionChecker = Substitute.For(); + _handler = new PermissionOrAuthorizationHandler(_permissionChecker); + } + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + PermissionOrRequirement requirement) + { + return new AuthorizationHandlerContext( + [requirement], + user, + resource: null); + } + + private static ClaimsPrincipal CreateUser() + { + var identity = new ClaimsIdentity("TestAuth"); + return new ClaimsPrincipal(identity); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenAnyPermissionIsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B", "Perm.C" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Granted); + grantResult.Result.Add("Perm.C", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenNoPermissionsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenAllPermissionsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Granted); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Granted); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldUseBatchApi_NotIndividualCalls() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.X", "Perm.Y", "Perm.Z" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.X", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.Y", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.Z", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert - single batch call, not individual calls + await _permissionChecker.Received(1).IsGrantedAsync(user, permissions); + await _permissionChecker.DidNotReceive().IsGrantedAsync(user, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenResultIsEmpty() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public void Requirement_ShouldStorePermissions() + { + var requirement = new PermissionOrRequirement("A", "B", "C"); + requirement.Permissions.ShouldBe(new[] { "A", "B", "C" }); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..399c8146df --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Web.Identity.Authorization; +using Volo.Abp.Authorization.Permissions; +using Xunit; + +namespace Unity.GrantManager.Identity.Authorization; + +public class RoleOrPermissionAuthorizationHandlerTests +{ + private readonly IPermissionChecker _permissionChecker; + private readonly RoleOrPermissionAuthorizationHandler _handler; + + public RoleOrPermissionAuthorizationHandlerTests() + { + _permissionChecker = Substitute.For(); + _handler = new RoleOrPermissionAuthorizationHandler(_permissionChecker); + } + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + RoleOrPermissionRequirement requirement) + { + return new AuthorizationHandlerContext( + [requirement], + user, + resource: null); + } + + private static ClaimsPrincipal CreateUserWithRole(string roleName) + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim(ClaimTypes.Role, roleName)); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal CreateUserWithoutRole() + { + var identity = new ClaimsIdentity("TestAuth"); + return new ClaimsPrincipal(identity); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenUserHasRole() + { + // Arrange + var user = CreateUserWithRole("ITAdministrator"); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + await _permissionChecker.DidNotReceive().IsGrantedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenUserHasPermission() + { + // Arrange + var user = CreateUserWithoutRole(); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(true); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenNeitherRoleNorPermission() + { + // Arrange + var user = CreateUserWithoutRole(); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(false); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ShouldShortCircuit_WhenRoleMatches() + { + // Arrange + var user = CreateUserWithRole("ITOperations"); + var requirement = new RoleOrPermissionRequirement("ITOperations", "Unity.ITOperations"); + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert - should not call permission checker at all + context.HasSucceeded.ShouldBeTrue(); + await _permissionChecker.DidNotReceive().IsGrantedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldCheckPermission_WhenRoleDoesNotMatch() + { + // Arrange + var user = CreateUserWithRole("SomeOtherRole"); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(false); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + await _permissionChecker.Received(1).IsGrantedAsync(user, "Unity.ITAdmin"); + } + + [Fact] + public void Requirement_ShouldStoreRoleAndPermission() + { + var requirement = new RoleOrPermissionRequirement("MyRole", "MyPermission"); + requirement.RoleName.ShouldBe("MyRole"); + requirement.PermissionName.ShouldBe("MyPermission"); + } +} From f1115c4644b8f535e086d5952c1cba7f5cfabe70 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:45:20 -0700 Subject: [PATCH 014/385] [AB#32738] Enhance user ID claim handling for GUID parsing --- .../Unity.GrantManager.Web/Identity/CurrentUser.cs | 14 +++++++++++++- .../Identity/IdentityProfileLoginHandler.cs | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index 6037b9f5c3..8bf2c7fb2b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,10 +75,22 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { + // First try the IDIR-specific GUID claim + var idirGuid = userClaims.FirstOrDefault(s => s.Type == UnityClaimsTypes.IDirUserGuid); + if (idirGuid != null && Guid.TryParse(idirGuid.Value, out var guid)) + { + return guid; + } + + // Fallback to UserId claim (strip @azureidir suffix if present) var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { - return Guid.Parse(userId.Value); + var value = userId.Value.Split('@')[0]; // Remove @azureidir suffix + if (Guid.TryParse(value, out guid)) + { + return guid; + } } } return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs index bf5cf48de0..d94f32e958 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; -using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; From 435c885a45211202878bde680762db46e491e49a Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 7 May 2026 12:12:32 -0700 Subject: [PATCH 015/385] Missing button for scoresheet identified and patched in --- .../ConfigurationManagement/Index.cshtml | 19 ++++++++ .../ScoresheetConfiguration.js | 45 +++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml index 59dfcfa43e..a53c96f106 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml @@ -218,9 +218,28 @@
+
+
+ + + +
@await Component.InvokeAsync("Scoresheet")
+
+ +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/ScoresheetConfiguration.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/ScoresheetConfiguration.js index b60cbf7393..dd7f36ec8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/ScoresheetConfiguration.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/ScoresheetConfiguration.js @@ -1,6 +1,43 @@ /* ScoresheetConfiguration JS - local copy for ConfigurationManagement */ +globalThis.importScoresheetFile = function (inputId) { + let input = document.getElementById(inputId); + let file = input.files[0]; + if (!file) return; + + const maxFileSize = decodeURIComponent($("#MaxFileSize").val()); + if ((file.size * 0.000001) > maxFileSize) { + input.value = null; + return abp.notify.error('File size exceeds ' + maxFileSize + 'MB', 'Error'); + } + + let formData = new FormData(); + formData.append("file", file); + + $.ajax({ + url: "/api/app/scoresheet/import", + data: formData, + processData: false, + contentType: false, + type: "POST", + success: function (data) { + abp.notify.success(data.responseText, 'Scoresheet Import Is Successful'); + PubSub.publish('refresh_scoresheet_list', { scoresheetId: null }); + input.value = null; + }, + error: function () { + abp.notify.error('Import failed.', 'Scoresheet Import Error'); + input.value = null; + } + }); +}; + (function ($) { + $(function () { + $('#scoresheet_import_upload_btn').on('click', function () { + $('#scoresheet_import_upload').trigger('click'); + }); + }); const scoresheetModal = new abp.ModalManager({ viewUrl: 'ScoresheetConfiguration/ScoresheetModal' }); @@ -45,7 +82,7 @@ }); // Exposed globally — called from inline onclick attributes in Scoresheet component HTML - window.openScoresheetModal = function (scoresheetId, actionType) { + globalThis.openScoresheetModal = function (scoresheetId, actionType) { scoresheetToEditId = scoresheetId; scoresheetModal.open({ scoresheetId: scoresheetId, @@ -53,14 +90,14 @@ }); }; - window.openCloneScoresheetModal = function (scoresheetId) { + globalThis.openCloneScoresheetModal = function (scoresheetId) { scoresheetToEditId = scoresheetId; cloneScoresheetModal.open({ scoresheetId: scoresheetId }); }; - window.openPublishScoresheetModal = function (scoresheetId) { + globalThis.openPublishScoresheetModal = function (scoresheetId) { scoresheetToEditId = scoresheetId; publishScoresheetModal.open({ scoresheetId: scoresheetId @@ -100,4 +137,4 @@ refreshScoresheetInfoWidget(data.scoresheetId); } ); -})(jQuery); +})(jQuery); \ No newline at end of file From 40cc5dfe63feaae097d4191167130264a11d3648 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 8 May 2026 09:42:12 -0700 Subject: [PATCH 016/385] WIP add AI generation log context --- .../BackgroundJobs/AIGenerationLogScope.cs | 28 +++++++++++++++++++ .../GenerateApplicationAnalysisJob.cs | 9 ++++++ .../GenerateApplicationScoringJob.cs | 9 ++++++ .../GenerateAttachmentSummaryJob.cs | 9 ++++++ .../RunApplicationAIPipelineJob.cs | 9 ++++++ 5 files changed, 64 insertions(+) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationLogScope.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationLogScope.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationLogScope.cs new file mode 100644 index 0000000000..e5c3f5a97f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationLogScope.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +internal static class AIGenerationLogScope +{ + public static IDisposable? Begin( + ILogger logger, + string operationType, + Guid applicationId, + Guid? tenantId, + string requestKey, + string? promptVersion, + Guid? requestedByUserId) + { + return logger.BeginScope(new Dictionary + { + ["AIOperationType"] = operationType, + ["AIApplicationId"] = applicationId, + ["AITenantId"] = tenantId, + ["AIGenerationRequestKey"] = requestKey, + ["AIPromptVersion"] = promptVersion, + ["AIRequestedByUserId"] = requestedByUserId + }); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 35d3f554c5..e072da2f02 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -22,6 +22,15 @@ public class GenerateApplicationAnalysisJob( { public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, + args.ApplicationId, + args.TenantId, + args.RequestKey, + args.PromptVersion, + args.RequestedByUserId); + using (currentTenant.Change(args.TenantId)) { await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index 2292324a5e..8a1393ea19 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -24,6 +24,15 @@ public class GenerateApplicationScoringJob( { public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType, + args.ApplicationId, + args.TenantId, + args.RequestKey, + args.PromptVersion, + args.RequestedByUserId); + using (currentTenant.Change(args.TenantId)) { await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs index 63507cd588..91b17e38a2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -22,6 +22,15 @@ public class GenerateAttachmentSummaryJob( { public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + args.ApplicationId, + args.TenantId, + args.RequestKey, + args.PromptVersion, + args.RequestedByUserId); + using (currentTenant.Change(args.TenantId)) { await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync(unitOfWorkManager, generationRequestRepository, args.RequestKey); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index 2a46fc3530..20c4c1cc3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -33,6 +33,15 @@ public class RunApplicationAIPipelineJob( { public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.PipelineOperationType, + args.ApplicationId, + args.TenantId, + args.RequestKey, + args.PromptVersion, + args.RequestedByUserId); + if (string.IsNullOrWhiteSpace(args.RequestKey)) { throw new ArgumentException("RequestKey is required.", nameof(args)); From c2fb05ec199a8b3086f52c810e09760ac38707b2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 14 May 2026 12:47:23 -0700 Subject: [PATCH 017/385] AB#32957 fix certain duplicated elements in submission render --- .../Pages/GrantApplications/Details.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index dbdca9ddda..f0093fcdf9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -644,6 +644,7 @@ $(function () { } Formio.icons = 'fontawesome'; + patchHtmlElementTags(formSchema); // Create container inside shadow DOM const container = document.createElement('div'); @@ -1768,3 +1769,27 @@ function clearCurrencyError(input) { document.getElementById(errorSpan).textContent = ''; input.attr('aria-invalid', 'false'); } + +// htmlelement components default to

, which causes browser HTML auto-repair when +// content contains block-level elements (div, h3, etc.), producing duplicate DOM nodes. +// Changing tag to 'div' before rendering prevents this at the source. +function patchHtmlElementTags(schema) { + function walk(components) { + if (!Array.isArray(components)) return; + components.forEach(comp => { + if (comp.type === 'htmlelement' && (!comp.tag || comp.tag === 'p')) { + comp.tag = 'div'; + } + walk(comp.components); + if (Array.isArray(comp.columns)) { + comp.columns.forEach(col => walk(col.components)); + } + if (Array.isArray(comp.rows)) { + comp.rows.forEach(row => { + if (Array.isArray(row)) row.forEach(cell => walk(cell.components)); + }); + } + }); + } + walk(schema.components); +} \ No newline at end of file From 856171a7e0e5bac325284c4d8044926727499449 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 14 May 2026 13:07:44 -0700 Subject: [PATCH 018/385] AB#32957 reduce nesting --- .../Pages/GrantApplications/Details.js | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index f0093fcdf9..e7d33072ac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -1773,23 +1773,24 @@ function clearCurrencyError(input) { // htmlelement components default to

, which causes browser HTML auto-repair when // content contains block-level elements (div, h3, etc.), producing duplicate DOM nodes. // Changing tag to 'div' before rendering prevents this at the source. +function walkFormComponents(components) { + if (!Array.isArray(components)) return; + components.forEach(comp => { + if (comp.type === 'htmlelement' && (!comp.tag || comp.tag === 'p')) { + comp.tag = 'div'; + } + walkFormComponents(comp.components); + if (Array.isArray(comp.columns)) { + comp.columns.forEach(col => walkFormComponents(col.components)); + } + if (Array.isArray(comp.rows)) { + comp.rows.forEach(row => { + if (Array.isArray(row)) row.forEach(cell => walkFormComponents(cell.components)); + }); + } + }); +} + function patchHtmlElementTags(schema) { - function walk(components) { - if (!Array.isArray(components)) return; - components.forEach(comp => { - if (comp.type === 'htmlelement' && (!comp.tag || comp.tag === 'p')) { - comp.tag = 'div'; - } - walk(comp.components); - if (Array.isArray(comp.columns)) { - comp.columns.forEach(col => walk(col.components)); - } - if (Array.isArray(comp.rows)) { - comp.rows.forEach(row => { - if (Array.isArray(row)) row.forEach(cell => walk(cell.components)); - }); - } - }); - } - walk(schema.components); + walkFormComponents(schema.components); } \ No newline at end of file From 7d9db29e664ec279476c88e07e4564932183b61d Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:09:30 -0700 Subject: [PATCH 019/385] [AB#29848] Refactor form synchronization to handle descriptions --- .../Applications/ApplicationFormManager.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs index 31ee0d2851..d58bd3c0ff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs @@ -1,9 +1,9 @@ -using System.Threading.Tasks; -using Volo.Abp.Domain.Services; -using Unity.GrantManager.Intakes; +using Newtonsoft.Json.Linq; +using System.Threading.Tasks; using Unity.GrantManager.Events; +using Unity.GrantManager.Intakes; using Volo.Abp.Domain.Repositories; -using Newtonsoft.Json.Linq; +using Volo.Abp.Domain.Services; namespace Unity.GrantManager.Applications { @@ -58,9 +58,14 @@ public ApplicationForm SynchronizePublishedForm(ApplicationForm applicationForm, JObject formObject = JObject.Parse(form.ToString()); var formName = formObject.SelectToken("name"); var formDescription = formObject.SelectToken("description"); - if(formName != null && formDescription != null) + + if(formName != null) { applicationForm.ApplicationFormName = formName.ToString(); + } + + if (formDescription != null && applicationForm.ApplicationFormDescription == null) + { applicationForm.ApplicationFormDescription = formDescription.ToString(); } From 611779c325065368234b4af58a3b79d76bae1091 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:21:55 -0700 Subject: [PATCH 020/385] [AB#29848] Support form description whitespace overwrite on sync --- .../Applications/ApplicationFormManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs index d58bd3c0ff..d72db21f77 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationFormManager.cs @@ -59,12 +59,12 @@ public ApplicationForm SynchronizePublishedForm(ApplicationForm applicationForm, var formName = formObject.SelectToken("name"); var formDescription = formObject.SelectToken("description"); - if(formName != null) + if (formName != null) { applicationForm.ApplicationFormName = formName.ToString(); } - if (formDescription != null && applicationForm.ApplicationFormDescription == null) + if (formDescription != null && string.IsNullOrWhiteSpace(applicationForm.ApplicationFormDescription)) { applicationForm.ApplicationFormDescription = formDescription.ToString(); } From ce04675703aee5abc696c43949ed73051740a995 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 2 Jun 2026 14:38:57 -0700 Subject: [PATCH 021/385] AB#33270 - update docs --- ...olidated_formversion_data_specification.md | 189 ++++++++++++++ ...nsolidated_worksheet_data_specification.md | 198 +++++++++++++++ .../get_formversion_data_specification.md | 233 ++++++++++++++++++ .../get_scoresheet_data_specification.md | 230 +++++++++++++++++ .../reporting/reporting-architecture.md | 5 +- .../reporting/reporting-configuration.md | 218 +++++++++++++--- 6 files changed, 1035 insertions(+), 38 deletions(-) create mode 100644 documentation/reporting/get_consolidated_formversion_data_specification.md create mode 100644 documentation/reporting/get_consolidated_worksheet_data_specification.md create mode 100644 documentation/reporting/get_formversion_data_specification.md create mode 100644 documentation/reporting/get_scoresheet_data_specification.md diff --git a/documentation/reporting/get_consolidated_formversion_data_specification.md b/documentation/reporting/get_consolidated_formversion_data_specification.md new file mode 100644 index 0000000000..ea13d31dd2 --- /dev/null +++ b/documentation/reporting/get_consolidated_formversion_data_specification.md @@ -0,0 +1,189 @@ +# get_consolidated_formversion_data.sql Function Specification + +## Overview + +`get_consolidated_formversion_data` is a PostgreSQL PL/pgSQL function that generates a dynamic SQL query spanning **all versions** of a CHEFS form. It is called by `generate_consolidated_formversion_view`. Rather than targeting a single form version, it iterates over every version recorded in the mapping metadata and generates a query per version, combining them with `UNION ALL`. Each output row includes a `form_version_label` column identifying which version it came from. + +## Function Signature + +```sql +CREATE OR REPLACE FUNCTION "Reporting".get_consolidated_formversion_data( + form_id uuid, + report_map_id uuid +) +RETURNS text +LANGUAGE plpgsql +``` + +## Purpose + +- Generates a multi-version UNION ALL query covering all form versions for a given form +- Reads version IDs and labels from the mapping's `Metadata.Info` dictionary +- Applies **version gating** — fields that belong to specific versions produce NULLs for other versions +- Inherits the dual-schema (legacy/current) UNION ALL pattern from `get_formversion_data` +- Supports both root fields and DataGrid fields +- Returns a complete SQL query string ready for use as a consolidated view body + +## Input Parameters + +- **form_id** — UUID of the application form (not a form version ID). Determines which versions to include via the metadata keys. +- **report_map_id** — UUID of the `ReportColumnsMaps` record containing the consolidated mapping configuration. + +## Return Value + +Returns a `TEXT` string containing a UNION ALL query across all form versions and field types. + +--- + +## Version Iteration + +Form version IDs and labels are stored in the mapping's `Metadata.Info` dictionary at save time, keyed as `formversion_{versionId}` with the version label as the value (e.g., `v1`, `v2`). + +The function reads these at query time: + +```sql +SELECT replace(kv.key, 'formversion_', '')::uuid, kv.value +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_each_text(rcm."Mapping"->'Metadata'->'Info') kv +WHERE rcm."Id" = report_map_id + AND kv.key LIKE 'formversion_%' +ORDER BY kv.value +``` + +For each version, one or more query pairs (root + DataGrid, each legacy + current) are generated and accumulated. All are combined into a final `UNION ALL`. + +--- + +## Version Gating + +Fields in a consolidated mapping carry a `VersionLabel` that indicates which versions they belong to: + +| VersionLabel | Meaning | +| ------------ | ------- | +| `NULL` | Field is present and identical in **all** versions — always emit the real value | +| `'v1'` | Field exists only in version 1 — emit real value for v1, NULL for other versions | +| `'v1, v3'` | Field exists in v1 and v3 — emit real value for those, NULL for others | + +When processing a specific version, any field whose `VersionLabel` is non-null and does not contain the current `version_lbl` is **skipped** — its NULL placeholder is left in place rather than replaced with a data extraction expression. + +--- + +## Dual-Schema Design + +Identical to `get_formversion_data`: each version generates two query branches (legacy and current submission schema), split by whether `submission->'submission'->'submission'` IS NULL: + +- **Legacy**: `afs."Submission"->'submission'->'data'` +- **Current**: `afs."Submission"->'submission'->'submission'->'data'` + +Each pair is combined with `UNION ALL` before the per-version UNION ALL accumulation. + +--- + +## Core Processing Logic + +### Pass 1 — Column inventory and DataGrid detection + +Same as `get_formversion_data`: collect all unique column names, types, and DataGrid names. + +No type-conflict fallback — the consolidated function uses the first occurrence of each column name's type and does not emit TEXT fallback for conflicts (fields from different versions with different types are handled via version gating instead). + +### Pass 2 — NULL column list construction + +Builds a typed NULL placeholder list ordered by column name, same as other functions. + +### Pass 3 — Per-version query generation + +For each version (ordered by label): + +1. Initialise SELECT with fixed columns + typed NULL placeholders +2. Add `form_version_label` as a literal string constant +3. For each field in the mapping, check version gating — skip if this version is not in the field's `VersionLabel` +4. Replace NULL placeholders with JSON extraction expressions for applicable fields +5. Emit a legacy + current UNION ALL pair for root fields +6. Emit a legacy + current UNION ALL pair per DataGrid +7. Append all version queries to the accumulation array + +### Final assembly + +All accumulated queries are combined with `UNION ALL`. + +--- + +## Data Sources + +- `public."ApplicationFormSubmissions"` — filtered by `ApplicationFormVersionId = version_id` for each version in the loop + +--- + +## Output Columns + +| Column | Source | Description | +| ------ | ------ | ----------- | +| `submission_id` | `afs."Id"` | UUID of the form submission record | +| `application_id` | `afs."ApplicationId"` | UUID of the parent application | +| `row_identifier` | `'root'` or `'{datagrid}_r{n}'` | Row type identifier | +| `form_version_label` | literal from metadata | Version label (e.g., `'v1'`, `'v2'`) | +| *(dynamic columns)* | Extracted from JSON | One column per mapped field; NULL for versions where the field doesn't exist | + +--- + +## Field Type Handling + +Same as `get_formversion_data`: + +| Type | SQL Type | +| ---- | -------- | +| `textfield` / `textarea` / `email` / `select` / `phoneNumber` | TEXT | +| `number` | NUMERIC | +| `currency` | DECIMAL(18,2) | +| `option` / `checkbox` | BOOLEAN | + +--- + +## Differences from get_formversion_data + +| Aspect | `get_consolidated_formversion_data` | `get_formversion_data` | +| ------ | ----------------------------------- | ---------------------- | +| Parameter | `form_id` (all versions) | `correlation_id` (one version) | +| Version loop | Iterates over metadata keys | No loop — single version | +| Version gating | Yes — per-field NULL for other versions | Not applicable | +| Extra output column | `form_version_label` | Not present | +| Type conflict handling | First-occurrence wins (no TEXT fallback) | TEXT fallback for conflicts | + +--- + +## Common Use Cases + +### 1. Field present in all versions (VersionLabel = null) + +The field produces a real value for every version's query — no NULLs due to version gating. + +### 2. Field exclusive to one version + +Produces a real value only in that version's query; all other version queries leave it as a typed NULL. + +### 3. Field with type conflict across versions + +The first occurrence's type is used for the NULL column list. The field may be a NULL for versions where it doesn't apply, avoiding the conflict at query time. + +--- + +## Debugging Queries + +```sql +-- Check what versions are stored in the mapping metadata +SELECT kv.key, kv.value +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_each_text(rcm."Mapping"->'Metadata'->'Info') kv +WHERE rcm."Id" = '' + AND kv.key LIKE 'formversion_%'; + +-- Check field VersionLabels in the mapping +SELECT row_data->>'ColumnName', row_data->>'VersionLabel' +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_array_elements(rcm."Mapping"->'Rows') row_data +WHERE rcm."Id" = ''; + +-- Test function output +SELECT "Reporting".get_consolidated_formversion_data('', ''); +``` diff --git a/documentation/reporting/get_consolidated_worksheet_data_specification.md b/documentation/reporting/get_consolidated_worksheet_data_specification.md new file mode 100644 index 0000000000..19806706c0 --- /dev/null +++ b/documentation/reporting/get_consolidated_worksheet_data_specification.md @@ -0,0 +1,198 @@ +# get_consolidated_worksheet_data.sql Function Specification + +## Overview + +`get_consolidated_worksheet_data` is a PostgreSQL PL/pgSQL function (implemented as pure CTE-based SQL) that generates a dynamic SQL query spanning **all form versions** for a consolidated worksheet view. It is called by `generate_consolidated_worksheet_view`. Like `get_worksheet_data`, it reads from `Flex.WorksheetInstances`, but iterates over every form version recorded in the mapping metadata, applying version gating per field, and adds a `form_version_label` column to each output row. + +## Function Signature + +```sql +CREATE OR REPLACE FUNCTION "Reporting".get_consolidated_worksheet_data( + form_id uuid, + report_map_id uuid +) +RETURNS text +LANGUAGE plpgsql +``` + +## Purpose + +- Generates a multi-version UNION ALL query covering worksheet data from all form versions for a given form +- Reads version IDs and labels from the mapping's `Metadata.Info` dictionary +- Reads worksheet link IDs from the same metadata (`ws_{versionId}_{worksheetId}` keys) +- Applies **version gating** — fields for specific versions produce NULLs in queries for other versions +- Supports both root fields and DataGrid fields per worksheet per version +- Implemented as a CTE-based approach (like `get_worksheet_data`), not imperative PL/pgSQL loops +- Returns a complete SQL query string ready for use as a view body + +## Input Parameters + +- **form_id** — UUID of the application form. Determines which versions and worksheets to include via the metadata. +- **report_map_id** — UUID of the `ReportColumnsMaps` record containing the consolidated mapping configuration. + +## Return Value + +Returns a `TEXT` string containing a UNION ALL query across all (version, worksheet, field-group) combinations. + +--- + +## Version and Worksheet Iteration + +### Version info + +Version IDs and labels are stored in `Metadata.Info` keyed as `formversion_{versionId}` → `v1`, `v2`, etc.: + +```sql +-- version_info CTE +SELECT replace(kv.key, 'formversion_', '')::uuid AS version_id, kv.value AS version_label +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_each_text(rcm."Mapping"->'Metadata'->'Info') kv +WHERE rcm."Id" = report_map_id + AND kv.key LIKE 'formversion_%' +``` + +### Worksheet links + +Worksheet link IDs are stored keyed as `ws_{versionId}_{worksheetId}` → worksheet display info. These are used to scope worksheet instance queries to specific versions. + +--- + +## Version Gating + +Fields carry a `VersionLabel` indicating which versions they belong to (same semantics as the consolidated formversion function): + +| VersionLabel | Meaning | +| ------------ | ------- | +| `NULL` | Field present and identical in **all** versions — always emit the real value | +| `'v1'` | Field exclusive to version 1 | +| `'v1, v3'` | Field present in v1 and v3 only | + +When generating a query for a specific version, fields whose `VersionLabel` does not include the current version label are left as typed NULL placeholders. + +--- + +## CTE-Based Implementation + +Unlike `get_consolidated_formversion_data` (which uses PL/pgSQL loops), this function uses a nested CTE structure to build and assemble query fragments. The key CTEs are: + +| CTE | Purpose | +| --- | ------- | +| `mapping_data` | Extracts all mapping rows from `ReportColumnsMaps`, parses worksheet name and clean DataPath | +| `unique_mappings` | `DISTINCT ON (column_name)` — one mapping per column | +| `version_info` | Reads form version IDs and labels from metadata | +| `unique_worksheet_names` | Distinct worksheet names from the mapping | +| `unique_datagrid_combinations` | Distinct `(worksheet_name, datagrid_name)` pairs for DataGrid fields | +| `datagrid_queries` | One query fragment per `(version, worksheet, datagrid)` triple | +| `root_queries` | One query fragment per `(version, worksheet)` for root-level fields | +| *(final assembly)* | `UNION ALL` of datagrid and root query fragments | + +--- + +## Data Sources + +| Table | Schema | Purpose | +| ----- | ------ | ------- | +| `WorksheetInstances` | `Flex` | Worksheet data per application; `CorrelationId` = application ID | +| `Worksheets` | `Flex` | Worksheet metadata (name, title) | +| `ReportColumnsMaps` | `Reporting` | Mapping configuration and metadata | + +--- + +## Output Columns + +| Column | Source | Description | +| ------ | ------ | ----------- | +| `worksheet_instance_id` | `wi."Id"` | UUID of the worksheet instance | +| `application_id` | `wi."CorrelationId"` | UUID of the parent application | +| `worksheet_name` | `w."Name"` | Worksheet name from definition | +| `row_identifier` | `'root'` or `'{datagrid}_r{n}'` | Row type identifier | +| `form_version_label` | literal from metadata | Version label (e.g., `'v1'`, `'v2'`) | +| *(dynamic columns)* | Extracted from `CurrentValue` | One column per mapped field; NULL for non-applicable versions | + +--- + +## DataPath and Worksheet Name Parsing + +The CTE extracts `worksheet_name` and `clean_data_path` from the stored `DataPath` and `Path` fields: + +```sql +CASE + WHEN data_path_raw ~ '^\(' -- starts with (worksheet_name) + THEN substring(data_path_raw from '^\(([^)]+)\)') -- extract from parentheses + ELSE split_part(path, '->', 1) -- first segment of Path +END AS worksheet_name, + +CASE + WHEN data_path_raw ~ '^\(' + THEN regexp_replace(data_path_raw, '^\([^)]+\)', '') -- strip leading (name) + ELSE data_path_raw +END AS clean_data_path +``` + +DataPath format for worksheet fields: `(WorksheetName)FieldName` or `(WorksheetName)DataGridName->FieldName`. + +--- + +## Field Type Handling + +Same as `get_worksheet_data`: + +| Type | SQL Type | Extraction | +| ---- | -------- | ---------- | +| `text` / `textarea` | TEXT | Direct string from `CurrentValue` | +| `currency` | DECIMAL(18,2) | Locale commas stripped, numeric validated | +| `number` | NUMERIC | Numeric validated | +| `date` | TIMESTAMP | Date validated | +| `checkbox` | BOOLEAN | True/false value list | +| `checkboxgroup` | BOOLEAN per option | JSON array parsed, individual option extracted | +| `radio` | TEXT | Selected option value | + +--- + +## Differences from get_worksheet_data + +| Aspect | `get_consolidated_worksheet_data` | `get_worksheet_data` | +| ------ | --------------------------------- | -------------------- | +| Parameter | `form_id` (all versions) | `correlation_id` (one form version) | +| Version loop | Yes — all versions from metadata | No loop — single version | +| Version gating | Yes — per-field NULL for other versions | Not applicable | +| Extra output column | `form_version_label` | Not present | +| Worksheet scope | Per-(version, worksheet) | Per-worksheet for one version | + +--- + +## Differences from get_consolidated_formversion_data + +| Aspect | `get_consolidated_worksheet_data` | `get_consolidated_formversion_data` | +| ------ | --------------------------------- | ------------------------------------ | +| Data source | `Flex.WorksheetInstances` | `ApplicationFormSubmissions` | +| Implementation | CTE-based SQL | Procedural PL/pgSQL loops | +| Dual-schema | No (single worksheet JSON schema) | Yes (legacy + current CHEFS schema) | +| Worksheet identity | Worksheet name from DataPath | Not applicable | + +--- + +## Debugging Queries + +```sql +-- Check versions and worksheet links stored in mapping metadata +SELECT kv.key, kv.value +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_each_text(rcm."Mapping"->'Metadata'->'Info') kv +WHERE rcm."Id" = ''; + +-- Check VersionLabels in the mapping +SELECT row_data->>'ColumnName', row_data->>'VersionLabel', row_data->>'DataPath' +FROM "Reporting"."ReportColumnsMaps" rcm, + jsonb_array_elements(rcm."Mapping"->'Rows') row_data +WHERE rcm."Id" = ''; + +-- Inspect worksheet instances linked to a form (via WorksheetLinkCorrelationId matching form version IDs) +SELECT wi."Id", wi."WorksheetId", wi."CorrelationId", w."Name" +FROM "Flex"."WorksheetInstances" wi +JOIN "Flex"."Worksheets" w ON wi."WorksheetId" = w."Id" +LIMIT 10; + +-- Test function output +SELECT "Reporting".get_consolidated_worksheet_data('', ''); +``` diff --git a/documentation/reporting/get_formversion_data_specification.md b/documentation/reporting/get_formversion_data_specification.md new file mode 100644 index 0000000000..e55e59e9ac --- /dev/null +++ b/documentation/reporting/get_formversion_data_specification.md @@ -0,0 +1,233 @@ +# get_formversion_data.sql Function Specification + +## Overview + +`get_formversion_data` is a PostgreSQL PL/pgSQL function that generates a dynamic SQL query for extracting CHEFS form submission data from a specific form version. It is called by the `generate_formversion_view` stored procedure to create flat, queryable database views from raw CHEFS JSON submission blobs. + +## Function Signature + +```sql +CREATE OR REPLACE FUNCTION "Reporting".get_formversion_data( + correlation_id uuid, + report_map_id uuid +) +RETURNS text +LANGUAGE plpgsql +``` + +## Purpose + +- Generates a dynamic SQL SELECT query based on the report column mappings for a form version +- Extracts individual field values from CHEFS JSON submission data +- Handles both root fields and DataGrid (repeating row) fields +- Supports the two CHEFS submission JSON schemas (legacy and current) via UNION ALL +- Detects type conflicts and applies TEXT fallback for affected columns +- Returns a complete SQL query string ready for execution as a view body + +## Input Parameters + +- **correlation_id** — UUID of the form version (`ApplicationFormVersionId`). Used to filter `ApplicationFormSubmissions`. +- **report_map_id** — UUID of the `ReportColumnsMaps` record containing the field-to-column mapping configuration. + +## Return Value + +Returns a `TEXT` string containing a complete SQL query (or UNION ALL of queries) that can be executed or used as a view definition. + +--- + +## Dual-Schema Design + +CHEFS form submissions are stored in two different JSON structures depending on when the submission was created. The function detects which schema applies to each row and generates queries for both, combined with `UNION ALL`. + +### Legacy schema + +Used for older submissions where `submission->'submission'->'submission'` **IS NULL**: + +```sql +afs."Submission"->'submission'->'data'->'fieldKey' +``` + +### Current schema + +Used for newer submissions where `submission->'submission'->'submission'` **IS NOT NULL**: + +```sql +afs."Submission"->'submission'->'submission'->'data'->'fieldKey' +``` + +### Detection + +Each generated query pair is split into two branches combined with `UNION ALL`: + +```sql +(SELECT ... FROM ApplicationFormSubmissions afs + WHERE afs."ApplicationFormVersionId" = '' + AND afs."Submission"->'submission'->'submission' IS NULL) -- legacy +UNION ALL +(SELECT ... FROM ApplicationFormSubmissions afs + WHERE afs."ApplicationFormVersionId" = '' + AND afs."Submission"->'submission'->'submission' IS NOT NULL) -- current +``` + +Both branches select identical columns so their types are always consistent across the UNION. + +--- + +## Core Processing Logic + +### Pass 1 — Column inventory and type-conflict detection + +Iterates all mapping rows to: +- Collect unique column names and their declared types +- Detect **type conflicts**: if the same column name appears in two rows with different types, the column is flagged for TEXT fallback (`use_text_fallback = true`) +- Identify DataGrid fields (TypePath contains `'datagrid'`) and collect unique DataGrid names +- Identify root-level fields (TypePath does not contain `'datagrid'`) + +### Pass 2 — NULL column list construction + +Builds a typed NULL placeholder list ordered by column name, used as a consistent column template in all UNION ALL branches: + +```sql +NULL::TEXT AS first_name, NULL::NUMERIC AS budget, NULL::BOOLEAN AS consent_given +``` + +This ensures all UNION ALL branches have identical column sets and types. + +### Pass 3 — Query generation + +For each unique DataGrid and for root fields, generates a UNION ALL pair (legacy + current) substituting NULL placeholders with actual JSON extraction expressions. + +--- + +## Data Sources + +- `public."ApplicationFormSubmissions"` — contains the raw CHEFS submission JSON and the `ApplicationFormVersionId` foreign key + +--- + +## Field Type Handling + +| Type | SQL Type | Extraction | +| ---- | -------- | ---------- | +| `textfield` / `textarea` / `email` / `select` / `phoneNumber` | TEXT | `->>` operator on JSON path | +| `number` | NUMERIC | Numeric regex validation → cast, or NULL | +| `currency` | DECIMAL(18,2) | Numeric regex validation → cast, or NULL | +| `option` / `checkbox` | BOOLEAN | `true/t/1/yes` → true, `false/f/0/no` → false, else NULL | +| *(type conflict fallback)* | TEXT | All values cast to TEXT with type-aware formatting | + +### Type conflict TEXT fallback + +When a column has conflicting types across mapping rows, the function converts values to TEXT and emits them as strings rather than typed values. This prevents UNION ALL type mismatches in the generated view. + +--- + +## DataGrid Handling + +DataGrid fields are identified by TypePath containing `'datagrid'`. For each unique DataGrid: + +1. A `CROSS JOIN LATERAL jsonb_array_elements(...)` expands the DataGrid JSON array into individual rows +2. Row numbers are tracked with `row_number() OVER()` to generate row identifiers: `{datagrid_name}_r{n}` +3. A separate UNION ALL pair (legacy + current) is generated for each DataGrid + +DataPath format for DataGrid fields: `(DKn)dataGridKey->fieldKey` or `dataGridKey->fieldKey`. + +--- + +## Output Columns + +| Column | Source | Description | +| ------ | ------ | ----------- | +| `submission_id` | `afs."Id"` | UUID of the form submission record | +| `application_id` | `afs."ApplicationId"` | UUID of the parent application | +| `row_identifier` | `'root'` or `'{datagrid}_r{n}'` | `root` for flat fields; `{datagrid_name}_r{n}` for DataGrid rows | +| *(dynamic columns)* | Extracted from JSON | One column per mapped field, typed per mapping | + +--- + +## Path Parsing Logic + +### DataPath format + +- **Root field**: `fieldKey` or `panel->fieldKey` (containers stripped) +- **DataGrid field**: `dataGridName->fieldName` or `(DK1)dataGridName->fieldName` + +The function splits DataPath on `'->'` to build the JSONB navigation path. The final segment uses the `->>` text-extract operator; intermediate segments use `->`. + +### DK prefix handling + +If DataPath begins with `(DK{n})`, the prefix is stripped before extracting the DataGrid name, and the stripped path is used for navigation. The prefix itself is not used for data extraction. + +--- + +## Data Type Mapping + +| Field Type | Normal SQL Type | Type-Conflict SQL Type | +| ---------- | --------------- | ---------------------- | +| `textfield` / `textarea` / `email` / `select` / `phoneNumber` | TEXT | TEXT | +| `number` | NUMERIC | TEXT (as string representation) | +| `currency` | DECIMAL(18,2) | TEXT (as string representation) | +| `option` / `checkbox` | BOOLEAN | TEXT (`'true'` / `'false'`) | + +--- + +## Relationship to `get_worksheet_data` + +Both functions serve the same role (query generation), but differ in data source and schema: + +| Aspect | `get_formversion_data` | `get_worksheet_data` | +| ------ | ---------------------- | -------------------- | +| Data source | `ApplicationFormSubmissions` | `Flex.WorksheetInstances` | +| JSON location | `submission->'submission'->'data'` (dual-schema) | `CurrentValue->'values'` array | +| Schema handling | Dual legacy/current UNION ALL | Single schema | +| Implementation style | Procedural PL/pgSQL | CTE-based SQL | +| Correlation ID | Form Version ID | Form Version ID | +| DataGrid support | Yes | Yes | + +--- + +## Common Use Cases + +### 1. Flat submission with root fields only + +Generates two branches (legacy + current), each returning one row per submission with all field values as flat columns. `row_identifier` = `'root'` for all rows. + +### 2. Submission with DataGrid fields + +Generates a separate UNION ALL pair per DataGrid, expanding each DataGrid array entry into its own row. `row_identifier` = `'{datagrid_name}_r1'`, `'{datagrid_name}_r2'`, etc. + +### 3. Mixed root and DataGrid fields + +One root query pair + one query pair per DataGrid, all combined with `UNION ALL`. Root columns are NULL in DataGrid rows and vice versa. + +### 4. Type conflict (field mapped with different types) + +Affected columns fall back to TEXT for both value and NULL type, allowing the UNION ALL to be well-typed. + +--- + +## Debugging Queries + +```sql +-- Check the mapping configuration +SELECT "Mapping"->'Rows' FROM "Reporting"."ReportColumnsMaps" WHERE "Id" = ''; + +-- Inspect raw submissions for a form version +SELECT "Id", "ApplicationId", "Submission"->'submission' FROM public."ApplicationFormSubmissions" +WHERE "ApplicationFormVersionId" = '' +LIMIT 5; + +-- Detect which schema variant a submission uses +SELECT "Id", + CASE WHEN "Submission"->'submission'->'submission' IS NULL THEN 'legacy' ELSE 'current' END AS schema_version +FROM public."ApplicationFormSubmissions" +WHERE "ApplicationFormVersionId" = ''; + +-- Test function output +SELECT "Reporting".get_formversion_data('', ''); +``` + +--- + +## Version Notes + +This function uses the same column-name generation and type-handling patterns as `get_worksheet_data`. Key differences are the dual-schema UNION ALL approach and the procedural (rather than CTE-based) implementation style. diff --git a/documentation/reporting/get_scoresheet_data_specification.md b/documentation/reporting/get_scoresheet_data_specification.md new file mode 100644 index 0000000000..8e0df49255 --- /dev/null +++ b/documentation/reporting/get_scoresheet_data_specification.md @@ -0,0 +1,230 @@ +# get_scoresheet_data.sql Function Specification + +## Overview + +`get_scoresheet_data` is a PostgreSQL PL/pgSQL function that generates a dynamic SQL query for extracting Unity.Flex scoresheet answer data. It is called by the `generate_scoresheet_view` stored procedure. Unlike the form version and worksheet equivalents, scoresheet field values are read from the normalised `Flex.Answers` table via a correlated subquery per field — not from a JSON blob on the scoresheet instance itself. + +## Function Signature + +```sql +CREATE OR REPLACE FUNCTION "Reporting".get_scoresheet_data( + correlation_id uuid, + report_map_id uuid +) +RETURNS text +LANGUAGE plpgsql +``` + +## Purpose + +- Generates a dynamic SQL SELECT query for scoresheet instance data +- Reads field values from `Flex.Answers` joined to `Flex.Questions` (one correlated subquery per mapped field) +- Always includes a `total_score` column calculated via `calculate_scoresheet_total_score()` +- Handles scoresheet-specific field types (`number`, `yesno`, text variants) +- No DataGrid support — scoresheets are flat (one row per scoresheet instance) +- Returns a complete SQL query string ready for use as a view body + +## Input Parameters + +- **correlation_id** — UUID of the application form (`ApplicationFormId`). Used to filter applications linked to scoresheet instances. +- **report_map_id** — UUID of the `ReportColumnsMaps` record containing the field-to-column mapping configuration. + +## Return Value + +Returns a `TEXT` string containing a complete SQL SELECT query. + +--- + +## Data Sources + +| Table | Schema | Purpose | +| ----- | ------ | ------- | +| `ScoresheetInstances` | `Flex` | One row per assessor's completed scoresheet; CorrelationId → Assessment | +| `Assessments` | *(public)* | Links scoresheet instances to applications via `ApplicationId` | +| `Applications` | *(public)* | Application record; filtered by `ApplicationFormId = correlation_id` | +| `Answers` | `Flex` | Normalised answer rows; one per question per scoresheet instance | +| `Questions` | `Flex` | Question definitions including `Name` used to match answers | + +### Join path + +``` +ScoresheetInstances (si) + JOIN Assessments (a) ON si."CorrelationId" = a."Id" + JOIN Applications (ap) ON a."ApplicationId" = ap."Id" +``` + +Filtered by: `ap."ApplicationFormId" = correlation_id` + +--- + +## Field Value Extraction + +Each mapped field's value is retrieved via a **correlated subquery** embedded directly in the SELECT clause: + +```sql +( + SELECT a_1."CurrentValue"->>'value' + FROM "Flex"."Answers" a_1 + JOIN "Flex"."Questions" q ON a_1."QuestionId" = q."Id" + WHERE a_1."ScoresheetInstanceId" = si."Id" + AND q."Name" = '' + LIMIT 1 +) +``` + +The `` value is resolved using the following priority: + +1. **Key** (`row_data->>'Key'`) — used if non-empty +2. **PropertyName** (`row_data->>'PropertyName'`) — used if Key is empty +3. **DataPath** (`row_data->>'DataPath'`) — used if PropertyName is also empty +4. **ColumnName** — final fallback + +--- + +## Core Processing Logic + +### Pass 1 — Column inventory and type-conflict detection + +Iterates all mapping rows to collect unique column names and their types (normalised to lowercase). Detects type conflicts (same column name with different types) and marks them for TEXT fallback. + +### Pass 2 — NULL column list construction + +Builds a typed NULL placeholder list ordered by column name, using scoresheet-specific types: + +- `number` → `NULL::NUMERIC` +- `yesno` → `NULL::BOOLEAN` +- All other types → `NULL::TEXT` + +### Pass 3 — Query construction + +Initialises a base SELECT with fixed columns, then iterates mapping rows to replace each NULL placeholder with the appropriate correlated subquery + type cast. + +### Final query shape + +```sql +SELECT + si."Id" AS scoresheet_instance_id, + ap."Id" AS application_id, + si."CorrelationId" AS assessment_id, + si."ScoresheetId" AS scoresheet_id, + "Reporting".calculate_scoresheet_total_score(si."Id") AS total_score, + :: AS , + ... +FROM "Flex"."ScoresheetInstances" si +JOIN "Assessments" a ON si."CorrelationId" = a."Id" +JOIN "Applications" ap ON a."ApplicationId" = ap."Id" +WHERE ap."ApplicationFormId" = '' +``` + +--- + +## Fixed Output Columns + +Every generated query includes these columns regardless of the field mapping: + +| Column | Source | Description | +| ------ | ------ | ----------- | +| `scoresheet_instance_id` | `si."Id"` | UUID of the scoresheet instance | +| `application_id` | `ap."Id"` | UUID of the parent application | +| `assessment_id` | `si."CorrelationId"` | UUID of the assessment linking instance to application | +| `scoresheet_id` | `si."ScoresheetId"` | UUID of the scoresheet definition | +| `total_score` | `calculate_scoresheet_total_score(si."Id")` | Computed total score for the instance | + +--- + +## Field Type Handling + +Scoresheet question types differ from Forms.io types. The function normalises all types to lowercase before comparison. + +| Type | SQL Type | Extraction | +| ---- | -------- | ---------- | +| `number` | NUMERIC | Numeric regex validation → cast, or NULL | +| `yesno` | BOOLEAN | `true/t/1/yes` → true, `false/f/0/no` → false, else NULL | +| `text` / `textarea` / `selectlist` / *(any other)* | TEXT | Direct string value | +| *(type conflict fallback)* | TEXT | Cast to TEXT | + +--- + +## No DataGrid Support + +Scoresheets do not have DataGrid (repeating row) components. The function generates a single flat query — one output row per scoresheet instance. There is no `row_identifier` column. + +--- + +## calculate_scoresheet_total_score + +This companion function (`Reporting.calculate_scoresheet_total_score(instance_id)`) is always called as part of the generated query. It iterates all answers for the given instance and applies scoring logic: + +- **Number questions**: uses the numeric answer value directly +- **YesNo questions**: applies the `yes_value` or `no_value` from the question definition +- **SelectList questions**: applies the `numeric_value` from the selected option +- **Text / TextArea questions**: contributes 0 to the total + +--- + +## Data Type Mapping + +| Question Type | Normal SQL Type | Type-Conflict SQL Type | +| ------------- | --------------- | ---------------------- | +| `number` | NUMERIC | TEXT | +| `yesno` | BOOLEAN | TEXT | +| `text` / `textarea` / `selectlist` / others | TEXT | TEXT | + +--- + +## Differences from get_worksheet_data and get_formversion_data + +| Aspect | `get_scoresheet_data` | `get_worksheet_data` | `get_formversion_data` | +| ------ | --------------------- | -------------------- | ---------------------- | +| Data source | `Flex.Answers` (normalised rows) | `Flex.WorksheetInstances` (JSON blob) | `ApplicationFormSubmissions` (JSON blob) | +| Field extraction | Correlated subquery per field | JSONB path expression | JSONB path expression | +| Total score | Always included | Not applicable | Not applicable | +| DataGrid support | No | Yes | Yes | +| Row identifier | Not present | `root` or `{grid}_r{n}` | `root` or `{grid}_r{n}` | +| Types | `number`, `yesno`, text | CHEFS types | CHEFS types | +| Correlation ID | Form ID | Form Version ID | Form Version ID | + +--- + +## Common Use Cases + +### 1. Standard scoresheet with number and text questions + +Generates one row per scoresheet instance with NUMERIC and TEXT columns, plus `total_score`. + +### 2. YesNo questions (boolean) + +`yesno` fields are extracted as BOOLEAN using a simple true/false lookup. + +### 3. SelectList questions + +`selectlist` fields are returned as TEXT (the selected option's label or value). + +--- + +## Debugging Queries + +```sql +-- Check the mapping configuration +SELECT "Mapping"->'Rows' FROM "Reporting"."ReportColumnsMaps" WHERE "Id" = ''; + +-- List scoresheet instances linked to a form +SELECT si."Id", si."ScoresheetId", si."CorrelationId", ap."Id" AS application_id +FROM "Flex"."ScoresheetInstances" si +JOIN "Assessments" a ON si."CorrelationId" = a."Id" +JOIN "Applications" ap ON a."ApplicationId" = ap."Id" +WHERE ap."ApplicationFormId" = '' +LIMIT 10; + +-- Inspect answers for a scoresheet instance +SELECT q."Name", a."CurrentValue"->>'value' AS answer_value +FROM "Flex"."Answers" a +JOIN "Flex"."Questions" q ON a."QuestionId" = q."Id" +WHERE a."ScoresheetInstanceId" = ''; + +-- Test function output +SELECT "Reporting".get_scoresheet_data('', ''); + +-- Test total score calculation +SELECT "Reporting".calculate_scoresheet_total_score(''); +``` diff --git a/documentation/reporting/reporting-architecture.md b/documentation/reporting/reporting-architecture.md index 063a15e266..422ce2f0fa 100644 --- a/documentation/reporting/reporting-architecture.md +++ b/documentation/reporting/reporting-architecture.md @@ -96,8 +96,9 @@ sequenceDiagram Service->>Job: Enqueue GenerateViewBackgroundJob Job->>DB: CALL Reporting.generate_*_view(correlationId) DB-->>Job: View created/updated - Job->>DB: GRANT SELECT ON view TO reporting_role - Job-->>Config: Status → SUCCESS + Job->>DB: Update ViewStatus → SUCCESS + Job->>RoleJob: Enqueue AssignViewRoleBackgroundJob + RoleJob->>DB: GRANT SELECT ON view TO reporting_role ``` **Provider types and data characteristics:** diff --git a/documentation/reporting/reporting-configuration.md b/documentation/reporting/reporting-configuration.md index 846023fb3f..7deb364e05 100644 --- a/documentation/reporting/reporting-configuration.md +++ b/documentation/reporting/reporting-configuration.md @@ -2,13 +2,19 @@ ## Overview -The Reporting Configuration system allows administrators to define how source field data maps to PostgreSQL database columns for generated reporting views. It supports three correlation providers, each sourcing field metadata from a different system: +The Reporting Configuration system allows administrators to define how source field data maps to PostgreSQL database columns for generated reporting views. It supports five correlation providers, each sourcing field metadata from a different system: -| Provider | Source | Correlation ID | Description | -|----------|--------|----------------|-------------| -| `formversion` | CHEFS form submissions | Form Version ID | Immutable form schema fields | -| `worksheet` | Unity.Flex worksheets | Form Version ID | Dynamic worksheet fields | -| `scoresheet` | Unity.Flex scoresheets | Form ID | Evaluation/scoring fields | +| Provider | Source | Correlation ID | Description | +| -------------------------- | ------------------------------------- | --------------- | ---------------------------------------------------------- | +| `formversion` | CHEFS form submissions | Form Version ID | Static field schema for a specific form version | +| `formversion_consolidated` | CHEFS form submissions (all versions) | Form ID | Merged submission fields across all versions of a form | +| `worksheet` | Unity.Flex worksheets | Form Version ID | Dynamic worksheet fields linked to a specific form version | +| `worksheet_consolidated` | Unity.Flex worksheets (all versions) | Form ID | Merged worksheet fields across all versions of a form | +| `scoresheet` | Unity.Flex scoresheets | Form ID | Evaluation/scoring fields linked to a form | + +> **Key distinction:** Per-version providers (`formversion`, `worksheet`) use a **Form Version ID** as the CorrelationId. Consolidated and scoresheet providers (`formversion_consolidated`, `worksheet_consolidated`, `scoresheet`) use the **Form ID**. + +--- ## Architecture @@ -16,7 +22,7 @@ The Reporting Configuration system allows administrators to define how source fi - **`ReportMappingUtils`** (`Unity.Reporting.Application`) — Static utility methods for column name sanitization, validation, uniqueness enforcement, and mapping creation/update logic. - **`ReportMappingService`** (`Unity.Reporting.Application`) — Application service orchestrating CRUD operations, field metadata retrieval, view generation, and provider resolution. -- **`IFieldsProvider`** (`Unity.Reporting.Application`) — Interface for correlation-specific field metadata extraction and change detection. +- **`IFieldsProvider`** (`Unity.Reporting.Application`) — Interface for correlation-specific field metadata extraction and change detection. Each provider registers itself by its `CorrelationProvider` string identifier. - **`ReportingConfigurationController`** (`Unity.Reporting.Web`) — MVC controller providing AJAX API endpoints for the UI. - **`Default.js`** (`Unity.Reporting.Web`) — Client-side DataTable configuration, validation, and provider-aware UI logic. @@ -24,9 +30,36 @@ The Reporting Configuration system allows administrators to define how source fi Each provider implements `IFieldsProvider`: -- **`FormVersionFieldsProvider`** — Retrieves field metadata from immutable CHEFS form version schemas. Change detection always returns null since form versions are immutable. -- **`WorksheetFieldsProvider`** — Retrieves field metadata from Unity.Flex worksheet definitions. Supports change detection for dynamic schema evolution. -- **`ScoresheetFieldsProvider`** — Retrieves field metadata from Unity.Flex scoresheet definitions. Supports change detection for scoring structure changes. +- **`FormVersionFieldsProvider`** — Retrieves field metadata from immutable CHEFS form version schemas via `IFormMetadataService`. Change detection always returns null since form versions cannot change after creation. +- **`ConsolidatedFormVersionFieldsProvider`** — Retrieves and merges submission field metadata across **all versions** of a form (CorrelationId = Form ID). Change detection tracks added/removed form versions. +- **`WorksheetFieldsProvider`** — Retrieves field metadata from Unity.Flex worksheet definitions linked to a specific form version. Stamps duplicate DataPaths within a version with `(DK1)`, `(DK2)`, etc. to ensure uniqueness before returning. Change detection tracks worksheet link additions/removals. +- **`ConsolidatedWorksheetFieldsProvider`** — Retrieves and merges worksheet field metadata across **all versions** of a form (CorrelationId = Form ID). Applies the same DataPath uniqueification per version before merging. Change detection tracks added/removed form versions and worksheets within versions. +- **`ScoresheetFieldsProvider`** — Retrieves field metadata from the Unity.Flex scoresheet linked to a form via `IApplicationFormAppService`. CorrelationId is the Form ID. Change detection compares the stored scoresheet ID against the form's current scoresheet. + +--- + +## Consolidated Providers + +The `formversion_consolidated` and `worksheet_consolidated` providers create a single unified view that spans all versions of a form. Because the CorrelationId is the Form ID (not a version ID), one mapping configuration covers the entire form history. + +### Field Merging Logic + +When multiple form versions each contribute fields, the provider merges them using the following rules based on (Label, Path, Type) matching: + +| Scenario | Outcome | VersionLabel | +| ----------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------- | +| Field has identical (Label, Path, Type) across **all** versions | Single merged row | None (field is universal across all versions) | +| Field has identical (Label, Path, Type) in **some** versions only | Single row for that exact match | Comma-joined list of versions where it appears (e.g., `v1, v3`) | +| Same (Label, Path) but **different Type** across versions | One row **per distinct type** | Comma-joined list of versions carrying that specific type | + +The `VersionLabel` property on a field row is displayed in the mapping UI to help administrators understand which form versions contain that field. Fields with no VersionLabel are consistent across all versions. + +### Change Detection for Consolidated Providers + +- **`formversion_consolidated`** — A form version is added or removed. Tracked via `formversion_{versionId}` metadata keys stored in the mapping at save time. +- **`worksheet_consolidated`** — A form version is added/removed, or a worksheet is linked/unlinked within a version. Tracked via both `formversion_{versionId}` and `ws_{versionId}_{worksheetId}` metadata keys. + +--- ## Default Column Name Generation @@ -36,24 +69,27 @@ When a user creates or saves a report configuration for the first time (or when The source used for generating default column names differs by provider: -| Provider | Default Column Name Source | Rationale | -|----------|---------------------------|-----------| -| `formversion` | **Key** (CHEFS Property Name, e.g., `firstName`) | CHEFS property names are stable, developer-defined identifiers that produce clean, predictable column names (e.g., `firstname`). Labels can be verbose or contain special characters. | -| `worksheet` | **Label** (e.g., `First Name`) | Worksheet labels are human-readable names configured by administrators, producing descriptive column names (e.g., `first_name`). | -| `scoresheet` | **Label** (e.g., `Score One`) | Scoresheet labels provide meaningful, user-facing descriptions that map well to reporting column names. | +- **`formversion`** — uses the field **Key** (CHEFS Property Name, e.g., `firstName`). CHEFS property names are stable, developer-defined identifiers that produce clean, predictable column names (e.g., `firstname`). Labels can be verbose or contain special characters. +- **`formversion_consolidated`** — uses the field **Label**. Consolidated views span multiple versions; labels are the primary human-readable identifiers across versions. +- **`worksheet`** — uses the field **Label**. Worksheet labels are human-readable names configured by administrators, producing descriptive column names (e.g., `first_name`). +- **`worksheet_consolidated`** — uses the field **Label**. Same rationale as `worksheet`. +- **`scoresheet`** — uses the field **Label**. Scoresheet labels provide meaningful, user-facing descriptions that map well to reporting column names. + +> **Note:** Only the `formversion` provider uses the field Key as its default source. All other providers use the field Label. The `GetDefaultColumnNameSource()` method checks for an exact case-insensitive match against the string `"formversion"` — the consolidated variant `"formversion_consolidated"` does **not** match and therefore uses Label. ### Column Name Sanitization All auto-generated column names go through the same sanitization pipeline regardless of provider: -1. Convert to lowercase -2. Replace spaces and hyphens with underscores -3. Remove all non-alphanumeric characters (except underscores) -4. Remove consecutive underscores -5. Trim leading/trailing underscores -6. Prefix with `col_` if name starts with a digit -7. Truncate to 60 characters maximum -8. Ensure uniqueness by appending numeric suffixes (`_1`, `_2`, etc.) when collisions occur +1. Replace spaces and hyphens with underscores +2. Remove all characters that are not letters, digits, or underscores +3. Remove consecutive underscores (collapse `__` → `_`) +4. Trim leading/trailing underscores +5. Substitute `"col"` if nothing remains after trimming +6. Prefix with `col_` if the name starts with a digit +7. Truncate to 60 characters; trim any trailing underscore introduced by truncation +8. Convert to lowercase +9. Ensure uniqueness by appending numeric suffixes (`_1`, `_2`, etc.) when collisions occur — suffix length is accounted for during truncation to stay within the 60-character limit ### Implementation Details @@ -62,42 +98,152 @@ The column name source selection is implemented identically on server and client - **Server-side** — `ReportMappingUtils.GetDefaultColumnNameSource()` returns `field.Key ?? ""` for `formversion`, or `field.Label ?? ""` for all other providers. Used by `CreateNewMap()` and `UpdateExistingMap()` when auto-generating column names for unmapped or newly discovered fields. The provider comparison is case-insensitive via `StringComparison.OrdinalIgnoreCase`. - **Client-side** — `getDefaultColumnNameSource()` in `Default.js` uses the same logic: `field.key || ''` when `currentProvider === 'formversion'`, otherwise `field.label || ''`. This is used by `transformFieldsMetadata()` to set initial column name values in the DataTable when no saved configuration exists. -> **Important:** Neither implementation falls back from Key to Label or vice versa. If the source value is null/empty, an empty string is used, and the downstream sanitization produces `"col_1"` as a placeholder. This ensures client and server always produce identical defaults. +> **Important:** Neither implementation falls back from Key to Label or vice versa. If the source value is null/empty, an empty string is used, and the downstream sanitization produces `"col_1"` as a placeholder (null/whitespace input returns `"col_1"` directly). This ensures client and server always produce identical defaults. + +### Column Name Priority During Updates + +When saving an updated configuration, column names are resolved using a three-tier priority system: + +1. **User-provided** — a column name explicitly set by the administrator in the current save operation +2. **Existing** — the column name already persisted in the database for that field path +3. **Auto-generated** — a new sanitized name derived from the field Key or Label, generated only for fields not covered by tiers 1 or 2 + +This ensures established mappings are preserved and only genuinely new fields receive auto-generated names. ### Impact on Existing Configurations - **Existing saved configurations are not affected.** Column names are persisted in the database; the provider-specific logic only determines defaults for new/unsaved fields. -- **Existing column names are preserved during updates.** The three-tier priority system (user-provided → existing → auto-generated) ensures established mappings are maintained. +- **Existing column names are preserved during updates.** The three-tier priority system ensures established mappings are maintained. + +--- + +## Field Column Definitions + +The configuration table displays one row per mappable field from the source. The first three column headers change depending on the active provider tab; the remaining three are constant across all providers. + +**Provider-specific headers (columns 1–3):** + +- `formversion` / `formversion_consolidated` — **CHEFS Label**, **CHEFS Property Name**, **CHEFS Type** +- `worksheet` / `worksheet_consolidated` — **Worksheet Label**, **Worksheet Property Name**, **Worksheet Type** +- `scoresheet` — **Scoresheet Label**, **Scoresheet Property Name**, **Scoresheet Type** + +**Constant headers (columns 4–6):** **Path**, **Report Column**, **Type Path** + +### Label *(CHEFS Label / Worksheet Label / Scoresheet Label)* + +The human-readable display name as it appears in the source form or worksheet UI. For checkbox group options this is the individual option's label (e.g., `Urban`, `Rural`). Used as the default column name source for all providers except `formversion`. + +### Property Name *(CHEFS Property Name / Worksheet Property Name / Scoresheet Property Name)* + +The developer-assigned key from the source schema (e.g., `firstName`, `projectBudget`). Stable and unique within a form component — it does not change when the label is edited. For checkbox group options the property name is `{parentKey}-{optionValue}` (e.g., `sector-urban`). Used as the default column name source for the `formversion` provider. + +### Type *(CHEFS Type / Worksheet Type / Scoresheet Type)* + +The component type from Forms.io or Unity.Flex that determines how the raw data is stored and how the view generation SQL extracts it. Common values: + +| Type | Description | +| ---- | ----------- | +| `textfield` / `textarea` | Free-text input — extracted as TEXT | +| `number` | Numeric input — extracted as NUMERIC | +| `currency` | Currency input — extracted as DECIMAL(18,2), locale commas stripped | +| `datetime` | Date/time input — extracted as TIMESTAMP | +| `radio` | Single-select radio group — extracted as TEXT (the selected value) | +| `simplecheckboxes` | Multi-select checkbox group — each option becomes a separate row with type `option` | +| `checkbox` | Single boolean checkbox — extracted as BOOLEAN | +| `option` | An individual option within a checkbox group | +| `datagrid` | Repeating data grid — each row becomes a separate view row with a row identifier | + +### Path *(UI column — displays DataPath)* + +> **Important naming note:** The UI column labelled **"Path"** actually displays the `DataPath` field from the data model — not the internal `path` field. The internal `path` is stored in the mapping for row-identity purposes but is not shown as its own column. + +The **Path** column shows the data-centric navigation path used by the PostgreSQL view generation SQL function to extract values from the JSON. Container segments that do not exist in the actual submitted JSON (panels, tabs, columns, fieldsets) are stripped from this path: + +| Internal `path` (not displayed) | Internal `typePath` | Displayed "Path" (DataPath) | Why | +| -------------------------------- | ------------------- | --------------------------- | --- | +| `panel1->firstName` | `panel->textfield` | `firstName` | `panel` is a container — stripped | +| `datagrid1->itemName` | `datagrid->textfield` | `datagrid1->itemName` | `datagrid` is kept — it IS in the JSON | +| `checkboxField->optionA` | `simplecheckboxes->option` | `checkboxField->optionA` | Both segments present in data | + +The internal `path` (the full key-based breadcrumb including container ancestors) is stored in the mapping and used as the stable identity key when matching rows across saves and updates. If two fields resolve to the same key chain, duplicates are prefixed with `(DK1)`, `(DK2)`, etc. on both `path` and `dataPath`. + +### Report Column + +The user-editable PostgreSQL column name this field will occupy in the generated database view. Auto-generated from the field Property Name or Label (depending on provider) using the sanitization pipeline. Must be unique within the configuration and conform to PostgreSQL identifier rules (see [Column Name Validation](#column-name-validation)). + +### Type Path + +The same schema hierarchy as the internal `path` but using component types instead of keys (e.g., `panel->textfield`, `datagrid->textarea`). Not user-editable. Used internally by the view generation SQL function to determine how to navigate the JSON structure — for example, a `datagrid` segment signals that `jsonb_array_elements` is needed. Also used to identify which segments are containers when computing the DataPath. + +### Version Label *(consolidated providers only)* + +Indicates which form versions contain this field. Only present when using `formversion_consolidated` or `worksheet_consolidated`: + +- Blank — field is present in all versions with the same type; merged into a single column +- `v1` — field exists only in version 1 +- `v1, v3` — field exists in versions 1 and 3 but not in all versions + +--- ## Column Name Validation Both client-side and server-side enforce PostgreSQL column name rules: - Maximum 60 characters -- Must start with a letter or underscore +- Must start with a letter or underscore (not a digit) - Contains only letters, digits, and underscores -- Cannot be a PostgreSQL reserved word +- Cannot be a PostgreSQL reserved word (enforced against a comprehensive built-in list) - Must be unique within the configuration +--- + ## View Generation After saving a configuration, users can generate a PostgreSQL database view: 1. User provides a view name (validated for availability and PostgreSQL compliance) -2. A background job is queued to create/update the view in the `Reporting` schema -3. View status is tracked and displayed via a widget +2. The service normalises the view name to lowercase and checks availability for the given correlation +3. The mapping's `ViewStatus` is set to `GENERATING` and persisted +4. A `GenerateViewBackgroundJob` is queued to perform the actual view creation asynchronously +5. The background job runs within the correct tenant context; if the view name has changed, the old view is deleted first +6. On success, `ViewStatus` is updated to `SUCCESS` and a second job (`AssignViewRoleBackgroundJob`) is automatically queued to grant `SELECT` on the new view to the tenant's configured reporting role +7. On failure, `ViewStatus` is set to `FAILED` and the error is logged +8. View status is polled and displayed to the administrator via a widget View names follow similar sanitization rules with a 63-character maximum (PostgreSQL identifier limit). +### View Name Availability + +Two availability checks exist: + +- **Global check** (`IsViewNameAvailableAsync(viewName)`) — Returns true if no view with that name exists in the Reporting schema. Used for name suggestions before a configuration is saved. +- **Correlation-aware check** (`IsViewNameAvailableAsync(viewName, correlationId, correlationProvider)`) — Returns true if no view with that name exists, **or** if the existing view belongs to the same correlation. This allows regenerating a view under the same name without a false conflict error. + +--- + +## Change Detection + +When an existing configuration is loaded, the provider's `DetectChangesAsync` method compares the current state of the source data against what was stored in the mapping's metadata at the time of the last save. Detected changes are surfaced as a human-readable description string in `ReportColumnsMapDto.DetectedChanges`, which the UI displays as an alert to the administrator. + +- **`formversion`** — No change detection. Form versions are immutable by design; always returns null. +- **`formversion_consolidated`** — Compares stored `formversion_{versionId}` metadata keys against the current list of form versions with fields. +- **`worksheet`** — Compares stored `worksheet_{worksheetId}` metadata keys against the current list of worksheet links for the form version. +- **`worksheet_consolidated`** — Compares stored `formversion_{versionId}` and `ws_{versionId}_{worksheetId}` metadata keys against current worksheet links across all versions. +- **`scoresheet`** — Compares stored `scoresheet_{scoresheetId}` metadata key against the form's current scoresheet assignment. + +--- + ## Configuration Lifecycle -``` +```text Fields Metadata → [Save] → Configuration → [Generate View] → Database View - ↓ - [Delete] → Removes configuration and optionally the view + ↓ ↓ + [Delete] [Assign Role Job] + Removes config Grants SELECT to + and drops the view reporting role ``` -1. **Initial Load**: Field metadata is fetched from the appropriate provider; default column names are generated. -2. **Save**: Creates or updates the mapping configuration with user-specified and auto-generated column names. -3. **Generate View**: Creates a PostgreSQL view in the `Reporting` schema based on the saved mapping. -4. **Delete**: Removes the configuration and optionally drops the associated database view. +1. **Initial Load** — Field metadata is fetched from the appropriate provider; default column names are generated client-side for display. +2. **Save** — Creates or updates the mapping configuration in the database with user-specified and auto-generated column names. +3. **Generate View** — Queues a background job that creates or replaces a PostgreSQL view in the `Reporting` schema based on the saved mapping. Role assignment is queued automatically on success. +4. **Delete** — Removes the mapping configuration record and drops the associated database view if it exists. View deletion failure is logged but does not prevent the mapping from being removed. From 6ddc76e41474979ace9b561b995d4627df4a172f Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 8 May 2026 09:21:38 -0700 Subject: [PATCH 022/385] AB#33266 prevent empty AI generation requests --- .../IAIGenerationPrerequisiteValidator.cs | 13 +++ .../AIGenerationPrerequisiteValidator.cs | 53 +++++++++ .../Operations/ApplicationAnalysisService.cs | 3 + .../Operations/ApplicationScoringService.cs | 3 + .../AI/Operations/AttachmentSummaryService.cs | 9 ++ .../ApplicationAIGenerationQueue.cs | 67 +++++++++++ .../AttachmentSummaryServiceTests.cs | 28 +++++ .../Automation/AIGenerationQueueTests.cs | 110 +++++++++++++++++- 8 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs new file mode 100644 index 0000000000..67df324548 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAIGenerationPrerequisiteValidator +{ + Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId); + + Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId); + + Task EnsureApplicationScoringAvailableAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs new file mode 100644 index 0000000000..acbff32f35 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; +using Unity.GrantManager.Applications; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Operations; + +public class AIGenerationPrerequisiteValidator( + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository) : IAIGenerationPrerequisiteValidator, ITransientDependency +{ + public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) + { + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + if (attachments.Count == 0) + { + throw new UserFriendlyException("No attachments are available to summarize."); + } + } + + public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) + { + await applicationRepository.GetAsync(applicationId); + + var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) + { + throw new UserFriendlyException("AI application analysis requires application submission data."); + } + } + + public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm.ScoresheetId == null) + { + throw new UserFriendlyException("AI scoring requires a configured scoresheet."); + } + + var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) + { + throw new UserFriendlyException("AI scoring requires a scoresheet with scoring fields."); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs index 12b9b24342..aabdf61e82 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs @@ -21,6 +21,7 @@ public class ApplicationAnalysisService( IApplicationFormVersionRepository applicationFormVersionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IAIService aiService, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, ILogger logger) : IApplicationAnalysisService, ITransientDependency { private const string ComponentsKey = "components"; @@ -31,6 +32,8 @@ public class ApplicationAnalysisService( public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { + await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId); + var application = await applicationRepository.GetAsync(applicationId); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index f6188f689b..2b28c2c920 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -23,11 +23,14 @@ public class ApplicationScoringService( IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, IAIService aiService, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, AIExecutionModeResolver executionModeResolver, ILogger logger) : IApplicationScoringService, ITransientDependency { public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { + await aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId); + var application = await applicationRepository.GetAsync(applicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); if (applicationForm.ScoresheetId == null) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 78762cea48..8be02f1603 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -8,6 +8,7 @@ using Unity.AI.Requests; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; +using Volo.Abp; using Volo.Abp.DependencyInjection; namespace Unity.AI.Operations; @@ -17,6 +18,7 @@ public class AttachmentSummaryService( IChefsFileAttachmentStreamProvider chefsFileAttachmentStreamProvider, ITextExtractionService textExtractionService, IAIService aiService, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, AIExecutionModeResolver executionModeResolver, ILogger logger) : IAttachmentSummaryService, ITransientDependency { @@ -47,6 +49,11 @@ public async Task GenerateAndSaveAsync(Guid attachmentId, string? prompt public async Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default) { var ids = attachmentIds as IReadOnlyCollection ?? attachmentIds.ToList(); + if (ids.Count == 0) + { + throw new UserFriendlyException("Select at least one attachment to generate summaries."); + } + var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); if (mode != AIExecutionMode.Sequential) { @@ -103,6 +110,8 @@ public async Task> GenerateForApplicationAsync( IReadOnlyCollection? attachmentIds = null, CancellationToken cancellationToken = default) { + await aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId); + var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) .Select(a => a.Id) .ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 6eec9bc4aa..46f348cf47 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -3,14 +3,18 @@ using System.Linq; using System.Threading.Tasks; using Unity.AI.Automation; +using Unity.AI.Features; +using Unity.AI.Operations; using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Medallion.Threading; using Microsoft.Extensions.Logging; +using Volo.Abp; using Volo.Abp.Domain.Repositories; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; using Volo.Abp.Users; namespace Unity.GrantManager.GrantApplications.Automation; @@ -19,6 +23,8 @@ public class ApplicationAIGenerationQueue( IBackgroundJobManager backgroundJobManager, IRepository generationRequestRepository, IDistributedLockProvider distributedLockProvider, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, + IFeatureChecker featureChecker, IAIRateLimiter aiRateLimiter, ICurrentUser currentUser, ILogger logger) @@ -32,6 +38,7 @@ await EnsureRequestAndEnqueueAsync( tenantId, AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, applicationId, + () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId), () => { return backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs @@ -54,6 +61,7 @@ await EnsureRequestAndEnqueueAsync( tenantId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, applicationId, + () => aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId), () => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationAnalysisBackgroundJobArgs @@ -75,6 +83,7 @@ await EnsureRequestAndEnqueueAsync( tenantId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType, applicationId, + () => aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId), () => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationScoringBackgroundJobArgs @@ -96,6 +105,7 @@ await EnsureRequestAndEnqueueAsync( tenantId, AIGenerationRequestKeyHelper.PipelineOperationType, applicationId, + () => EnsureAnyPipelineStageAvailableAsync(applicationId), () => { return backgroundJobManager.EnqueueAsync(new RunApplicationAIPipelineJobArgs @@ -114,6 +124,7 @@ private async Task EnsureRequestAndEnqueueAsync( Guid? tenantId, string operationType, Guid? applicationId, + Func validateInput, Func enqueue) { var requestLock = distributedLockProvider.CreateLock($"ai-generation:{requestKey}"); @@ -135,6 +146,8 @@ private async Task EnsureRequestAndEnqueueAsync( return; } + await validateInput(); + // Single chokepoint for all AI generate flows (manual + auto). // The limiter is a no-op for system/background callers without an authenticated user. await aiRateLimiter.EnsureAsync(); @@ -160,6 +173,60 @@ private async Task EnsureRequestAndEnqueueAsync( } } + private async Task EnsureAnyPipelineStageAvailableAsync(Guid applicationId) + { + var hasEnabledStage = false; + var hasAvailableStage = false; + UserFriendlyException? lastPrerequisiteException = null; + + async Task CheckStageAsync(bool isEnabled, Func ensurePrerequisite) + { + if (!isEnabled) + { + return; + } + + hasEnabledStage = true; + + try + { + await ensurePrerequisite(); + hasAvailableStage = true; + } + catch (UserFriendlyException ex) + { + lastPrerequisiteException = ex; + } + } + + await CheckStageAsync( + await featureChecker.IsEnabledAsync(AIFeatures.AttachmentSummaries), + () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId)); + await CheckStageAsync( + await featureChecker.IsEnabledAsync(AIFeatures.ApplicationAnalysis), + () => aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId)); + await CheckStageAsync( + await featureChecker.IsEnabledAsync(AIFeatures.Scoring), + () => aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId)); + + if (hasAvailableStage) + { + return; + } + + if (lastPrerequisiteException != null) + { + throw lastPrerequisiteException; + } + + if (!hasEnabledStage) + { + throw new UserFriendlyException("AI generation is not enabled."); + } + + throw new UserFriendlyException("No AI generation stages have the required input to run."); + } + private async Task MarkFailedBestEffortAsync(AIGenerationRequest request, Exception exception) { try diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index 0a5c618e2a..894458d34a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -13,6 +13,7 @@ using Unity.AI.Responses; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; +using Volo.Abp; using Xunit; namespace Unity.GrantManager.AI.Operations; @@ -51,11 +52,14 @@ public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request)) .Returns(new AttachmentSummaryResponse { Summary = "summary text" }); + var prerequisiteValidator = Substitute.For(); + var service = new AttachmentSummaryService( attachmentRepository, streamProvider, textExtractionService, aiService, + prerequisiteValidator, new AIExecutionModeResolver(new ConfigurationBuilder().Build()), NullLogger.Instance); @@ -103,10 +107,34 @@ public async Task GenerateAndSaveAsync_Should_Propagate_Cancellation() streamProvider, Substitute.For(), Substitute.For(), + Substitute.For(), new AIExecutionModeResolver(new ConfigurationBuilder().Build()), NullLogger.Instance); await Should.ThrowAsync(() => service.GenerateAndSaveAsync(attachmentId, "v1", cancellationTokenSource.Token)); } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Reject_Empty_Attachment_List() + { + var attachmentRepository = Substitute.For(); + var streamProvider = Substitute.For(); + var textExtractionService = Substitute.For(); + var aiService = Substitute.For(); + var prerequisiteValidator = Substitute.For(); + + var service = new AttachmentSummaryService( + attachmentRepository, + streamProvider, + textExtractionService, + aiService, + prerequisiteValidator, + new AIExecutionModeResolver(new ConfigurationBuilder().Build()), + NullLogger.Instance); + + await Should.ThrowAsync(() => service.GenerateAndSaveAsync([], "v1")); + + await aiService.DidNotReceive().GenerateAttachmentSummaryAsync(Arg.Any()); + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index f499604bc3..71c1efd884 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -7,13 +7,17 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Unity.AI.Features; using Unity.AI.RateLimit; +using Unity.AI.Operations; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Volo.Abp.BackgroundJobs; using Volo.Abp.Domain.Repositories; using Volo.Abp.DistributedLocking; +using Volo.Abp; +using Volo.Abp.Features; using Volo.Abp.Users; using Xunit; using Xunit.Abstractions; @@ -63,6 +67,67 @@ await repository.Received(1).InsertAsync(Arg.Is(r => r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueAllAIStagesAsync_Should_Enqueue_When_Any_Enabled_Stage_Has_Required_Input() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + var backgroundJobManager = Substitute.For(); + var prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); + prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId).Returns(Task.CompletedTask); + prerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("AI scoring requires a configured scoresheet.")); + + var queue = CreateQueue( + backgroundJobManager, + repository, + prerequisiteValidator: prerequisiteValidator); + + await queue.QueueAllAIStagesAsync(applicationId, tenantId); + + await repository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.Received(1).EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task QueueAllAIStagesAsync_Should_Not_Insert_Or_Enqueue_When_No_Enabled_Stage_Has_Required_Input() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + var prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); + prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("AI application analysis requires application submission data.")); + prerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("AI scoring requires a configured scoresheet.")); + + var queue = CreateQueue( + backgroundJobManager, + repository, + rateLimiter, + prerequisiteValidator); + + await Should.ThrowAsync(() => queue.QueueAllAIStagesAsync(applicationId, tenantId)); + + await rateLimiter.DidNotReceive().EnsureAsync(); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() { @@ -175,6 +240,29 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Insert_Or_Enqueue_Whe await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueAttachmentSummaryAsync_Should_Not_Insert_Or_Enqueue_When_No_Attachments_Are_Available() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + var prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) + .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); + var queue = CreateQueue(backgroundJobManager, repository, rateLimiter, prerequisiteValidator); + + await Should.ThrowAsync(() => queue.QueueAttachmentSummaryAsync(applicationId, tenantId)); + + await rateLimiter.DidNotReceive().EnsureAsync(); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task QueueAttachmentSummaryAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() { @@ -349,7 +437,9 @@ public ValueTask DisposeAsync() private static ApplicationAIGenerationQueue CreateQueue( IBackgroundJobManager backgroundJobManager, IRepository repository, - Unity.AI.RateLimit.IAIRateLimiter? rateLimiter = null) + Unity.AI.RateLimit.IAIRateLimiter? rateLimiter = null, + IAIGenerationPrerequisiteValidator? prerequisiteValidator = null, + IFeatureChecker? featureChecker = null) { if (rateLimiter == null) { @@ -357,10 +447,28 @@ private static ApplicationAIGenerationQueue CreateQueue( rateLimiter.EnsureAsync().Returns(Task.CompletedTask); } + if (prerequisiteValidator == null) + { + prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + prerequisiteValidator.EnsureApplicationScoringAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + } + + if (featureChecker == null) + { + featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(AIFeatures.AttachmentSummaries).Returns(Task.FromResult(true)); + featureChecker.IsEnabledAsync(AIFeatures.ApplicationAnalysis).Returns(Task.FromResult(true)); + featureChecker.IsEnabledAsync(AIFeatures.Scoring).Returns(Task.FromResult(true)); + } + return new ApplicationAIGenerationQueue( backgroundJobManager, repository, new TestDistributedLockProvider(), + prerequisiteValidator, + featureChecker, rateLimiter, CreateCurrentUser(), Substitute.For>()); From 31a667d8db859e1aff61a9302322fe8568a05d84 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 8 May 2026 09:26:58 -0700 Subject: [PATCH 023/385] AB#33267 structure empty AI attachment context --- .../Versions/v1/application-analysis.system.txt | 4 ++-- .../Versions/v1/application-scoring.system.txt | 4 ++-- .../AI/Prompts/Versions/v1/common.rules.txt | 1 + .../AI/Runtime/OpenAIRuntimeService.cs | 2 +- .../DataSeed/AIPromptDataSeeder.cs | 5 +++-- .../AI/Runtime/OpenAIPromptRendererTests.cs | 16 ++++++++++++++++ 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt index cfd2fe2d47..b05196b8ab 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.system.txt @@ -3,7 +3,7 @@ You are a careful grant review assistant for human reviewers. Do not fill gaps, TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: -1. Review the application and attachments for the strongest reviewer-relevant evidence. +1. Review the application and any provided attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. -4. Return only the strongest evidence-backed reviewer conclusions. \ No newline at end of file +4. Return only the strongest evidence-backed reviewer conclusions. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt index 297a9f351f..aefebf06d5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-scoring.system.txt @@ -5,7 +5,7 @@ TASK Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 1. Review each question in SECTION one at a time. 2. Identify the exact condition the question asks about. -3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. +3. Consider only the most relevant evidence in DATA and any provided ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. -6. Repeat for every question in SECTION. \ No newline at end of file +6. Repeat for every question in SECTION. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt index ada24e7e87..790f34fb73 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/common.rules.txt @@ -1,4 +1,5 @@ - Any narrative text response must be at least 12 words. +- If ATTACHMENTS is empty, use DATA only and do not mention missing attachments unless their absence is material to the specific conclusion or question. - Return values exactly as specified in OUTPUT. - Do not return keys outside OUTPUT. - Return valid JSON only. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index b61fed77c1..869bc67acf 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -198,7 +198,7 @@ public async Task GenerateApplicationScoringAsync(Ap { var attachments = attachmentSummaries.Count > 0 ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) - : "No attachments provided."; + : "[]"; var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 58859a5e83..6ee4261528 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -278,7 +278,7 @@ OPTIONAL FIELDS (may be left blank): TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: - 1. Review the application and attachments for the strongest reviewer-relevant evidence. + 1. Review the application and any provided attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. 4. Return only the strongest evidence-backed reviewer conclusions. @@ -520,7 +520,7 @@ 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidenc Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 1. Review each question in SECTION one at a time. 2. Identify the exact condition the question asks about. - 3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. + 3. Consider only the most relevant evidence in DATA and any provided ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. 6. Repeat for every question in SECTION. @@ -583,6 +583,7 @@ 4. Choose the most conservative valid answer supported by that evidence. // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. + - If ATTACHMENTS is empty, use DATA only and do not mention missing attachments unless their absence is material to the specific conclusion or question. - Return values exactly as specified in OUTPUT. - Do not return keys outside OUTPUT. - Return valid JSON only. diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs index 7d7258808b..cc2c48823d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs @@ -26,4 +26,20 @@ public void ResolvePromptVersion_Should_Throw_When_Version_Is_Missing_Or_Unsuppo { Should.Throw(() => OpenAIPromptRenderer.ResolvePromptVersion(version)); } + + [Fact] + public void BuildApplicationScoringUserPrompt_Should_Render_Structured_Empty_Attachments() + { + var prompt = OpenAIPromptRenderer.BuildApplicationScoringUserPrompt( + "v1", + "{}", + "[]", + "{\"name\":\"Test\",\"questions\":[]}", + "{}"); + + prompt.ShouldContain("ATTACHMENTS"); + prompt.ShouldContain("[]"); + prompt.ShouldContain("If ATTACHMENTS is empty, use DATA only"); + prompt.ShouldNotContain("No attachments provided."); + } } From c9955ee3924e1c180434d3290075b7187c4dc4d0 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 2 Jun 2026 17:04:58 -0700 Subject: [PATCH 024/385] AB#33266 skip unavailable AI pipeline stages --- .../RunApplicationAIPipelineJob.cs | 36 +++++++++++- .../RunApplicationAIPipelineJobTests.cs | 55 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index 269d08aae6..a9182d3ab6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Operations; using Unity.AI.RateLimit; using Unity.GrantManager.Applications; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.Events; +using Volo.Abp; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -23,6 +25,7 @@ public class RunApplicationAIPipelineJob( IAttachmentSummaryAppService attachmentSummaryAppService, IApplicationAnalysisAppService applicationAnalysisAppService, IApplicationScoringAppService applicationScoringAppService, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, IFeatureChecker featureChecker, ILocalEventBus localEventBus, IRepository generationRequestRepository, @@ -58,7 +61,10 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) logger.LogInformation("Executing queued AI content pipeline for application {ApplicationId}.", args.ApplicationId); - if (attachmentSummariesEnabled) + if (attachmentSummariesEnabled && await HasStageInputAsync( + args.ApplicationId, + "attachment summaries", + () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(args.ApplicationId))) { var attachmentIds = await GetAttachmentIdsAsync(args.ApplicationId); var attachmentResults = await attachmentSummaryAppService.GenerateAttachmentSummariesForPipelineAsync(attachmentIds, args.PromptVersion); @@ -68,7 +74,10 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) Exception? analysisException = null; Exception? scoringException = null; - if (applicationAnalysisEnabled) + if (applicationAnalysisEnabled && await HasStageInputAsync( + args.ApplicationId, + "application analysis", + () => aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(args.ApplicationId))) { try { @@ -85,7 +94,10 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) } } - if (scoringEnabled) + if (scoringEnabled && await HasStageInputAsync( + args.ApplicationId, + "application scoring", + () => aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(args.ApplicationId))) { try { @@ -126,6 +138,24 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent } } + private async Task HasStageInputAsync(Guid applicationId, string stageName, Func ensurePrerequisite) + { + try + { + await ensurePrerequisite(); + return true; + } + catch (UserFriendlyException ex) + { + logger.LogInformation( + ex, + "Skipping AI {StageName} stage for application {ApplicationId} because required input is unavailable.", + stageName, + applicationId); + return false; + } + } + private async Task> GetAttachmentIdsAsync(Guid applicationId) { var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs index bc24754ba3..a8eee00532 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/RunApplicationAIPipelineJobTests.cs @@ -6,10 +6,12 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Unity.AI.Operations; using Unity.GrantManager.Applications; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Unity.AI.RateLimit; +using Volo.Abp; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; using Volo.Abp.Features; @@ -87,6 +89,49 @@ await localEventBus.Received(1).PublishAsync( Arg.Is(x => x.ApplicationId == request.ApplicationId)); } + [Fact] + public async Task ExecuteAsync_Should_Skip_Unavailable_Stage_And_Run_Available_Stage() + { + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(true); + featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(true); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false); + + var repository = BuildRequestRepository(out var requests); + var request = CreateRequest(); + requests.Add(request); + + var prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(request.ApplicationId!.Value) + .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); + prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(request.ApplicationId.Value) + .Returns(Task.CompletedTask); + + var attachmentSummaryAppService = Substitute.For(); + var applicationAnalysisAppService = Substitute.For(); + applicationAnalysisAppService.GenerateApplicationAnalysisForPipelineAsync(Arg.Any(), Arg.Any()) + .Returns(new ApplicationAnalysisResultDto { Completed = true }); + + var job = BuildJob( + featureChecker, + repository, + attachmentSummaryAppService: attachmentSummaryAppService, + applicationAnalysisAppService: applicationAnalysisAppService, + prerequisiteValidator: prerequisiteValidator); + + await job.ExecuteAsync(new RunApplicationAIPipelineJobArgs + { + ApplicationId = request.ApplicationId.Value, + RequestKey = request.RequestKey, + RequestedByUserId = Guid.NewGuid(), + TenantId = request.TenantId + }); + + request.Status.ShouldBe(AIGenerationRequestStatus.Completed); + await attachmentSummaryAppService.DidNotReceive().GenerateAttachmentSummariesForPipelineAsync(Arg.Any>(), Arg.Any()); + await applicationAnalysisAppService.Received(1).GenerateApplicationAnalysisForPipelineAsync(request.ApplicationId.Value, Arg.Any()); + } + private RunApplicationAIPipelineJob BuildJob( IFeatureChecker featureChecker, IRepository generationRequestRepository, @@ -94,13 +139,23 @@ private RunApplicationAIPipelineJob BuildJob( IAttachmentSummaryAppService? attachmentSummaryAppService = null, IApplicationAnalysisAppService? applicationAnalysisAppService = null, IApplicationScoringAppService? applicationScoringAppService = null, + IAIGenerationPrerequisiteValidator? prerequisiteValidator = null, IAIRateLimiter? rateLimiter = null) { + if (prerequisiteValidator == null) + { + prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + prerequisiteValidator.EnsureApplicationScoringAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); + } + return new RunApplicationAIPipelineJob( Substitute.For(), attachmentSummaryAppService ?? Substitute.For(), applicationAnalysisAppService ?? Substitute.For(), applicationScoringAppService ?? Substitute.For(), + prerequisiteValidator, featureChecker, localEventBus ?? Substitute.For(), generationRequestRepository, From f2ca4623cafa2e2b04bbb1f38bde4f5cdacce248 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 08:40:37 -0700 Subject: [PATCH 025/385] AB#33313 remove large AI model output logging --- .../src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index b61fed77c1..8735419847 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -318,14 +318,12 @@ private async Task GenerateWithRetryAsync( private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt, CancellationToken cancellationToken = default) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, promptVersion, formattedInput); await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput, cancellationToken); } private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output, CancellationToken cancellationToken = default) { var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, promptVersion, formattedOutput); await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput, cancellationToken); } From 600f41f605c3da9716bc53c474eb02651c3ea941 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 09:41:07 -0700 Subject: [PATCH 026/385] AB#33315 align AI analysis tab height --- .../Pages/GrantApplications/Details.css | 66 +++++++------------ 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 9adb01aaff..73828d06ba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -272,9 +272,18 @@ select.selected { margin-top: 0.8rem; } -.details-nav .nav-link { - display: flex; -} +.details-nav .nav-link { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 42px; + line-height: 1.5; +} + +.details-nav .nav-link i { + line-height: 1; +} .details-nav .nav-link .fl { margin-right: 4px; @@ -338,15 +347,15 @@ select.selected { z-index: 999; } -#detailsTab .nav-item .nav-link { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.action-bar { - display: flex !important; -} +#detailsTab .nav-item .nav-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-bar { + display: flex !important; +} #DetailsActionBarStart { margin-right: 0.5rem; @@ -677,36 +686,7 @@ form label.error { border-color: #f5c2c7; } -#ai-analysis-tab { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.ai-analysis-tab-icon-wrap { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ai-analysis-tab-indicator { - width: 8px; - height: 8px; - border-radius: 999px; - background-color: #adb5bd; - flex-shrink: 0; -} - -.ai-analysis-tab-indicator.proceed { - background-color: #198754; -} - -.ai-analysis-tab-indicator.hold { - background-color: #dc3545; -} - -.ai-analysis-action-btn { +.ai-analysis-action-btn { background: #ffffff; border: 1px solid #d1d5db; padding: 4px 10px; @@ -728,4 +708,4 @@ form label.error { .worksheet_AssessmentInfo { padding-left: 0.5rem; -} \ No newline at end of file +} From 426f085ab02c639dfb8bb6f7844c30a2903600aa Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 3 Jun 2026 11:13:41 -0700 Subject: [PATCH 027/385] htofix/AB#FixTestConflicts --- .../Integrations/Chefs/FormsApiService.cs | 7 +++++-- .../Pages/Applicants/Details.js | 5 +++-- .../Pages/ApplicationForms/Mapping.js | 16 ++++++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs index d522017699..e77af5aeb7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Chefs/FormsApiService.cs @@ -91,9 +91,12 @@ private async Task GetRequestAsync(string url, string chefs throw new ArgumentException("API key is missing or empty"); var decryptedApiKey = stringEncryptionService.Decrypt(encryptedApiKey) ?? string.Empty; + var sanitizedUrl = url.Replace(Environment.NewLine, string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal); logger.LogInformation( "Sending GET request to {Url} using basic auth with FormGuid: {FormGuid}", - url.Replace(Environment.NewLine, "").Replace("\n", "").Replace("\r", ""), + sanitizedUrl, chefsApplicationFormGuid ); @@ -110,7 +113,7 @@ private async Task GetRequestAsync(string url, string chefs var content = await response.Content.ReadAsStringAsync(); logger.LogError( "Request to {Url} failed with status {StatusCode} ({Reason}). Response: {Content}", - url.Replace(Environment.NewLine, "").Replace("\n", "").Replace("\r", ""), + sanitizedUrl, response.StatusCode, response.ReasonPhrase, content diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index 38438044c3..da939d3d9a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -27,7 +27,8 @@ $(document).ready(function () { // Handle tab switching animations $('button[data-bs-toggle="tab"]').on('shown.bs.tab', function (e) { let targetTab = $(e.target).attr('data-bs-target'); - $(targetTab).addClass('fade-in-load visible'); + const targetEl = targetTab ? document.querySelector(targetTab) : null; + if (targetEl) $(targetEl).addClass('fade-in-load visible'); if ($(e.target).closest('#detailsTab').length) { syncLeftTabScrollPosition(targetTab); scheduleLeftTabScrollReset(targetTab); @@ -371,7 +372,7 @@ function updateCommentsCounters() { setTimeout(() => { $('.comments-container') .map(function () { - $('#' + $(this).data('counttag')).html($(this).data('count')); + $('#' + $(this).data('counttag')).text($(this).data('count')); }) .get(); }, 500); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index a698cbf33e..34fd5b00ae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -228,7 +228,8 @@ columnDefs: [ { render: function (data) { - return '

'; + const safeId = String(data).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); + return '
'; }, targets: 3 } @@ -367,7 +368,8 @@ dragableDiv.id = 'unity_' + intakeFieldJson.Name; dragableDiv.className = 'card mapping-field'; dragableDiv.setAttribute("draggable", "true"); - dragableDiv.innerHTML = `${setTypeIndicator(intakeField)}` + intakeFieldJson.Label + (intakeFieldJson.IsCustom ? " *" : ""); + dragableDiv.innerHTML = setTypeIndicator(intakeField); + dragableDiv.appendChild(document.createTextNode(intakeFieldJson.Label + (intakeFieldJson.IsCustom ? ' *' : ''))); if (intakeFieldJson.IsCustom) { worksheetMapColumn.appendChild(dragableDiv); dragableDiv.className += ' custom-field'; @@ -456,12 +458,6 @@ return `${text}`; } - function stripHtml(html) { - let tmp = document.createElement("DIV"); - tmp.innerHTML = html; - return tmp.textContent || tmp.innerText || ""; - } - document.addEventListener('dragstart', function (ev) { if (ev.target.classList.contains('non-drag')) { ev.preventDefault(); @@ -679,3 +675,7 @@ } } }); + +function stripHtml(html) { + return new DOMParser().parseFromString(html, 'text/html').body.textContent || ''; +} From d56d98fe5cf8e2685b22ac7387ebdd3a6f57650b Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 3 Jun 2026 11:22:41 -0700 Subject: [PATCH 028/385] htofix/AB#FixTestConflicts --- .../Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index 34fd5b00ae..f67d7d99f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -677,5 +677,5 @@ }); function stripHtml(html) { - return new DOMParser().parseFromString(html, 'text/html').body.textContent || ''; + return String(html).replace(/<[^>]*>/g, ''); } From ff48fda841f87fdbf18488a30b1beb684df82454 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 3 Jun 2026 11:29:43 -0700 Subject: [PATCH 029/385] htofix/AB#FixTestConflicts --- .../Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index f67d7d99f7..afaa0a24d8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -677,5 +677,5 @@ }); function stripHtml(html) { - return String(html).replace(/<[^>]*>/g, ''); + return String(html).replace(/<[^>]*>/g, '').replace(/ Date: Wed, 3 Jun 2026 11:38:04 -0700 Subject: [PATCH 030/385] htofix/AB#FixTestConflicts --- .../Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index afaa0a24d8..85caffbf8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -677,5 +677,8 @@ }); function stripHtml(html) { - return String(html).replace(/<[^>]*>/g, '').replace(/', '>'); } From 9ce32e654f59fe2e724b4371381e08db749bd68b Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 3 Jun 2026 12:46:45 -0700 Subject: [PATCH 031/385] AB#33223: Bugfix - Selective Attachment Download --- .../Components/ChefsAttachments/ChefsAttachments.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 827ec58c0a..ca6e5734c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -33,7 +33,7 @@ $(function () { }); }, 10); - $(downloadAll).prop('disabled', result.length === 0); + $(downloadAll).prop('disabled', true); selectedAttachmentIds = []; setGenerateSummariesEnabled(); @@ -68,6 +68,10 @@ $(function () { $('#generateAiSummaries').prop('disabled', selectedAttachmentIds.length === 0); } + function setDownloadAllEnabled() { + $(downloadAll).prop('disabled', selectedAttachmentIds.length === 0); + } + function setSelectAllState() { const totalCheckboxes = $('#ChefsAttachmentsTable .chkbox').length; const selectedCheckboxes = $('#ChefsAttachmentsTable .chkbox:checked').length; @@ -83,6 +87,7 @@ $(function () { chefsDataTable.$('.chkbox').prop('checked', false); $('.select-all-chefs-files').prop('checked', false); setGenerateSummariesEnabled(); + setDownloadAllEnabled(); } function setAllAttachmentSelections(isSelected) { @@ -335,6 +340,7 @@ $(function () { }); setGenerateSummariesEnabled(); setSelectAllState(); + setDownloadAllEnabled(); }); chefsDataTable.on('deselect', function (e, dt, type, indexes) { @@ -352,6 +358,7 @@ $(function () { }); setGenerateSummariesEnabled(); setSelectAllState(); + setDownloadAllEnabled(); }); $('#resyncSubmissionAttachments').on('click', function () { @@ -381,7 +388,7 @@ $(function () { const _this = $(this); const existingHTML = _this.html(); const zip = new JSZip(); - const tempFiles = chefsDataTable.rows().data().toArray().map((row) => ({ + const tempFiles = chefsDataTable.rows({ selected: true }).data().toArray().map((row) => ({ FormSubmissionId: row.chefsSubmissionId, ChefsFileId: row.chefsFileId, Filename: row.fileName, From a1a5ae372bee8f3c4ac81fdc0dfab996848f5eaa Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 3 Jun 2026 13:36:00 -0700 Subject: [PATCH 032/385] htofix/AB#FixTestConflicts --- .../ConfigurationManagement/Index.cshtml | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml index fed2a4b38c..fcf22cf0eb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml @@ -215,43 +215,8 @@ @if (Model.ShowScoresheets) { -
-
-
-
-
- - - -
-
- @await Component.InvokeAsync("Scoresheet") -
-
- -
-
-
-
- - -

Preview

-
-
-
-
-
-
+
+ @await Component.InvokeAsync(typeof(ScoresheetConfigurationViewComponent))
} From d29603ae44ce1dabe110ab2ae231b628e9f4d20b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 13:28:07 -0700 Subject: [PATCH 033/385] AB#33319 unify active AI generation status --- .../AIGenerationRequestDto.cs | 2 + .../GrantApplicationAppService.cs | 53 ++++++++++++++----- .../GrantManagerApplicationMapperlyProfile.cs | 12 ++++- .../Pages/GrantApplications/ai-analysis.js | 2 +- .../ai-generation-button-state.js | 45 ++++++++++++++-- .../Pages/GrantApplications/ai-rate-limit.js | 25 +++++---- .../AssessmentScoresWidget/Default.js | 2 +- .../ChefsAttachments/ChefsAttachments.js | 5 +- .../Components/ReviewList/ReviewList.js | 2 +- 9 files changed, 113 insertions(+), 35 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs index 0dfccaa949..5c1ac1ee8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs @@ -13,4 +13,6 @@ public class AIGenerationRequestDto : EntityDto public DateTime? CompletedAt { get; set; } public string? FailureReason { get; set; } public bool IsActive { get; set; } + public bool IsGenerating { get; set; } + public int RetryAfterSeconds { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 6178fea11f..be44fb379e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -13,9 +13,10 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; -using Unity.Flex.Worksheets; -using Unity.AI.Models; -using Unity.AI.Responses; +using Unity.Flex.Worksheets; +using Unity.AI.Models; +using Unity.AI.RateLimit; +using Unity.AI.Responses; using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; @@ -55,10 +56,11 @@ public class GrantApplicationAppService( IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService, - IApplicationAIGenerationQueue aiGenerationQueue, - IAIGenerationStatusAppService aiGenerationStatusAppService, - IFeatureChecker featureChecker) + IPaymentRequestAppService paymentRequestService, + IApplicationAIGenerationQueue aiGenerationQueue, + IAIGenerationStatusAppService aiGenerationStatusAppService, + IAIRateLimiter aiRateLimiter, + IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters { @@ -1180,7 +1182,8 @@ public async Task QueueApplicationAnalysisAsync(Guid app AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI analysis request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI analysis request.")); } [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] @@ -1195,7 +1198,8 @@ public async Task QueueAttachmentSummaryAsync(QueueAttac AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request.")); } [Authorize(AIPermissions.Analysis.GenerateScoring)] @@ -1209,13 +1213,15 @@ public async Task QueueApplicationScoringAsync(Guid appl AIGenerationRequestKeyHelper.ApplicationScoringOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI scoring request."); + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI scoring request.")); } public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { - await EnsureAIGenerationStatusAccessAsync(operationType); - return await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id); + await EnsureAIGenerationStatusAccessAsync(operationType); + return await ApplyRateLimitStateOrDefaultAsync( + await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id)); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] @@ -1233,8 +1239,27 @@ public async Task QueueAllAIStagesAsync(Guid application AIGenerationRequestKeyHelper.PipelineOperationType, CurrentTenant.Id); - return request ?? throw new UserFriendlyException("Unable to queue AI generation request."); - } + return await ApplyRateLimitStateAsync( + request ?? throw new UserFriendlyException("Unable to queue AI generation request.")); + } + + private async Task ApplyRateLimitStateOrDefaultAsync(AIGenerationRequestDto? request) + { + if (request is null) + { + return null; + } + + return await ApplyRateLimitStateAsync(request); + } + + private async Task ApplyRateLimitStateAsync(AIGenerationRequestDto request) + { + var state = await aiRateLimiter.GetStateAsync(); + request.IsGenerating = state.IsGenerating; + request.RetryAfterSeconds = state.RetryAfterSeconds; + return request; + } private async Task EnsureAIGenerationStatusAccessAsync(string operationType) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs index c2eca7860d..6a8b608d1f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs @@ -536,7 +536,17 @@ public partial class ApplicationActionResultItemToDtoMapper : MapperBase { public override partial CommunityDto Map(Community source); public override partial void Map(Community source, CommunityDto destination); } [Mapper] public partial class RegionalDistrictToDtoMapper : MapperBase { public override partial RegionalDistrictDto Map(RegionalDistrict source); public override partial void Map(RegionalDistrict source, RegionalDistrictDto destination); } [Mapper] public partial class ApplicationTagsToDtoMapper : MapperBase { public override partial ApplicationTagsDto Map(ApplicationTags source); public override partial void Map(ApplicationTags source, ApplicationTagsDto destination); } -[Mapper] public partial class AIGenerationRequestToDtoMapper : MapperBase { public override partial AIGenerationRequestDto Map(AIGenerationRequest source); public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); } +[Mapper] +public partial class AIGenerationRequestToDtoMapper : MapperBase +{ + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] + public override partial AIGenerationRequestDto Map(AIGenerationRequest source); + + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] + [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] + public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); +} [Mapper(AllowNullPropertyAssignment = true)] public partial class ApplicantToGrantApplicationApplicantDtoMapper : MapperBase { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index c608d0f2ce..a29267f48d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -456,8 +456,8 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(request); loadAIAnalysis(); - globalThis.syncAIRateLimitButtons?.(); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js index 7424d1591a..02aea6030d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js @@ -13,6 +13,16 @@ .prop('disabled', true); } + function applyRateLimitState(request, options = {}) { + global.applyAIRateLimitState?.( + { + isGenerating: request?.isGenerating === true || request?.isActive === true, + retryAfterSeconds: Number(request?.retryAfterSeconds) || 0 + }, + { pollWhenGenerating: options.pollWhenGenerating === true } + ); + } + global.AIGenerationButtonState = { resolveStatus(status) { switch (Number(status)) { @@ -29,7 +39,7 @@ } }, setGenerating($button) { - global.setAIGenerationButtonsGenerating?.(); + global.setAIGenerationButtonsGenerating?.({ poll: false }); }, restore($button) { $button.removeClass('disabled'); @@ -37,11 +47,17 @@ restoreForCooldownCheck($button, html) { restoreButtonForCooldownCheck($button, html); }, + applyStatusState(request) { + applyRateLimitState(request, { pollWhenGenerating: true }); + }, monitor(options) { const intervalMs = options.intervalMs || 15000; + const activeIntervalMs = options.activeIntervalMs || 2000; + const activePollCount = options.activePollCount || 10; const maxFailures = options.maxFailures || 3; let timeoutId = null; let failures = 0; + let pollCount = 0; const stop = () => { if (timeoutId) { @@ -50,6 +66,15 @@ } }; + const scheduleNextPoll = () => { + pollCount += 1; + const nextIntervalMs = pollCount <= activePollCount + ? activeIntervalMs + : intervalMs; + + timeoutId = setTimeout(poll, nextIntervalMs); + }; + const poll = () => { options.getStatus() .done((request) => { @@ -59,19 +84,29 @@ if (status === 'Failed') { stop(); restoreButton(options.$button, options.originalHtml); + applyRateLimitState(request, { pollWhenGenerating: true }); options.onFailed?.(request); return; } - if (!request || request.isActive === false || status === 'Completed') { + if (!request) { + stop(); + restoreButton(options.$button, options.originalHtml); + global.refreshAIRateLimitState?.(); + options.onMissing?.(); + return; + } + + if (request.isActive === false || status === 'Completed') { stop(); restoreButtonForCooldownCheck(options.$button, options.originalHtml); + applyRateLimitState(request, { pollWhenGenerating: true }); options.onComplete?.(request); - global.syncAIRateLimitButtons?.(); return; } - timeoutId = setTimeout(poll, intervalMs); + applyRateLimitState(request); + scheduleNextPoll(); }) .fail((error) => { failures += 1; @@ -82,7 +117,7 @@ return; } - timeoutId = setTimeout(poll, intervalMs); + scheduleNextPoll(); }); }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index 6ee671727d..f822be6c42 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -121,12 +121,14 @@ } } - function applyGenerating() { + function applyGenerating(options = {}) { currentState = { mode: 'generating' }; renderState(); clearStatePollTimer(); - statePollTimer = setTimeout(() => fetchState(true), 2000); + if (options.poll !== false) { + statePollTimer = setTimeout(() => fetchState(true), 2000); + } } function renderGenerating() { @@ -223,29 +225,34 @@ return; } const data = await res.json(); - if (data.isGenerating === true) { - applyGenerating(); - return; - } - - applyCooldown(Number(data.retryAfterSeconds) || 0); + applyRateLimitState(data); } catch (_) { // Best-effort; the server is the source of truth. handleStateFetchFailure(); } } + function applyRateLimitState(data, options = {}) { + if (data?.isGenerating === true) { + applyGenerating({ poll: options.pollWhenGenerating !== false }); + return; + } + + applyCooldown(Number(data?.retryAfterSeconds) || 0); + } + globalThis.syncAIRateLimitButtons = () => { renderState(); fetchState(true); }; globalThis.setAIGenerationButtonsGenerating = applyGenerating; + globalThis.applyAIRateLimitState = applyRateLimitState; globalThis.refreshAIRateLimitState = globalThis.syncAIRateLimitButtons; document.addEventListener('click', (e) => { const btn = e.target.closest(BUTTON_SELECTOR); if (!btn) return; - applyGenerating(); + applyGenerating({ poll: false }); }); document.addEventListener('DOMContentLoaded', () => { 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 9cbc185d6b..4f17bbbb06 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 @@ -699,8 +699,8 @@ function queueApplicationScoring(triggerButton = null) { if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(request); PubSub.publish('refresh_assessment_scores', null); - globalThis.syncAIRateLimitButtons?.(); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 827ec58c0a..332412f0a9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -212,9 +212,8 @@ $(function () { resetAttachmentSelection(); chefsDataTable.ajax.reload(); abp.notify.success('AI summaries generated successfully.'); - globalThis.AIGenerationButtonState?.restore($activeButton); - $activeButton.html(existingHTML).prop('disabled', false); - globalThis.syncAIRateLimitButtons?.(); + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($activeButton, existingHTML); + globalThis.AIGenerationButtonState?.applyStatusState(request); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js index a52d5b6467..53c123bb51 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js @@ -477,8 +477,8 @@ function generateAiButtonAction(e, dt, button, config) { if (status === 'Completed') { restoreReviewListAiButtonForCooldownCheck($button); + globalThis.AIGenerationButtonState?.applyStatusState(request); refreshReviewListAfterAiScoring(); - globalThis.syncAIRateLimitButtons?.(); return; } From 517fc860ba532ec20e477a11f4854b5b785479af Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 14:47:12 -0700 Subject: [PATCH 034/385] AB#33319 clean up AI generation status DTO --- .../AIGenerationRequestDto.cs | 2 - .../AIGenerationStatusDto.cs | 8 ++++ .../IGrantApplicationAppService.cs | 12 +++--- .../GrantApplicationAppService.cs | 43 ++++++++----------- .../GrantManagerApplicationMapperlyProfile.cs | 12 +----- .../Pages/GrantApplications/ai-analysis.js | 8 ++-- .../ai-generation-button-state.js | 20 +++++---- .../AssessmentScoresWidget/Default.js | 5 ++- .../ChefsAttachments/ChefsAttachments.js | 5 ++- .../Components/ReviewList/ReviewList.js | 8 ++-- 10 files changed, 60 insertions(+), 63 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs index 5c1ac1ee8c..0dfccaa949 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationRequestDto.cs @@ -13,6 +13,4 @@ public class AIGenerationRequestDto : EntityDto public DateTime? CompletedAt { get; set; } public string? FailureReason { get; set; } public bool IsActive { get; set; } - public bool IsGenerating { get; set; } - public int RetryAfterSeconds { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs new file mode 100644 index 0000000000..55814b6beb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs @@ -0,0 +1,8 @@ +namespace Unity.GrantManager.GrantApplications; + +public class AIGenerationStatusDto +{ + public AIGenerationRequestDto? GenerationRequest { get; set; } + public bool IsGenerating { get; set; } + public int RetryAfterSeconds { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index 1c876c1425..ce55d62db4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -18,12 +18,12 @@ public interface IGrantApplicationAppService Task> GetApplicationDetailsListAsync(List applicationIds); Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); - Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null); - Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); - Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null); - Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null); - Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null); - Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null); + Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null); + Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null); + Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null); + Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null); Task GetAccountCodingIdFromFormIdAsync(Guid formId); Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId); Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index be44fb379e..01b0be6f94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1165,14 +1165,14 @@ await LocalEventBus.PublishAsync( return ObjectMapper.Map(application); } - public async Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueAIGenerationAsync(Guid applicationId, string? promptVersion = null) { await EnsureAIAnalysisEnabledAsync(); return await QueueApplicationAnalysisAsync(applicationId, promptVersion); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] - public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await EnsureAIAnalysisEnabledAsync(); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, CurrentTenant.Id, promptVersion); @@ -1182,12 +1182,12 @@ public async Task QueueApplicationAnalysisAsync(Guid app AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, CurrentTenant.Id); - return await ApplyRateLimitStateAsync( + return await CreateGenerationStatusAsync( request ?? throw new UserFriendlyException("Unable to queue AI analysis request.")); } [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] - public async Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null) + public async Task QueueAttachmentSummaryAsync(QueueAttachmentSummaryRequestDto input, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); var attachmentIds = await ResolveAttachmentSummaryIdsAsync(input); @@ -1198,12 +1198,12 @@ public async Task QueueAttachmentSummaryAsync(QueueAttac AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, CurrentTenant.Id); - return await ApplyRateLimitStateAsync( + return await CreateGenerationStatusAsync( request ?? throw new UserFriendlyException("Unable to queue AI attachment summary request.")); } [Authorize(AIPermissions.Analysis.GenerateScoring)] - public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await EnsureScoringEnabledAsync(); await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, CurrentTenant.Id, promptVersion); @@ -1213,21 +1213,21 @@ public async Task QueueApplicationScoringAsync(Guid appl AIGenerationRequestKeyHelper.ApplicationScoringOperationType, CurrentTenant.Id); - return await ApplyRateLimitStateAsync( + return await CreateGenerationStatusAsync( request ?? throw new UserFriendlyException("Unable to queue AI scoring request.")); } - public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) + public async Task GetAIGenerationStatusAsync(Guid applicationId, string operationType, string? promptVersion = null) { await EnsureAIGenerationStatusAccessAsync(operationType); - return await ApplyRateLimitStateOrDefaultAsync( + return await CreateGenerationStatusAsync( await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id)); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [Authorize(AIPermissions.Analysis.GenerateScoring)] - public async Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null) + public async Task QueueAllAIStagesAsync(Guid applicationId, string? promptVersion = null) { await EnsureAttachmentSummariesEnabledAsync(); await EnsureAIAnalysisEnabledAsync(); @@ -1239,26 +1239,19 @@ public async Task QueueAllAIStagesAsync(Guid application AIGenerationRequestKeyHelper.PipelineOperationType, CurrentTenant.Id); - return await ApplyRateLimitStateAsync( + return await CreateGenerationStatusAsync( request ?? throw new UserFriendlyException("Unable to queue AI generation request.")); } - private async Task ApplyRateLimitStateOrDefaultAsync(AIGenerationRequestDto? request) - { - if (request is null) - { - return null; - } - - return await ApplyRateLimitStateAsync(request); - } - - private async Task ApplyRateLimitStateAsync(AIGenerationRequestDto request) + private async Task CreateGenerationStatusAsync(AIGenerationRequestDto? request) { var state = await aiRateLimiter.GetStateAsync(); - request.IsGenerating = state.IsGenerating; - request.RetryAfterSeconds = state.RetryAfterSeconds; - return request; + return new AIGenerationStatusDto + { + GenerationRequest = request, + IsGenerating = state.IsGenerating, + RetryAfterSeconds = state.RetryAfterSeconds + }; } private async Task EnsureAIGenerationStatusAccessAsync(string operationType) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs index 6a8b608d1f..c2eca7860d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs @@ -536,17 +536,7 @@ public partial class ApplicationActionResultItemToDtoMapper : MapperBase { public override partial CommunityDto Map(Community source); public override partial void Map(Community source, CommunityDto destination); } [Mapper] public partial class RegionalDistrictToDtoMapper : MapperBase { public override partial RegionalDistrictDto Map(RegionalDistrict source); public override partial void Map(RegionalDistrict source, RegionalDistrictDto destination); } [Mapper] public partial class ApplicationTagsToDtoMapper : MapperBase { public override partial ApplicationTagsDto Map(ApplicationTags source); public override partial void Map(ApplicationTags source, ApplicationTagsDto destination); } -[Mapper] -public partial class AIGenerationRequestToDtoMapper : MapperBase -{ - [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] - [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] - public override partial AIGenerationRequestDto Map(AIGenerationRequest source); - - [MapperIgnoreTarget(nameof(AIGenerationRequestDto.IsGenerating))] - [MapperIgnoreTarget(nameof(AIGenerationRequestDto.RetryAfterSeconds))] - public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); -} +[Mapper] public partial class AIGenerationRequestToDtoMapper : MapperBase { public override partial AIGenerationRequestDto Map(AIGenerationRequest source); public override partial void Map(AIGenerationRequest source, AIGenerationRequestDto destination); } [Mapper(AllowNullPropertyAssignment = true)] public partial class ApplicantToGrantApplicationApplicantDtoMapper : MapperBase { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index a29267f48d..424a8d9666 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -451,12 +451,13 @@ globalThis.queueApplicationAnalysis = function(triggerButton = null) { unity.grantManager.grantApplications.grantApplication .queueApplicationAnalysis(applicationId) - .done(function(request) { + .done(function(generationStatus) { + const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); - globalThis.AIGenerationButtonState?.applyStatusState(request); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); loadAIAnalysis(); return; } @@ -540,7 +541,8 @@ $(function() { unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(applicationId, 'application-analysis') - .done(function(request) { + .done(function(generationStatus) { + const request = generationStatus?.generationRequest; if (request?.isActive !== true) { return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js index 02aea6030d..1ffd85a1c6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button-state.js @@ -13,11 +13,12 @@ .prop('disabled', true); } - function applyRateLimitState(request, options = {}) { + function applyRateLimitState(generationStatus, options = {}) { + const request = generationStatus?.generationRequest; global.applyAIRateLimitState?.( { - isGenerating: request?.isGenerating === true || request?.isActive === true, - retryAfterSeconds: Number(request?.retryAfterSeconds) || 0 + isGenerating: generationStatus?.isGenerating === true || request?.isActive === true, + retryAfterSeconds: Number(generationStatus?.retryAfterSeconds) || 0 }, { pollWhenGenerating: options.pollWhenGenerating === true } ); @@ -47,8 +48,8 @@ restoreForCooldownCheck($button, html) { restoreButtonForCooldownCheck($button, html); }, - applyStatusState(request) { - applyRateLimitState(request, { pollWhenGenerating: true }); + applyStatusState(generationStatus) { + applyRateLimitState(generationStatus, { pollWhenGenerating: true }); }, monitor(options) { const intervalMs = options.intervalMs || 15000; @@ -77,14 +78,15 @@ const poll = () => { options.getStatus() - .done((request) => { + .done((generationStatus) => { failures = 0; + const request = generationStatus?.generationRequest; const status = this.resolveStatus(request?.status); if (status === 'Failed') { stop(); restoreButton(options.$button, options.originalHtml); - applyRateLimitState(request, { pollWhenGenerating: true }); + applyRateLimitState(generationStatus, { pollWhenGenerating: true }); options.onFailed?.(request); return; } @@ -100,12 +102,12 @@ if (request.isActive === false || status === 'Completed') { stop(); restoreButtonForCooldownCheck(options.$button, options.originalHtml); - applyRateLimitState(request, { pollWhenGenerating: true }); + applyRateLimitState(generationStatus, { pollWhenGenerating: true }); options.onComplete?.(request); return; } - applyRateLimitState(request); + applyRateLimitState(generationStatus); scheduleNextPoll(); }) .fail((error) => { 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 4f17bbbb06..b627970d32 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 @@ -694,12 +694,13 @@ function queueApplicationScoring(triggerButton = null) { unity.grantManager.grantApplications.grantApplication .queueApplicationScoring(applicationId) - .done(function (request) { + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); - globalThis.AIGenerationButtonState?.applyStatusState(request); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); PubSub.publish('refresh_assessment_scores', null); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index c0c85d3641..fedcbeed87 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -210,7 +210,8 @@ $(function () { applicationId: getApplicationId(), attachmentIds: summaryAttachmentIds }) - .done(function (request) { + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { @@ -218,7 +219,7 @@ $(function () { chefsDataTable.ajax.reload(); abp.notify.success('AI summaries generated successfully.'); globalThis.AIGenerationButtonState?.restoreForCooldownCheck($activeButton, existingHTML); - globalThis.AIGenerationButtonState?.applyStatusState(request); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); return; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js index 53c123bb51..67687361df 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ReviewList/ReviewList.js @@ -472,12 +472,13 @@ function generateAiButtonAction(e, dt, button, config) { } unity.grantManager.grantApplications.grantApplication.queueApplicationScoring(pageApplicationId) - .done(function (request) { + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; if (status === 'Completed') { restoreReviewListAiButtonForCooldownCheck($button); - globalThis.AIGenerationButtonState?.applyStatusState(request); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); refreshReviewListAfterAiScoring(); return; } @@ -518,7 +519,8 @@ function resumeActiveReviewListAiButton(reviewListTable) { const $button = $(button.node()); unity.grantManager.grantApplications.grantApplication .getAIGenerationStatus(pageApplicationId, 'application-scoring') - .done(function(request) { + .done(function(generationStatus) { + const request = generationStatus?.generationRequest; if (request?.isActive !== true) { return; } From 6294dac74c3939e3048384778bcbe03521bcf9a9 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 15:03:15 -0700 Subject: [PATCH 035/385] AB#33313 skip prompt formatting when file logging disabled --- .../AI/Runtime/OpenAIRuntimeService.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index c3c0f9fb5e..bdf4c38faf 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -317,12 +317,22 @@ private async Task GenerateWithRetryAsync( private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt, CancellationToken cancellationToken = default) { + if (!CanWritePromptFileLog()) + { + return; + } + var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput, cancellationToken); } private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output, CancellationToken cancellationToken = default) { + if (!CanWritePromptFileLog()) + { + return; + } + var formattedOutput = FormatPromptOutputForLog(output); await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput, cancellationToken); } From 41ed639e0bf261fd2269f2e439e7d684363f3e53 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 15:06:39 -0700 Subject: [PATCH 036/385] AB#33267 tighten empty attachment prompt test --- .../AI/Runtime/OpenAIPromptRendererTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs index cc2c48823d..0d63f1216b 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs @@ -34,12 +34,13 @@ public void BuildApplicationScoringUserPrompt_Should_Render_Structured_Empty_Att "v1", "{}", "[]", - "{\"name\":\"Test\",\"questions\":[]}", + "{\"name\":\"Test\",\"questions\":[{\"id\":\"q1\",\"type\":\"YesNo\"}]}", "{}"); - prompt.ShouldContain("ATTACHMENTS"); - prompt.ShouldContain("[]"); - prompt.ShouldContain("If ATTACHMENTS is empty, use DATA only"); - prompt.ShouldNotContain("No attachments provided."); + var normalized = prompt.Replace("\r\n", "\n"); + + normalized.ShouldContain("ATTACHMENTS\n[]\n\nSECTION"); + normalized.ShouldContain("If ATTACHMENTS is empty, use DATA only"); + normalized.ShouldNotContain("No attachments provided."); } } From 251e1f3851841c2d641dd108a80e3fc07330a2d2 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 2 Jun 2026 15:58:00 -0700 Subject: [PATCH 037/385] AB#33307 fix prompt renderer data placeholders --- .../AI/Runtime/OpenAIPromptRenderer.cs | 27 ++++++++++++------- .../AI/Runtime/OpenAIPromptRendererTests.cs | 26 ++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs index c90ed92916..627eed4511 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -261,8 +261,9 @@ private static string RenderPromptTemplateInternal( var template = GetRequiredPromptTemplate(version, templateName); var replacements = new Dictionary(runtimeReplacements, StringComparer.Ordinal); var baseTemplateName = GetTemplateBaseName(templateName); + var placeholders = GetTemplatePlaceholders(template); - foreach (var placeholder in GetTemplatePlaceholders(template)) + foreach (var placeholder in placeholders) { if (replacements.ContainsKey(placeholder)) { @@ -280,19 +281,21 @@ private static string RenderPromptTemplateInternal( } } - var rendered = template; - foreach (var replacement in replacements) - { - rendered = rendered.Replace($"{{{{{replacement.Key}}}}}", replacement.Value ?? string.Empty, StringComparison.Ordinal); - } - - var unresolved = GetTemplatePlaceholders(rendered); + var unresolved = placeholders + .Where(placeholder => !replacements.ContainsKey(placeholder)) + .ToList(); if (unresolved.Count > 0) { throw new InvalidOperationException( $"Unresolved prompt placeholders in '{templateName}.txt' for prompt version '{version}': {string.Join(", ", unresolved.OrderBy(item => item))}"); } + var rendered = template; + foreach (var placeholder in placeholders) + { + rendered = rendered.Replace($"{{{{{placeholder}}}}}", replacements[placeholder] ?? string.Empty, StringComparison.Ordinal); + } + resolutionStack.Remove(templateName); return rendered; } @@ -369,7 +372,7 @@ private static HashSet GetTemplatePlaceholders(string template) } var placeholder = template.Substring(start + 2, end - start - 2).Trim(); - if (!string.IsNullOrWhiteSpace(placeholder)) + if (IsPromptPlaceholder(placeholder)) { placeholders.Add(placeholder); } @@ -379,4 +382,10 @@ private static HashSet GetTemplatePlaceholders(string template) return placeholders; } + + private static bool IsPromptPlaceholder(string placeholder) + { + return !string.IsNullOrWhiteSpace(placeholder) && + placeholder.All(character => char.IsUpper(character) || char.IsDigit(character) || character == '_'); + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs index cc2c48823d..a66da02aef 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs @@ -42,4 +42,30 @@ public void BuildApplicationScoringUserPrompt_Should_Render_Structured_Empty_Att prompt.ShouldContain("If ATTACHMENTS is empty, use DATA only"); prompt.ShouldNotContain("No attachments provided."); } + + [Fact] + public void BuildApplicationAnalysisUserPrompt_Should_Not_Treat_FormIo_Mustache_Data_As_Prompt_Placeholders() + { + const string data = """ + { + "template": "{{ item.label }}", + "fileNameTemplate": "{{fileName}}", + "sector": "{{ item.SectorName }}", + "topicSourceId": "{{ item.topic_source_id }}", + "uppercaseTemplateValue": "{{FORMIO_VALUE}}" + } + """; + + var result = OpenAIPromptRenderer.BuildApplicationAnalysisUserPrompt( + "v1", + "{}", + data, + "[]"); + + result.ShouldContain("{{ item.label }}"); + result.ShouldContain("{{fileName}}"); + result.ShouldContain("{{ item.SectorName }}"); + result.ShouldContain("{{ item.topic_source_id }}"); + result.ShouldContain("{{FORMIO_VALUE}}"); + } } From 837a8281f22d3d18fd39a0c68d0a68f4243686fa Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 3 Jun 2026 15:34:50 -0700 Subject: [PATCH 038/385] AB#32702 fix column filters --- .../ConsolidatedFormVersionFieldsProvider.cs | 18 +-- .../ConsolidatedWorksheetFieldsProvider.cs | 18 +-- .../ReportingConfiguration/Default.js | 142 +++++++++--------- 3 files changed, 93 insertions(+), 85 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs index a81086a22f..72bad667f8 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedFormVersionFieldsProvider.cs @@ -29,9 +29,9 @@ public class ConsolidatedFormVersionFieldsProvider( /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. /// Fields unique to one version are included with a VersionLabel marker. ///
- public async Task GetFieldsMetadataAsync(Guid formId) + public async Task GetFieldsMetadataAsync(Guid correlationId) { - var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versions = await applicationFormAppService.GetVersionsAsync(correlationId); var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); var metadataInfo = new Dictionary(); @@ -138,7 +138,7 @@ private static List MergeFields( var result = new List(); var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (_, versionLabel, fields) in versionsWithFields) + foreach (var (_, _, fields) in versionsWithFields) { foreach (var field in fields) { @@ -167,8 +167,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) @@ -182,8 +182,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = null @@ -197,8 +197,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs index 1ad032d941..bb8d055188 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ConsolidatedWorksheetFieldsProvider.cs @@ -31,9 +31,9 @@ public class ConsolidatedWorksheetFieldsProvider( /// Fields with the same (Label, Path) but different Type produce per-version conflict entries. /// Fields unique to one version are included with a VersionLabel marker. ///
- public async Task GetFieldsMetadataAsync(Guid formId) + public async Task GetFieldsMetadataAsync(Guid correlationId) { - var versions = await applicationFormAppService.GetVersionsAsync(formId); + var versions = await applicationFormAppService.GetVersionsAsync(correlationId); var versionsWithFields = new List<(Guid VersionId, string VersionLabel, FieldPathTypeDto[] Fields)>(); var metadataInfo = new Dictionary(); @@ -164,7 +164,7 @@ private static List MergeFields( var result = new List(); var processedExactKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (_, versionLabel, fields) in versionsWithFields) + foreach (var (_, _, fields) in versionsWithFields) { foreach (var field in fields) { @@ -193,8 +193,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) @@ -208,8 +208,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = null @@ -223,8 +223,8 @@ private static List MergeFields( Id = field.Id, Key = field.Key, Label = field.Label, - Path = field.Path, - Type = field.Type, + Path = field.Path ?? string.Empty, + Type = field.Type ?? string.Empty, TypePath = field.TypePath, DataPath = field.DataPath, VersionLabel = string.Join(", ", exactGroup.Select(e => e.VersionLabel)) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js index 7985f2ed8d..13c27dbb34 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js @@ -1,6 +1,7 @@ $(function () { let hasUnsavedChanges = false; let dataTable = null; + let _suppressColvisSave = false; // Initialize provider from the hidden input let currentProvider = $('#reportingProvider').val() || 'formversion'; @@ -255,6 +256,47 @@ $(function () { refreshViewStatusWidget(correlationId, getCorrelationProvider()); } + // Persist column visibility state per provider in localStorage + function saveColvisState(provider) { + if (!dataTable) return; + try { + const state = {}; + dataTable.columns().every(function (colIdx) { + const colName = dataTable.settings()[0].aoColumns[colIdx].name; + if (colName) state[colName] = this.visible(); + }); + localStorage.setItem('reportingColvis_' + provider, JSON.stringify(state)); + } catch (e) { + /* storage unavailable */ + console.err(e); + } + } + + function restoreColvisState(provider) { + if (!dataTable) return; + try { + const stored = localStorage.getItem('reportingColvis_' + provider); + if (!stored) return; + const state = JSON.parse(stored); + dataTable.columns().every(function (colIdx) { + const colName = dataTable.settings()[0].aoColumns[colIdx].name; + if (colName && colName in state) this.visible(state[colName]); + }); + } catch (e) { + /* ignore invalid stored state */ + console.err(e); + } + } + + // Destroy and reinitialize the DataTable for the current provider + function reinitTable() { + if (dataTable) { + dataTable.destroy(); + dataTable = null; + } + initializeReportConfigTable(); + } + // Function to handle provider change function handleProviderChange(newProvider) { if (newProvider === currentProvider) return; @@ -269,48 +311,10 @@ $(function () { updateVersionSelectorVisibility(); resetChangesState(); $('#reportingViewStatus').val(''); - updateDataTableColumnHeaders(); - - const correlationId = getCurrentCorrelationId(); - if (correlationId) { - refreshConfigurationState(correlationId); - } else { - updateGenerateViewButtonVisibility(false); - updateDeleteButtonVisibility(false); - } - - if (dataTable) { - dataTable.ajax.reload(); - } - } - - // Function to update DataTable column headers - function updateDataTableColumnHeaders() { - if (!dataTable) return; - - const providerConfig = getCurrentProviderConfig(); - const columns = providerConfig.columns; - - // Update column headers - const columnIndices = [ - { index: 0, title: columns.label }, - { index: 1, title: columns.key }, - { index: 2, title: columns.type }, - { index: 3, title: columns.path }, - { index: 4, title: columns.columnName }, - { index: 5, title: columns.typePath } - ]; - - columnIndices.forEach(col => { - const header = $(dataTable.column(col.index).header()); - header.text(col.title); - }); - - // Show the Version(s) column only for consolidated providers - dataTable.column('versionLabel:name').visible(currentProvider === 'worksheet_consolidated' || currentProvider === 'formversion_consolidated'); - - // Force redraw to update headers - dataTable.draw(false); + _suppressColvisSave = true; + reinitTable(); + restoreColvisState(newProvider); + _suppressColvisSave = false; } // Handle provider toggle change event @@ -818,6 +822,16 @@ $(function () { fixedHeaders: true }); + // Persist column visibility per provider + dataTable.on('column-visibility.dt', function () { + if (!_suppressColvisSave) saveColvisState(currentProvider); + }); + + // Restore saved visibility for the initial provider + _suppressColvisSave = true; + restoreColvisState(currentProvider); + _suppressColvisSave = false; + // Add event handler for when DataTable completes drawing dataTable.on('draw.dt', function () { // Ensure inputs are always enabled (edit mode is always on) @@ -832,31 +846,6 @@ $(function () { updateDynamicColumnsWarning(hasDynamicColumns); }); - // Track changes on column name inputs with validation - $('#ReportConfigurationTable').on('input', '.column-name-input', function () { - const $input = $(this); - const value = $input.val(); - const path = $input.data('path'); - - // Validate and provide feedback - validateColumnNameInput($input, value, path); - markAsChanged(); - }); - - // Handle blur event to sanitize input - $('#ReportConfigurationTable').on('blur', '.column-name-input', function () { - const $input = $(this); - const value = $input.val().trim(); - - if (value) { - const sanitized = sanitizeColumnName(value); - if (sanitized !== value) { - $input.val(sanitized); - validateColumnNameInput($input, sanitized, $input.data('path')); - } - } - }); - // Initial check for button visibility on page load const initialCorrelationId = getCurrentCorrelationId(); if (initialCorrelationId) { @@ -2154,6 +2143,25 @@ $(function () { // Initialize on page load initializeReportConfigTable(); + // Delegated handlers registered once — survive DataTable destroy/reinit + $('#ReportConfigurationTable').on('input', '.column-name-input', function () { + const $input = $(this); + validateColumnNameInput($input, $input.val(), $input.data('path')); + markAsChanged(); + }); + + $('#ReportConfigurationTable').on('blur', '.column-name-input', function () { + const $input = $(this); + const value = $input.val().trim(); + if (value) { + const sanitized = sanitizeColumnName(value); + if (sanitized !== value) { + $input.val(sanitized); + validateColumnNameInput($input, sanitized, $input.data('path')); + } + } + }); + // Set initial undo button state updateUndoButtonVisibility(); From 4db92e43a095a5d75eb029788bcfa8900d3d301d Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 15:34:55 -0700 Subject: [PATCH 039/385] AB#33266 address AI prerequisite review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AI/Operations/AIGenerationPrerequisiteValidator.cs | 9 ++++++--- .../AI/Operations/ApplicationScoringService.cs | 10 ++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index acbff32f35..fa6dfc7492 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Linq; namespace Unity.AI.Operations; @@ -13,12 +14,14 @@ public class AIGenerationPrerequisiteValidator( IApplicationFormRepository applicationFormRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, - IScoresheetRepository scoresheetRepository) : IAIGenerationPrerequisiteValidator, ITransientDependency + IScoresheetRepository scoresheetRepository, + IAsyncQueryableExecuter asyncExecuter) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { - var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); - if (attachments.Count == 0) + var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync(); + var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId)); + if (!hasAttachments) { throw new UserFriendlyException("No attachments are available to summarize."); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index 2b28c2c920..8bc7e31fa8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -11,6 +11,7 @@ using Unity.AI.Requests; using Unity.AI.Runtime; using Unity.GrantManager.Applications; +using Volo.Abp; using Volo.Abp.DependencyInjection; namespace Unity.AI.Operations @@ -23,25 +24,22 @@ public class ApplicationScoringService( IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, IAIService aiService, - IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, AIExecutionModeResolver executionModeResolver, ILogger logger) : IApplicationScoringService, ITransientDependency { public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { - await aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId); - var application = await applicationRepository.GetAsync(applicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); if (applicationForm.ScoresheetId == null) { - return "{}"; + throw new UserFriendlyException("AI scoring requires a configured scoresheet."); } var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); - if (scoresheet == null) + if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { - return "{}"; + throw new UserFriendlyException("AI scoring requires a scoresheet with scoring fields."); } var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); From 53c31a0ec52754b80c0dc3665f8110db3ff59f2e Mon Sep 17 00:00:00 2001 From: Andre Goncalves <98196495+AndreGAot@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:48:27 -0700 Subject: [PATCH 040/385] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Views/Shared/Components/ReportingConfiguration/Default.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js index 13c27dbb34..bc252ff69c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js @@ -268,7 +268,7 @@ $(function () { localStorage.setItem('reportingColvis_' + provider, JSON.stringify(state)); } catch (e) { /* storage unavailable */ - console.err(e); + console.error(e); } } @@ -284,7 +284,7 @@ $(function () { }); } catch (e) { /* ignore invalid stored state */ - console.err(e); + console.error(e); } } From aa90aef225ca4f8b95a2ffae606acf360def7b47 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 15:56:38 -0700 Subject: [PATCH 041/385] AB#33319 add AI status null request test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...GrantApplicationAIGenerationStatusTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationAIGenerationStatusTests.cs diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationAIGenerationStatusTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationAIGenerationStatusTests.cs new file mode 100644 index 0000000000..a8323f6cc8 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/GrantApplicationAIGenerationStatusTests.cs @@ -0,0 +1,79 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Threading.Tasks; +using Unity.AI.Automation; +using Unity.AI.RateLimit; +using Unity.GrantManager.Applicants; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Flex; +using Unity.GrantManager.GlobalTag; +using Unity.GrantManager.Identity; +using Unity.GrantManager.Payments; +using Unity.Payments.PaymentRequests; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.GrantApplications; + +public class GrantApplicationAIGenerationStatusTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) +{ + [Fact] + public async Task GetAIGenerationStatusAsync_Should_Return_RateLimit_State_When_No_Generation_Request_Exists() + { + var applicationId = Guid.NewGuid(); + var aiGenerationStatusAppService = Substitute.For(); + aiGenerationStatusAppService + .GetLatestAsync(applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, null) + .Returns((AIGenerationRequestDto?)null); + + var aiRateLimiter = Substitute.For(); + aiRateLimiter.GetStateAsync().Returns(new AIRateLimitStateDto + { + IsGenerating = true, + RetryAfterSeconds = 17 + }); + + var appService = CreateAppService(aiGenerationStatusAppService, aiRateLimiter); + + var result = await appService.GetAIGenerationStatusAsync( + applicationId, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + + result.ShouldNotBeNull(); + result.GenerationRequest.ShouldBeNull(); + result.IsGenerating.ShouldBeTrue(); + result.RetryAfterSeconds.ShouldBe(17); + await aiGenerationStatusAppService.Received(1) + .GetLatestAsync(applicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, null); + await aiRateLimiter.Received(1).GetStateAsync(); + } + + private GrantApplicationAppService CreateAppService( + IAIGenerationStatusAppService aiGenerationStatusAppService, + IAIRateLimiter aiRateLimiter) + { + var appService = new GrantApplicationAppService( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + aiGenerationStatusAppService, + aiRateLimiter, + Substitute.For()); + + appService.LazyServiceProvider = GetRequiredService(); + return appService; + } +} From b83d5c02efdf79e5bdc5e1c265a881c9f2573e76 Mon Sep 17 00:00:00 2001 From: Andre Goncalves <98196495+AndreGAot@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:01:30 -0700 Subject: [PATCH 042/385] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Views/Shared/Components/ReportingConfiguration/Default.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js index bc252ff69c..a75e0fc3d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js @@ -311,10 +311,7 @@ $(function () { updateVersionSelectorVisibility(); resetChangesState(); $('#reportingViewStatus').val(''); - _suppressColvisSave = true; reinitTable(); - restoreColvisState(newProvider); - _suppressColvisSave = false; } // Handle provider toggle change event From a3fbb3cef9066b6e10d6e5b6f5a7c2b9f888bd51 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 16:10:20 -0700 Subject: [PATCH 043/385] AB#33266 address AI prerequisite review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AIGenerationPrerequisiteValidator.cs | 15 +++---- .../Operations/ApplicationScoringService.cs | 9 +++-- .../AI/Operations/AttachmentSummaryService.cs | 7 +++- .../Localization/AI/en.json | 5 +++ .../Localization/AILocalizationKeys.cs | 5 +++ .../ApplicationAIGenerationQueue.cs | 9 +++-- .../AttachmentSummaryServiceTests.cs | 11 ++++-- .../Automation/AIGenerationQueueTests.cs | 39 ++++++++++++++++++- 8 files changed, 80 insertions(+), 20 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index fa6dfc7492..50b4217195 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -1,7 +1,9 @@ +using Microsoft.Extensions.Localization; using System; using System.Linq; using System.Threading.Tasks; using Unity.Flex.Domain.Scoresheets; +using Unity.AI.Localization; using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.DependencyInjection; @@ -15,7 +17,8 @@ public class AIGenerationPrerequisiteValidator( IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, - IAsyncQueryableExecuter asyncExecuter) : IAIGenerationPrerequisiteValidator, ITransientDependency + IAsyncQueryableExecuter asyncExecuter, + IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { @@ -23,18 +26,16 @@ public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId)); if (!hasAttachments) { - throw new UserFriendlyException("No attachments are available to summarize."); + throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]); } } public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) { - await applicationRepository.GetAsync(applicationId); - var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) { - throw new UserFriendlyException("AI application analysis requires application submission data."); + throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); } } @@ -44,13 +45,13 @@ public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); if (applicationForm.ScoresheetId == null) { - throw new UserFriendlyException("AI scoring requires a configured scoresheet."); + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); } var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { - throw new UserFriendlyException("AI scoring requires a scoresheet with scoring fields."); + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index 8bc7e31fa8..9dfeb35348 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -10,9 +10,11 @@ using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Runtime; +using Unity.AI.Localization; using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.DependencyInjection; +using Microsoft.Extensions.Localization; namespace Unity.AI.Operations { @@ -25,7 +27,8 @@ public class ApplicationScoringService( IScoresheetRepository scoresheetRepository, IAIService aiService, AIExecutionModeResolver executionModeResolver, - ILogger logger) : IApplicationScoringService, ITransientDependency + ILogger logger, + IStringLocalizer localizer) : IApplicationScoringService, ITransientDependency { public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { @@ -33,13 +36,13 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); if (applicationForm.ScoresheetId == null) { - throw new UserFriendlyException("AI scoring requires a configured scoresheet."); + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); } var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { - throw new UserFriendlyException("AI scoring requires a scoresheet with scoring fields."); + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); } var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 8be02f1603..bef40bc051 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Localization; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Unity.AI.Extraction; +using Unity.AI.Localization; using Unity.AI.Requests; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; @@ -20,7 +22,8 @@ public class AttachmentSummaryService( IAIService aiService, IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, AIExecutionModeResolver executionModeResolver, - ILogger logger) : IAttachmentSummaryService, ITransientDependency + ILogger logger, + IStringLocalizer localizer) : IAttachmentSummaryService, ITransientDependency { private const string SummaryGenerationFailedMessage = "AI summary generation failed."; @@ -51,7 +54,7 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen var ids = attachmentIds as IReadOnlyCollection ?? attachmentIds.ToList(); if (ids.Count == 0) { - throw new UserFriendlyException("Select at least one attachment to generate summaries."); + throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]); } var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index c9b41c00d7..95dbbba4b4 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -23,6 +23,11 @@ "AI:ApplicationAnalysisDisabled": "AI application analysis is not enabled.", "AI:ScoringDisabled": "AI scoring is not enabled.", "AI:GenerateAllDisabled": "AI generation is not enabled.", + "AI:NoAttachmentsAvailable": "No attachments are available to summarize.", + "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", + "AI:ScoringRequiresScoresheet": "AI scoring requires a configured scoresheet.", + "AI:ScoringRequiresScoresheetFields": "AI scoring requires a scoresheet with scoring fields.", + "AI:SelectAttachmentForSummaries": "Select at least one attachment to generate summaries.", "AIPrompts": "AI Prompts", "AIPrompt": "AI Prompt", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs index 1b099e5fe8..2fdcdbd2e1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -6,4 +6,9 @@ public static class AILocalizationKeys public const string ApplicationAnalysisDisabled = "AI:ApplicationAnalysisDisabled"; public const string ScoringDisabled = "AI:ScoringDisabled"; public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; + public const string NoAttachmentsAvailable = "AI:NoAttachmentsAvailable"; + public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; + public const string ScoringRequiresScoresheet = "AI:ScoringRequiresScoresheet"; + public const string ScoringRequiresScoresheetFields = "AI:ScoringRequiresScoresheetFields"; + public const string SelectAttachmentForSummaries = "AI:SelectAttachmentForSummaries"; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 46f348cf47..c2aa5f6a2c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -4,12 +4,14 @@ using System.Threading.Tasks; using Unity.AI.Automation; using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Medallion.Threading; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Localization; using Volo.Abp; using Volo.Abp.Domain.Repositories; using Volo.Abp.BackgroundJobs; @@ -27,7 +29,8 @@ public class ApplicationAIGenerationQueue( IFeatureChecker featureChecker, IAIRateLimiter aiRateLimiter, ICurrentUser currentUser, - ILogger logger) + ILogger logger, + IStringLocalizer localizer) : IApplicationAIGenerationQueue, ITransientDependency { public async Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null) @@ -221,10 +224,8 @@ await featureChecker.IsEnabledAsync(AIFeatures.Scoring), if (!hasEnabledStage) { - throw new UserFriendlyException("AI generation is not enabled."); + throw new UserFriendlyException(localizer[AILocalizationKeys.GenerateAllDisabled]); } - - throw new UserFriendlyException("No AI generation stages have the required input to run."); } private async Task MarkFailedBestEffortAsync(AIGenerationRequest request, Exception exception) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index 894458d34a..81574bb842 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Shouldly; @@ -8,6 +9,7 @@ using System.Threading.Tasks; using Unity.AI; using Unity.AI.Extraction; +using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; @@ -61,7 +63,8 @@ public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() aiService, prerequisiteValidator, new AIExecutionModeResolver(new ConfigurationBuilder().Build()), - NullLogger.Instance); + NullLogger.Instance, + Substitute.For>()); var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); @@ -109,7 +112,8 @@ public async Task GenerateAndSaveAsync_Should_Propagate_Cancellation() Substitute.For(), Substitute.For(), new AIExecutionModeResolver(new ConfigurationBuilder().Build()), - NullLogger.Instance); + NullLogger.Instance, + Substitute.For>()); await Should.ThrowAsync(() => service.GenerateAndSaveAsync(attachmentId, "v1", cancellationTokenSource.Token)); @@ -131,7 +135,8 @@ public async Task GenerateAndSaveAsync_Should_Reject_Empty_Attachment_List() aiService, prerequisiteValidator, new AIExecutionModeResolver(new ConfigurationBuilder().Build()), - NullLogger.Instance); + NullLogger.Instance, + Substitute.For>()); await Should.ThrowAsync(() => service.GenerateAndSaveAsync([], "v1")); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 71c1efd884..99e3a4c22f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -1,4 +1,5 @@ using Medallion.Threading; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using NSubstitute; using Shouldly; @@ -8,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.AI.Features; +using Unity.AI.Localization; using Unity.AI.RateLimit; using Unity.AI.Operations; using Unity.GrantManager.GrantApplications; @@ -128,6 +130,40 @@ public async Task QueueAllAIStagesAsync_Should_Not_Insert_Or_Enqueue_When_No_Ena await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueAllAIStagesAsync_Should_Not_Insert_Or_Enqueue_When_No_AI_Stages_Are_Enabled() + { + var applicationId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + + var backgroundJobManager = Substitute.For(); + var rateLimiter = Substitute.For(); + rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + var prerequisiteValidator = Substitute.For(); + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(AIFeatures.AttachmentSummaries).Returns(Task.FromResult(false)); + featureChecker.IsEnabledAsync(AIFeatures.ApplicationAnalysis).Returns(Task.FromResult(false)); + featureChecker.IsEnabledAsync(AIFeatures.Scoring).Returns(Task.FromResult(false)); + + var queue = CreateQueue( + backgroundJobManager, + repository, + rateLimiter, + prerequisiteValidator, + featureChecker); + + await Should.ThrowAsync(() => queue.QueueAllAIStagesAsync(applicationId, tenantId)); + + await prerequisiteValidator.DidNotReceive().EnsureAttachmentSummaryAvailableAsync(Arg.Any()); + await prerequisiteValidator.DidNotReceive().EnsureApplicationAnalysisAvailableAsync(Arg.Any()); + await prerequisiteValidator.DidNotReceive().EnsureApplicationScoringAvailableAsync(Arg.Any()); + await rateLimiter.DidNotReceive().EnsureAsync(); + await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task QueueApplicationAnalysisAsync_Should_Not_Enqueue_When_An_Active_Request_Already_Exists() { @@ -471,7 +507,8 @@ private static ApplicationAIGenerationQueue CreateQueue( featureChecker, rateLimiter, CreateCurrentUser(), - Substitute.For>()); + Substitute.For>(), + Substitute.For>()); } private static readonly Guid CreateQueueCurrentUserId = Guid.NewGuid(); From de86c7b8e766076a6904100669b22b650e2c4c78 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 3 Jun 2026 16:16:19 -0700 Subject: [PATCH 044/385] AB#32702 remove unused function --- .../Shared/Components/ReportingConfiguration/Default.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js index a75e0fc3d0..4d9505829f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js @@ -248,14 +248,6 @@ $(function () { } } - function refreshConfigurationState(correlationId) { - checkConfigurationExists(correlationId, function (exists) { - updateGenerateViewButtonVisibility(exists); - updateDeleteButtonVisibility(exists); - }); - refreshViewStatusWidget(correlationId, getCorrelationProvider()); - } - // Persist column visibility state per provider in localStorage function saveColvisState(provider) { if (!dataTable) return; From 5342f399c1448241d0a50da317e07d71ed272981 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 16:24:12 -0700 Subject: [PATCH 045/385] AB#33307 validate prompt template placeholders Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AI/Runtime/OpenAIPromptRenderer.cs | 15 +++++++- .../AI/Runtime/OpenAIPromptRendererTests.cs | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs index 627eed4511..5bd7070207 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -261,7 +261,7 @@ private static string RenderPromptTemplateInternal( var template = GetRequiredPromptTemplate(version, templateName); var replacements = new Dictionary(runtimeReplacements, StringComparer.Ordinal); var baseTemplateName = GetTemplateBaseName(templateName); - var placeholders = GetTemplatePlaceholders(template); + var placeholders = GetTemplatePlaceholders(template, version, templateName); foreach (var placeholder in placeholders) { @@ -352,9 +352,10 @@ private static string GetTemplateBaseName(string templateName) return templateName.Substring(0, separatorIndex); } - private static HashSet GetTemplatePlaceholders(string template) + private static HashSet GetTemplatePlaceholders(string template, string version, string templateName) { var placeholders = new HashSet(StringComparer.Ordinal); + var invalidPlaceholders = new List(); var searchIndex = 0; while (searchIndex < template.Length) @@ -376,10 +377,20 @@ private static HashSet GetTemplatePlaceholders(string template) { placeholders.Add(placeholder); } + else + { + invalidPlaceholders.Add(placeholder); + } searchIndex = end + 2; } + if (invalidPlaceholders.Count > 0) + { + throw new InvalidOperationException( + $"Invalid prompt placeholders in '{templateName}.txt' for prompt version '{version}': {string.Join(", ", invalidPlaceholders.OrderBy(item => item))}"); + } + return placeholders; } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs index a66da02aef..d20418f15e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIPromptRendererTests.cs @@ -1,5 +1,8 @@ using Shouldly; using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; using Unity.AI.Runtime; using Xunit; @@ -68,4 +71,39 @@ public void BuildApplicationAnalysisUserPrompt_Should_Not_Treat_FormIo_Mustache_ result.ShouldContain("{{ item.topic_source_id }}"); result.ShouldContain("{{FORMIO_VALUE}}"); } + + [Fact] + public void RenderPromptTemplate_Should_Reject_Non_Prompt_Placeholders_In_Template_Text() + { + var templateCache = GetPromptTemplateCache(); + templateCache["v1/unit-test.invalid"] = "Bad template token: {{ item.label }}"; + + var exception = Should.Throw(() => + RenderPromptTemplate("v1", "unit-test.invalid", new Dictionary())); + + exception.InnerException.ShouldBeOfType(); + exception.InnerException!.Message.ShouldContain("Invalid prompt placeholders"); + exception.InnerException.Message.ShouldContain("item.label"); + } + + private static string RenderPromptTemplate( + string version, + string templateName, + IReadOnlyDictionary runtimeReplacements) + { + var method = typeof(OpenAIPromptRenderer).GetMethod( + "RenderPromptTemplate", + BindingFlags.Static | BindingFlags.NonPublic); + method.ShouldNotBeNull(); + return (string)method.Invoke(null, [version, templateName, runtimeReplacements])!; + } + + private static ConcurrentDictionary GetPromptTemplateCache() + { + var field = typeof(OpenAIPromptRenderer).GetField( + "PromptTemplateCache", + BindingFlags.Static | BindingFlags.NonPublic); + field.ShouldNotBeNull(); + return (ConcurrentDictionary)field.GetValue(null)!; + } } From 29b1ca574252590da619279f811399ecf400e83a Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 3 Jun 2026 16:50:55 -0700 Subject: [PATCH 046/385] AB#33319 harden AI status polling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AI/Runtime/OpenAITransportService.cs | 5 ++++- .../RateLimit/AIRateLimiter.cs | 12 ++++------ .../AI/RateLimit/AIRateLimiterTests.cs | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs index b3cafcae36..1428408375 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -93,7 +93,10 @@ public async Task GenerateSummaryAsync( if (!response.IsSuccessStatusCode) { - _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); + _logger.LogError( + "OpenAI API request failed with status {StatusCode}. Response body length: {ResponseLength}.", + response.StatusCode, + responseContent.Length); return MapFailureOutcome(response.StatusCode, providerResponse); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index fe5a68b569..2f0f06f309 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -86,15 +86,11 @@ public virtual async Task GetStateAsync() return new AIRateLimitStateDto { RetryAfterSeconds = 0, IsGenerating = false }; } - var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + userId); - using (await userLock.AcquireAsync()) + return new AIRateLimitStateDto { - return new AIRateLimitStateDto - { - RetryAfterSeconds = await GetRemainingSecondsAsync(userId), - IsGenerating = await HasActiveGenerationAsync() - }; - } + RetryAfterSeconds = await GetRemainingSecondsAsync(userId), + IsGenerating = await HasActiveGenerationAsync() + }; } private async Task HasActiveGenerationAsync() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs index 85fae0d005..ab35d12f62 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/RateLimit/AIRateLimiterTests.cs @@ -57,6 +57,17 @@ public async Task GetStateAsync_Returns_Generating_When_ActivityProvider_HasActi state.IsGenerating.ShouldBeTrue(); } + [Fact] + public async Task GetStateAsync_Does_Not_Acquire_Cooldown_Lock() + { + var lockProvider = new CountingDistributedLockProvider(); + var limiter = new AIRateLimiter(_cache, _currentUser, _configuration, lockProvider, []); + + await limiter.GetStateAsync(); + + lockProvider.CreatedLockCount.ShouldBe(0); + } + [Fact] public async Task EnsureAsync_AllowsThrough_When_NoCooldown() { @@ -167,6 +178,17 @@ private sealed class TestDistributedLockProvider : IDistributedLockProvider public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); } + private sealed class CountingDistributedLockProvider : IDistributedLockProvider + { + public int CreatedLockCount { get; private set; } + + public IDistributedLock CreateLock(string name) + { + CreatedLockCount++; + return new TestDistributedLock(name); + } + } + private sealed class TestDistributedLock(string name) : IDistributedLock { private static readonly SemaphoreSlim Gate = new(1, 1); From c91cae635170f3bf99db73b3489d61d5e5b5c29c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 4 Jun 2026 08:30:51 -0700 Subject: [PATCH 047/385] AB#31529 update WS allowed key and label lengths --- .../CheckboxGroupDefinitionWidget/Default.cshtml | 8 ++++---- .../CheckboxGroupDefinitionWidget/Default.js | 16 ++++++++-------- .../RadioDefinitionWidget/Default.cshtml | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.cshtml index 3b7a384844..40e498b845 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.cshtml @@ -22,8 +22,8 @@ @foreach (var option in Model.CheckboxOptions) { - - + + - - + + - - ` - } - function bindCancelOption() { let cancel = $('#cancel-checkbox-option-btn'); if (cancel) { @@ -101,3 +94,10 @@ $(function () { ); }); +function getCheckboxGroupRowTemplate(key, label) { + return ` + + ` +} + diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml index 1f5295287b..d70ea84c66 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml @@ -57,7 +57,7 @@ newOption.id = optionId; newOption.innerHTML = ` - + `; document.getElementById("radioOptions").appendChild(newOption); }); From c548ef5395c0064f52968504358476de6b2252e5 Mon Sep 17 00:00:00 2001 From: Andre Goncalves <98196495+AndreGAot@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:10:56 -0700 Subject: [PATCH 048/385] Potential fix for pull request finding 'CodeQL / DOM text reinterpreted as HTML' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../CheckboxGroupDefinitionWidget/Default.js | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.js index 781bd7657e..6f2ed9251d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxGroupDefinitionWidget/Default.js @@ -95,9 +95,43 @@ $(function () { }); function getCheckboxGroupRowTemplate(key, label) { - return ` - - ` + let $row = $(''); + + let $keyInput = $('', { + type: 'text', + class: 'form-control key-input', + name: 'CheckboxKeys', + minlength: 1, + maxlength: 60, + required: true, + id: 'new-chk-key-' + key + }).val(key); + + let $labelInput = $('', { + type: 'text', + class: 'form-control', + name: 'CheckboxLabels', + maxlength: 100, + required: true, + id: 'new-chk-label-' + key + }).val(label); + + let $button = $(' + `; } From f9f5188a5099f6d2181623149ef27868118c9fb2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 4 Jun 2026 14:18:04 -0700 Subject: [PATCH 050/385] 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 e7b34704c1cf6634ddd56cd13b3e513ed36ea7cc Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 4 Jun 2026 15:27:44 -0700 Subject: [PATCH 051/385] AB#33313 only log successful AI operation outputs - Move LogPromptOutputAsync calls to only execute after verifying result.Outcome is Success - Prevents error responses from logging full response bodies to prompt log files - Ensures AC compliance: 'Error logs include safe failure context without dumping full model responses' - Applied to all three AI operations: ApplicationAnalysis, AttachmentSummary, ApplicationScoring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index bdf4c38faf..dd3543b66d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -99,13 +99,13 @@ public async Task GenerateApplicationAnalysisAsync( AIProviderPayloadValidator.IsValidApplicationAnalysisJson, "application analysis", cancellationToken); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { return new ApplicationAnalysisResponse(); } + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput, cancellationToken); return OpenAIResponseParser.ParseApplicationAnalysisResponse(result.Content); } @@ -154,7 +154,6 @@ public async Task GenerateAttachmentSummaryAsync(Atta AIProviderPayloadValidator.IsValidAttachmentSummaryText, "attachment summary", cancellationToken); - await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { @@ -164,6 +163,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta }; } + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); return new AttachmentSummaryResponse { Summary = ExtractSummaryFromJson(result.Content) @@ -230,13 +230,13 @@ public async Task GenerateApplicationScoringAsync(Ap content => AIProviderPayloadValidator.IsValidApplicationScoringJson(content, section), $"application scoring section {request.SectionName}", cancellationToken); - await LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { return new ApplicationScoringResponse(); } + await LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); } catch (OperationCanceledException) From 2582392cf671ed3abed61d623a2fe1d468756b3e Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 4 Jun 2026 15:31:25 -0700 Subject: [PATCH 052/385] AB#33270 update docs --- documentation/reporting/reporting-configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/reporting/reporting-configuration.md b/documentation/reporting/reporting-configuration.md index 7deb364e05..9d607119b1 100644 --- a/documentation/reporting/reporting-configuration.md +++ b/documentation/reporting/reporting-configuration.md @@ -162,6 +162,7 @@ The **Path** column shows the data-centric navigation path used by the PostgreSQ | Internal `path` (not displayed) | Internal `typePath` | Displayed "Path" (DataPath) | Why | | -------------------------------- | ------------------- | --------------------------- | --- | | `panel1->firstName` | `panel->textfield` | `firstName` | `panel` is a container — stripped | +| `tab1->firstName` | `tab->textfield` | `firstName` | `tab` is a container — stripped | | `datagrid1->itemName` | `datagrid->textfield` | `datagrid1->itemName` | `datagrid` is kept — it IS in the JSON | | `checkboxField->optionA` | `simplecheckboxes->option` | `checkboxField->optionA` | Both segments present in data | From cd30b612352aef7b1da759089923ff6f37b316bb Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 4 Jun 2026 15:47:10 -0700 Subject: [PATCH 053/385] AB#33307 retrigger build pipeline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From b9591f5608f0c20092fdf4f0532b37219a117070 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Thu, 4 Jun 2026 15:58:04 -0700 Subject: [PATCH 054/385] AB#33331: Fix broken links in the Payments page --- .../PaymentRequests/PaymentRequestDto.cs | 1 + .../Services/PaymentRequestQueryManager.cs | 21 ++++++++++++++++++- .../Pages/PaymentRequests/Index.js | 8 +++---- ...RequestQueryManager_PaymentRollup_Tests.cs | 3 ++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index b6ef9d46d7..bdab6921b9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -47,6 +47,7 @@ public class PaymentRequestDto : AuditedEntityDto, IMultiTenant public DateTime? FsbNotificationSentDate { get; set; } public string? FsbApNotified { get; set; } + public Guid? ApplicantId { get; set; } public Guid? TenantId { get; set; } public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index c9b7f6e813..d0f0ca4d25 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.GrantManager.Applications; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Suppliers; using Unity.Payments.Enums; @@ -19,7 +20,8 @@ public class PaymentRequestQueryManager( ISiteRepository siteRepository, IExternalUserLookupServiceProvider externalUserLookupServiceProvider, CasPaymentRequestCoordinator casPaymentRequestCoordinator, - IObjectMapper objectMapper) : DomainService, IPaymentRequestQueryManager + IObjectMapper objectMapper, + IApplicationRepository applicationRepository) : DomainService, IPaymentRequestQueryManager { public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId) { @@ -128,6 +130,23 @@ public async Task> MapToDtoAndLoadDetailsAsync(List, List>(paymentsList); + // Batch-fetch applicant IDs for all unique correlation IDs (application IDs) + var applicationIds = paymentDtos + .Select(p => p.CorrelationId) + .Distinct() + .ToArray(); + + var applications = await applicationRepository.GetListByIdsAsync(applicationIds) ?? []; + var applicantIdByApplicationId = applications.ToDictionary(a => a.Id, a => a.ApplicantId); + + foreach (var paymentDto in paymentDtos) + { + if (applicantIdByApplicationId.TryGetValue(paymentDto.CorrelationId, out var applicantId)) + { + paymentDto.ApplicantId = applicantId; + } + } + // Flatten all DecisionUserIds from ExpenseApprovals across all PaymentRequestDtos List paymentRequesterIds = [.. paymentDtos .Select(payment => payment.CreatorId) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index f2deb13b7c..7b858aa3cc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -389,7 +389,7 @@ $(function () { const safeApplicantName = $.fn.dataTable.render.text().display(applicantName); if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { - const applicantId = row?.id; + const applicantId = row?.applicantId; const isGuid = applicantId && guidPattern.test(applicantId); if (isGuid) { @@ -421,11 +421,11 @@ $(function () { const safeCode = $.fn.dataTable.render.text().display(code); if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { - const applicantId = row?.id; - const isGuid = applicantId && guidPattern.test(applicantId); + const applicationId = row?.correlationId; + const isGuid = applicationId && guidPattern.test(applicationId); if (isGuid) { - return `${safeCode}`; + return `${safeCode}`; } return safeCode; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs index 49c2c4bd1d..f4e2af138f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs @@ -404,7 +404,8 @@ private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepositor Substitute.For(), Substitute.For(), null!, // CasPaymentRequestCoordinator - not used by Rollup methods - null! // IObjectMapper - not used by Rollup methods + null!, // IObjectMapper - not used by Rollup methods + null! // IApplicationRepository - not used by Rollup methods ); } From 38f768547ddc05f979cc5b82de1a2f56a6b196cc Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 8 May 2026 09:31:30 -0700 Subject: [PATCH 055/385] WIP extract AI prompt input builders --- .../Operations/ApplicationAnalysisService.cs | 139 +---------------- .../Operations/ApplicationScoringService.cs | 9 +- .../AI/Prompts/PromptDataPayloadBuilder.cs | 141 +++++++++++++++++- 3 files changed, 146 insertions(+), 143 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs index aabdf61e82..1d56edc349 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs @@ -1,12 +1,8 @@ using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Unity.AI.Models; using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Runtime; @@ -24,12 +20,6 @@ public class ApplicationAnalysisService( IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, ILogger logger) : IApplicationAnalysisService, ITransientDependency { - private const string ComponentsKey = "components"; - private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) - { - "applicantAgent" - }; - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) { await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId); @@ -39,20 +29,11 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); - var attachmentSummaries = attachments - .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) - .Select(a => new AIAttachmentItem - { - Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), - Summary = a.AISummary!.Trim() - }) - .ToList(); - - object formFieldConfiguration = new { message = "Form configuration not available." }; - if (formSubmission?.ApplicationFormVersionId != null) - { - formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); - } + var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments); + var formFieldConfiguration = await PromptDataPayloadBuilder.BuildFormFieldConfigurationAsync( + applicationFormVersionRepository, + formSubmission?.ApplicationFormVersionId, + logger); var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest { @@ -87,115 +68,5 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro } } - private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) - { - try - { - var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); - if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) - { - return new { message = "Form configuration not available." }; - } - - var schema = JObject.Parse(formVersion.FormSchema); - var components = schema[ComponentsKey] as JArray; - if (components == null || components.Count == 0) - { - return new { message = "No form fields configured." }; - } - - var requiredFields = new List(); - var optionalFields = new List(); - ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); - - return new - { - required_fields = requiredFields, - optional_fields = optionalFields - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); - return new { message = "Form configuration could not be extracted." }; - } - } - - private static void ExtractFieldRequirements(JArray components, List requiredFields, List optionalFields, string currentPath) - { - foreach (var component in components.OfType()) - { - var key = component["key"]?.ToString(); - var label = component["label"]?.ToString(); - var type = component["type"]?.ToString(); - var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; - - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) - { - ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); - continue; - } - - var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; - var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; - var validate = component["validate"] as JObject; - var isRequired = validate?["required"]?.Value() ?? false; - - if (component["input"]?.Value() == true) - { - if (isRequired) requiredFields.Add(fullPath); - else optionalFields.Add(fullPath); - } - - ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath); - } - } - - private static void ProcessNestedFieldRequirements(JObject component, string? type, List requiredFields, List optionalFields, string currentPath) - { - switch (type) - { - case "panel": - case "simplepanel": - case "fieldset": - case "well": - case "container": - case "datagrid": - case "table": - if (component[ComponentsKey] is JArray nestedComponents) - { - ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath); - } - break; - case "columns": - case "simplecols2": - case "simplecols3": - case "simplecols4": - if (component["columns"] is JArray columns) - { - foreach (var column in columns.OfType()) - { - if (column[ComponentsKey] is JArray columnComponents) - { - ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath); - } - } - } - break; - case "tabs": - case "simpletabs": - if (component[ComponentsKey] is JArray tabs) - { - foreach (var tab in tabs.OfType()) - { - if (tab[ComponentsKey] is JArray tabComponents) - { - ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); - } - } - } - break; - } - } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index 9dfeb35348..05705d3b31 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -46,14 +46,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro } var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); - var attachmentSummaries = attachments - .Where(a => !string.IsNullOrEmpty(a.AISummary)) - .Select(a => new AIAttachmentItem - { - Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), - Summary = a.AISummary!.Trim() - }) - .ToList(); + var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs index 315a26fc92..16a3f4c921 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Models; using Unity.GrantManager.Applications; namespace Unity.AI.Prompts @@ -33,6 +35,13 @@ internal static class PromptDataPayloadBuilder "simpleseparator" }; + private const string ComponentsKey = "components"; + + private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + { + "applicantAgent" + }; + public static JsonElement BuildPromptDataPayload( Application application, ApplicationFormSubmission? formSubmission, @@ -56,6 +65,60 @@ public static JsonElement BuildPromptDataPayload( return JsonSerializer.SerializeToElement(fallbackPayload); } + public static List BuildAttachmentSummaries(IEnumerable attachments) + { + return attachments + .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.AISummary!.Trim() + }) + .ToList(); + } + + public static async Task BuildFormFieldConfigurationAsync( + IApplicationFormVersionRepository applicationFormVersionRepository, + Guid? formVersionId, + ILogger logger) + { + if (formVersionId == null) + { + return new { message = "Form configuration not available." }; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + { + return new { message = "Form configuration not available." }; + } + + var schema = JObject.Parse(formVersion.FormSchema); + var components = schema[ComponentsKey] as JArray; + if (components == null || components.Count == 0) + { + return new { message = "No form fields configured." }; + } + + var requiredFields = new List(); + var optionalFields = new List(); + ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); + + return new + { + required_fields = requiredFields, + optional_fields = optionalFields + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); + return new { message = "Form configuration could not be extracted." }; + } + } + private static object BuildFallbackPromptDataPayload(Application application) { var notSpecified = "Not specified"; @@ -164,7 +227,7 @@ private static HashSet ExtractAllowedSchemaKeys(string? formSchema) try { var schema = JObject.Parse(formSchema); - if (schema["components"] is not JArray components) + if (schema[ComponentsKey] is not JArray components) { return new HashSet(StringComparer.OrdinalIgnoreCase); } @@ -217,5 +280,81 @@ private static void ProcessNestedSchemaComponents(JObject component, HashSet requiredFields, List optionalFields, string currentPath) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var label = component["label"]?.ToString(); + var type = component["type"]?.ToString(); + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || NonDataComponentTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) + { + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); + continue; + } + + var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; + var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; + var validate = component["validate"] as JObject; + var isRequired = validate?["required"]?.Value() ?? false; + + if (component["input"]?.Value() == true) + { + if (isRequired) requiredFields.Add(fullPath); + else optionalFields.Add(fullPath); + } + + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath); + } + } + + private static void ProcessNestedFieldRequirements(JObject component, string? type, List requiredFields, List optionalFields, string currentPath) + { + switch (type) + { + case "panel": + case "simplepanel": + case "fieldset": + case "well": + case "container": + case "datagrid": + case "table": + if (component[ComponentsKey] is JArray nestedComponents) + { + ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath); + } + break; + case "columns": + case "simplecols2": + case "simplecols3": + case "simplecols4": + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column[ComponentsKey] is JArray columnComponents) + { + ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + case "tabs": + case "simpletabs": + if (component[ComponentsKey] is JArray tabs) + { + foreach (var tab in tabs.OfType()) + { + if (tab[ComponentsKey] is JArray tabComponents) + { + ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + } + } } } From cd6559b07dac5cc98a39387139b8906e35c0f866 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 4 Jun 2026 16:03:35 -0700 Subject: [PATCH 056/385] AB#33263 add prompt payload builder coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationAnalysisServiceTests.cs | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs new file mode 100644 index 0000000000..700e59e242 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs @@ -0,0 +1,185 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI; +using Unity.AI.Operations; +using Unity.AI.Requests; +using Unity.AI.Responses; +using Unity.GrantManager.Applications; +using Volo.Abp.Domain.Entities; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.AI.Operations; + +public class ApplicationAnalysisServiceTests : GrantManagerApplicationTestBase +{ + public ApplicationAnalysisServiceTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + } + + [Fact] + public async Task RegenerateAndSaveAsync_Builds_Filtered_Prompt_Payload_From_Form_Submission() + { + var applicationId = Guid.NewGuid(); + var formVersionId = Guid.NewGuid(); + ApplicationAnalysisRequest? capturedRequest = null; + var application = WithId(new Application + { + ApplicationFormId = Guid.NewGuid(), + ProjectName = "Fallback project", + ReferenceNo = "REF-001", + RequestedAmount = 100, + TotalProjectBudget = 200, + SubmissionDate = new DateTime(2026, 1, 2) + }, applicationId); + var submission = new ApplicationFormSubmission + { + ApplicationId = applicationId, + ApplicationFormVersionId = formVersionId, + Submission = """ + { + "data": { + "projectName": "Submitted project", + "requestedAmount": 12345, + "metadata": { "timezone": "utc" }, + "files": [{ "name": "large.pdf" }], + "ignoredField": "not in schema" + } + } + """ + }; + var formVersion = new ApplicationFormVersion + { + FormSchema = """ + { + "components": [ + { "key": "projectName", "label": "Project Name", "type": "textfield", "input": true, "validate": { "required": true } }, + { "key": "requestedAmount", "label": "Requested Amount", "type": "number", "input": true }, + { "key": "applicantAgent", "label": "Applicant Agent", "type": "textfield", "input": true }, + { "key": "ignoredField", "label": "Ignored Field", "type": "html", "input": false } + ] + } + """ + }; + + var applicationRepository = Substitute.For(); + applicationRepository.GetAsync(applicationId).Returns(application); + + var submissionRepository = Substitute.For(); + submissionRepository.GetByApplicationAsync(applicationId).Returns(submission); + + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + + var attachmentRepository = Substitute.For(); + attachmentRepository + .GetListAsync(Arg.Any>>()) + .Returns([ + new ApplicationChefsFileAttachment + { + FileName = " summary.pdf ", + AISummary = " Summary text " + }, + new ApplicationChefsFileAttachment + { + FileName = "empty.txt", + AISummary = " " + } + ]); + + var aiService = Substitute.For(); + aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) + .Returns(new ApplicationAnalysisResponse { Decision = "ok" }); + + var service = new ApplicationAnalysisService( + applicationRepository, + submissionRepository, + formVersionRepository, + attachmentRepository, + aiService, + NullLogger.Instance); + + var result = await service.RegenerateAndSaveAsync(applicationId, "v1"); + + result.ShouldContain("\"Decision\": \"ok\""); + capturedRequest.ShouldNotBeNull(); + capturedRequest.PromptVersion.ShouldBe("v1"); + capturedRequest.Attachments.Count.ShouldBe(1); + capturedRequest.Attachments[0].Name.ShouldBe("summary.pdf"); + capturedRequest.Attachments[0].Summary.ShouldBe("Summary text"); + + capturedRequest.Data.GetProperty("projectName").GetString().ShouldBe("Submitted project"); + capturedRequest.Data.GetProperty("requestedAmount").GetInt32().ShouldBe(12345); + capturedRequest.Data.TryGetProperty("metadata", out _).ShouldBeFalse(); + capturedRequest.Data.TryGetProperty("files", out _).ShouldBeFalse(); + capturedRequest.Data.TryGetProperty("ignoredField", out _).ShouldBeFalse(); + + var schemaJson = JsonSerializer.Serialize(capturedRequest.Schema); + schemaJson.ShouldContain("Project Name (projectName)"); + schemaJson.ShouldContain("Requested Amount (requestedAmount)"); + schemaJson.ShouldNotContain("Applicant Agent (applicantAgent)"); + await applicationRepository.Received(1).UpdateAsync(application); + } + + [Fact] + public async Task RegenerateAndSaveAsync_Falls_Back_To_Application_Data_When_Submission_Is_Missing() + { + var applicationId = Guid.NewGuid(); + var application = WithId(new Application + { + ProjectName = "Fallback project", + ReferenceNo = "REF-002", + RequestedAmount = 1500, + TotalProjectBudget = 3000, + ProjectSummary = "Fallback summary", + SubmissionDate = new DateTime(2026, 2, 3) + }, applicationId); + ApplicationAnalysisRequest? capturedRequest = null; + + var applicationRepository = Substitute.For(); + applicationRepository.GetAsync(applicationId).Returns(application); + + var submissionRepository = Substitute.For(); + submissionRepository.GetByApplicationAsync(applicationId).Returns((ApplicationFormSubmission?)null); + + var formVersionRepository = Substitute.For(); + var attachmentRepository = Substitute.For(); + attachmentRepository + .GetListAsync(Arg.Any>>()) + .Returns([]); + + var aiService = Substitute.For(); + aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) + .Returns(new ApplicationAnalysisResponse()); + + var service = new ApplicationAnalysisService( + applicationRepository, + submissionRepository, + formVersionRepository, + attachmentRepository, + aiService, + NullLogger.Instance); + + await service.RegenerateAndSaveAsync(applicationId); + + capturedRequest.ShouldNotBeNull(); + capturedRequest.Data.GetProperty("project_name").GetString().ShouldBe("Fallback project"); + capturedRequest.Data.GetProperty("reference_number").GetString().ShouldBe("REF-002"); + capturedRequest.Data.GetProperty("requested_amount").GetDecimal().ShouldBe(1500); + capturedRequest.Attachments.ShouldBeEmpty(); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + typeof(Entity) + .GetProperty(nameof(Entity.Id))! + .SetValue(entity, id); + return entity; + } +} From 9d8eb96ed3d2f68830553c771a19d8d65499a821 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 4 Jun 2026 16:06:18 -0700 Subject: [PATCH 057/385] AB#33263 align prompt payload tests with prerequisites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AI/Operations/ApplicationAnalysisServiceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs index 700e59e242..42c27d35e6 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs @@ -96,6 +96,7 @@ public async Task RegenerateAndSaveAsync_Builds_Filtered_Prompt_Payload_From_For var aiService = Substitute.For(); aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) .Returns(new ApplicationAnalysisResponse { Decision = "ok" }); + var prerequisiteValidator = Substitute.For(); var service = new ApplicationAnalysisService( applicationRepository, @@ -103,6 +104,7 @@ public async Task RegenerateAndSaveAsync_Builds_Filtered_Prompt_Payload_From_For formVersionRepository, attachmentRepository, aiService, + prerequisiteValidator, NullLogger.Instance); var result = await service.RegenerateAndSaveAsync(applicationId, "v1"); @@ -157,6 +159,7 @@ public async Task RegenerateAndSaveAsync_Falls_Back_To_Application_Data_When_Sub var aiService = Substitute.For(); aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) .Returns(new ApplicationAnalysisResponse()); + var prerequisiteValidator = Substitute.For(); var service = new ApplicationAnalysisService( applicationRepository, @@ -164,6 +167,7 @@ public async Task RegenerateAndSaveAsync_Falls_Back_To_Application_Data_When_Sub formVersionRepository, attachmentRepository, aiService, + prerequisiteValidator, NullLogger.Instance); await service.RegenerateAndSaveAsync(applicationId); From a6f26ab940c1cc878809725337bd3a3c105d711b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 4 Jun 2026 16:13:18 -0700 Subject: [PATCH 058/385] AB#33263 preserve prompt builder behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Operations/ApplicationScoringService.cs | 4 +++- .../AI/Prompts/PromptDataPayloadBuilder.cs | 20 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index 05705d3b31..3a4ee6f7d2 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -46,7 +46,9 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro } var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); - var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments); + var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries( + attachments, + excludeWhitespaceOnlySummaries: false); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs index 16a3f4c921..f2303ce528 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -35,6 +35,16 @@ internal static class PromptDataPayloadBuilder "simpleseparator" }; + private static readonly HashSet NonFieldRequirementComponentTypes = + [ + "button", + "simplebuttonadvanced", + "html", + "htmlelement", + "content", + "simpleseparator" + ]; + private const string ComponentsKey = "components"; private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) @@ -65,10 +75,14 @@ public static JsonElement BuildPromptDataPayload( return JsonSerializer.SerializeToElement(fallbackPayload); } - public static List BuildAttachmentSummaries(IEnumerable attachments) + public static List BuildAttachmentSummaries( + IEnumerable attachments, + bool excludeWhitespaceOnlySummaries = true) { return attachments - .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) + .Where(a => excludeWhitespaceOnlySummaries + ? !string.IsNullOrWhiteSpace(a.AISummary) + : !string.IsNullOrEmpty(a.AISummary)) .Select(a => new AIAttachmentItem { Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), @@ -289,7 +303,7 @@ private static void ExtractFieldRequirements(JArray components, List req var label = component["label"]?.ToString(); var type = component["type"]?.ToString(); - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || NonDataComponentTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || NonFieldRequirementComponentTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) { ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); continue; From 2a981e9a4d2d3a123e53ee6db9f365110e7aca60 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Thu, 4 Jun 2026 13:01:06 -0700 Subject: [PATCH 059/385] AB#33099 - Implemented domain and application layer --- .gitignore | 1 + .../Scoresheets/IScoresheetAppService.cs | 1 + .../Scoresheets/ScoresheetDto.cs | 1 + .../Domain/Scoresheets/Scoresheet.cs | 7 +++++++ .../Scoresheets/ScoresheetAppService.cs | 8 ++++++++ .../src/Unity.Flex.Shared/Localization/Flex/en.json | 2 ++ 6 files changed, 20 insertions(+) diff --git a/.gitignore b/.gitignore index cf56f6bd31..8cae5022ff 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ bld/ # Visual Studio cache/options directory .vs/ .vscode/ +*.lscache # Uncomment if you have tasks that create the project's static files in wwwroot **/wwwroot/lib/ diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/IScoresheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/IScoresheetAppService.cs index 5faabc4abe..1d7c9b6e20 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/IScoresheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/IScoresheetAppService.cs @@ -22,6 +22,7 @@ public interface IScoresheetAppService : IApplicationService Task> GetSelectListQuestionsAsync(List questionIdsToCheck); Task ValidateChangeableScoresheet(Guid scoresheetId); Task PublishScoresheetAsync(Guid id); + Task ArchiveAsync(Guid id, bool archive); Task ExportScoresheet(Guid scoresheetId); Task ImportScoresheetAsync(ScoresheetImportDto scoresheetImportDto); Task SaveScoresheetOrder(List scoresheetIds); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetDto.cs index 15f36c75db..ff455aa482 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Scoresheets/ScoresheetDto.cs @@ -10,6 +10,7 @@ public class ScoresheetDto : ExtensibleFullAuditedEntityDto public string Name { get; set; } = string.Empty; public uint Version { get; set; } public bool Published { get; set; } + public bool IsArchived { get; set; } public Collection Sections { get; private set; } = []; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Scoresheets/Scoresheet.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Scoresheets/Scoresheet.cs index 73c693b271..f271ad0aef 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Scoresheets/Scoresheet.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/Scoresheets/Scoresheet.cs @@ -17,6 +17,7 @@ public class Scoresheet : FullAuditedAggregateRoot, IMultiTenant, IReporta public virtual uint Version { get; set; } = 1; public virtual uint Order { get; set; } = 0; public virtual bool Published { get; set; } = false; + public virtual bool IsArchived { get; private set; } = false; public Guid? TenantId { get; set; } // For reporting purposes @@ -88,6 +89,12 @@ public Scoresheet IncreaseVersion() return this; } + public Scoresheet SetArchived(bool archived) + { + IsArchived = archived; + return this; + } + public Scoresheet SetName(string name) { this.Name = name; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs index da85afbcf9..e72da905f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs @@ -197,6 +197,14 @@ public async Task ValidateChangeableScoresheet(Guid scoresheetId) } } + public virtual async Task ArchiveAsync(Guid id, bool archive) + { + var scoresheet = await scoresheetRepository.GetAsync(id); + scoresheet.SetArchived(archive); + await scoresheetRepository.UpdateAsync(scoresheet); + return true; + } + public async Task PublishScoresheetAsync(Guid id) { var scoresheet = await scoresheetRepository.GetAsync(id); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json index eb026f2630..c49d667e9d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json @@ -16,6 +16,8 @@ "Scoresheet:Configuration:CloneScoresheetButtonText": "CLONE", "Scoresheet:Configuration:PublishScoresheetButtonText": "PUBLISH", "Scoresheet:Configuration:ExportScoresheetButtonText": "EXPORT", + "Scoresheet:Configuration:ArchiveScoresheetButtonText": "ARCHIVE", + "Scoresheet:Configuration:UnarchiveScoresheetButtonText": "UNARCHIVE", "Scoresheet:Configuration:SaveChangesButtonText": "SAVE CHANGES", "Scoresheet:Configuration:DiscardChangesButtonText": "DISCARD CHANGES", "Worksheet:Configuration:SaveChangesButtonText": "SAVE CHANGES", From 23f1e0229e0725d469ea10ac62ca0683215d7041 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Thu, 4 Jun 2026 13:11:29 -0700 Subject: [PATCH 060/385] AB#33099 - Added EF --- ...305_AddIsArchivedToScoresheets.Designer.cs | 5040 +++++++++++++++++ ...260604200305_AddIsArchivedToScoresheets.cs | 31 + .../GrantTenantDbContextModelSnapshot.cs | 3 + 3 files changed, 5074 insertions(+) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.Designer.cs new file mode 100644 index 0000000000..64d647d7e9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.Designer.cs @@ -0,0 +1,5040 @@ +// +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("20260604200305_AddIsArchivedToScoresheets")] + partial class AddIsArchivedToScoresheets + { + /// + 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("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("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("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("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/20260604200305_AddIsArchivedToScoresheets.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.cs new file mode 100644 index 0000000000..0704b9e28c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260604200305_AddIsArchivedToScoresheets.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AddIsArchivedToScoresheets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsArchived", + schema: "Flex", + table: "Scoresheets", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsArchived", + schema: "Flex", + table: "Scoresheets"); + } + } +} 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 62fbafaadd..aeb709113a 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 @@ -270,6 +270,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("ExtraProperties"); + b.Property("IsArchived") + .HasColumnType("boolean"); + b.Property("IsDeleted") .ValueGeneratedOnAdd() .HasColumnType("boolean") From 4b704796ff4b0b12704532fab7e78d3956a32db1 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Thu, 4 Jun 2026 16:49:09 -0700 Subject: [PATCH 061/385] AB#33099 - Implemented frontend --- .../Localization/Flex/en.json | 8 +++ .../Pages/ScoresheetConfiguration/Index.js | 53 +++++++++++++++++-- .../Components/Scoresheet/Default.cshtml | 44 +++++++++------ .../Components/Scoresheet/Scoresheet.js | 6 +++ .../ScoresheetConfiguration/Default.cshtml | 1 + 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json index c49d667e9d..abec19b96e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json @@ -18,6 +18,14 @@ "Scoresheet:Configuration:ExportScoresheetButtonText": "EXPORT", "Scoresheet:Configuration:ArchiveScoresheetButtonText": "ARCHIVE", "Scoresheet:Configuration:UnarchiveScoresheetButtonText": "UNARCHIVE", + "Scoresheet:Configuration:ArchiveModal.Message": "Archived scoresheets are hidden from the default view. You can find them using the Archived filter.", + "Scoresheet:Configuration:UnarchiveModal.Message": "Unarchived scoresheets are shown in the default view. You can find them using the All filter.", + "Scoresheet:Configuration:ArchivedSuccess": "Scoresheet has been archived.", + "Scoresheet:Configuration:UnarchivedSuccess": "Scoresheet has been unarchived.", + "Scoresheet:Configuration:ArchiveFailed": "Failed to archive the scoresheet.", + "Scoresheet:Configuration:UnarchiveFailed": "Failed to unarchive the scoresheet.", + "Scoresheet:Configuration:ArchiveModal.Confirm": "Confirm", + "Scoresheet:Configuration:ArchiveModal.Cancel": "Cancel", "Scoresheet:Configuration:SaveChangesButtonText": "SAVE CHANGES", "Scoresheet:Configuration:DiscardChangesButtonText": "DISCARD CHANGES", "Worksheet:Configuration:SaveChangesButtonText": "SAVE CHANGES", diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/Index.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/Index.js index b78866304a..681b5597f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/ScoresheetConfiguration/Index.js @@ -44,7 +44,7 @@ $(function () { }); } - // --- Scoresheet name filter & published toggle --- + // --- Scoresheet name filter, published toggle, and archived toggle --- $(document).on('input', '#scoresheet-name-filter', applyScoresheetFilters); $(document).on('click', '#scoresheet-published-toggle button', function () { @@ -53,6 +53,12 @@ $(function () { applyScoresheetFilters(); }); + $(document).on('click', '.archive-scoresheet-btn', function () { + const scoresheetId = $(this).data('scoresheet-id'); + const isArchived = $(this).data('is-archived') === true || $(this).data('is-archived') === 'true'; + handleArchiveScoresheet(scoresheetId, isArchived); + }); + applyScoresheetFilters(); }); @@ -66,12 +72,17 @@ function applyScoresheetFilters() { const title = $item.find('.scoresheet-title').text().toLowerCase(); const name = $item.find('.scoresheet-name').text().toLowerCase(); const isPublished = !$item.find('.scoresheet-published-icon').hasClass('hidden'); + const isArchived = $item.data('is-archived') === true || $item.data('is-archived') === 'true'; const matchesText = !searchText || title.includes(searchText) || name.includes(searchText); const matchesFilter = - publishedFilter === 'all' || - (publishedFilter === 'published' && isPublished) || - (publishedFilter === 'unpublished' && !isPublished); + publishedFilter === 'archived' + ? isArchived + : !isArchived && ( + publishedFilter === 'all' || + (publishedFilter === 'published' && isPublished) || + (publishedFilter === 'unpublished' && !isPublished) + ); const visible = matchesText && matchesFilter; $item.toggle(visible); @@ -81,6 +92,40 @@ function applyScoresheetFilters() { $('#scoresheet-no-results').toggle(visibleCount === 0); } +function handleArchiveScoresheet(scoresheetId, currentlyArchived) { + const l = abp.localization.getResource('Flex'); + const label = currentlyArchived ? l('Scoresheet:Configuration:UnarchiveScoresheetButtonText') : l('Scoresheet:Configuration:ArchiveScoresheetButtonText'); + const message = currentlyArchived + ? l('Scoresheet:Configuration:UnarchiveModal.Message') + : l('Scoresheet:Configuration:ArchiveModal.Message'); + + Swal.fire({ + title: label + '?', + text: message, + showCancelButton: true, + confirmButtonText: l('Scoresheet:Configuration:ArchiveModal.Confirm'), + cancelButtonText: l('Scoresheet:Configuration:ArchiveModal.Cancel'), + customClass: { + confirmButton: 'btn btn-primary', + cancelButton: 'btn btn-secondary' + } + }).then((result) => { + if (result.isConfirmed) { + unity.flex.scoresheets.scoresheet.archive(scoresheetId, !currentlyArchived) + .done(function () { + PubSub.publish('refresh_scoresheet_list', { scoresheetId: null }); + abp.notify.success( + currentlyArchived ? l('Scoresheet:Configuration:UnarchivedSuccess') : l('Scoresheet:Configuration:ArchivedSuccess'), + label + ); + }) + .fail(function () { + abp.notify.error(currentlyArchived ? l('Scoresheet:Configuration:UnarchiveFailed') : l('Scoresheet:Configuration:ArchiveFailed')); + }); + } + }); +} + function importScoresheetFile(inputId) { importFlexFile(inputId, "/api/app/scoresheet/import", "Scoresheet", 'refresh_scoresheet_list'); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml index 6a8f2148b4..4b270ad47c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml @@ -17,7 +17,7 @@ { var totalQuestions = scoresheet.Sections.SelectMany(sec => sec.Fields).Count(); var totalSections = scoresheet.Sections.Count; -
+
- - @if (!@scoresheet.Published) + @if (scoresheet.IsArchived) { - + } + else + { + + + @if (!scoresheet.Published) + { + + } + + } -
@foreach (var sec in scoresheet.Sections) @@ -66,7 +78,7 @@
Section: @sec.Name - @if (!@scoresheet.Published) + @if (!scoresheet.Published && !scoresheet.IsArchived) {
- @if (!@scoresheet.Published) + @if (!scoresheet.Published && !scoresheet.IsArchived) {
+
From 99a584f43175ef9a9ff088da5624abd7dd638cb7 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Thu, 4 Jun 2026 17:32:01 -0700 Subject: [PATCH 062/385] AB#33099 - Blocked server side call for editing archived scoresheets, Fixed screen reader legend --- .../Scoresheets/ScoresheetAppService.cs | 4 ++++ .../Shared/Components/ScoresheetConfiguration/Default.cshtml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs index e72da905f5..2f166258d7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Scoresheets/ScoresheetAppService.cs @@ -195,6 +195,10 @@ public async Task ValidateChangeableScoresheet(Guid scoresheetId) { throw new UserFriendlyException("Cannot change scoresheet. Scoresheet is already published."); } + if (scoresheet.IsArchived) + { + throw new UserFriendlyException("Cannot change scoresheet. Scoresheet is archived."); + } } public virtual async Task ArchiveAsync(Guid id, bool archive) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml index 5ab975c715..fabde53c6c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml @@ -36,7 +36,7 @@
- Published filter + Scoresheet filter From c80b68fd7ff660f89744bcd4d198fe29fb353f89 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 5 Jun 2026 08:25:29 -0700 Subject: [PATCH 063/385] AB#33263 Address Copilot PR review: whitespace check and case-insensitive type comparer - Use IsNullOrWhiteSpace for FormSchema check to align with GetFormSchemaAsync behavior - Use OrdinalIgnoreCase for NonFieldRequirementComponentTypes for consistency with NonDataComponentTypes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AI/Prompts/PromptDataPayloadBuilder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs index f2303ce528..66e5cf93e6 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -35,15 +35,15 @@ internal static class PromptDataPayloadBuilder "simpleseparator" }; - private static readonly HashSet NonFieldRequirementComponentTypes = - [ + private static readonly HashSet NonFieldRequirementComponentTypes = new(StringComparer.OrdinalIgnoreCase) + { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" - ]; + }; private const string ComponentsKey = "components"; @@ -104,7 +104,7 @@ public static async Task BuildFormFieldConfigurationAsync( try { var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); - if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + if (formVersion == null || string.IsNullOrWhiteSpace(formVersion.FormSchema)) { return new { message = "Form configuration not available." }; } From 77f25b50e972abd30a9a25b4a821e324ff335751 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Fri, 5 Jun 2026 11:56:28 -0700 Subject: [PATCH 064/385] AB#33025 - Indent question labels for each section within a scoresheet --- .../Views/Shared/Components/Scoresheet/Scoresheet.css | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css index 616561b5a1..3b221db113 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css @@ -56,6 +56,7 @@ } .question-item { + padding-left: 5rem; cursor: grab; } From 90f6eba2a794a45818c54b8cb17e41a34447bd3a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 5 Jun 2026 13:08:56 -0700 Subject: [PATCH 065/385] 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 72872b34d4fc59f7e92d0d6e6e1d43b5b83a7767 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Fri, 5 Jun 2026 20:12:41 -0700 Subject: [PATCH 066/385] AB#33221: Soft Delete Applicant --- ...ApplicationPermissionDefinitionProvider.cs | 1 + .../Applicants/ApplicantAppService.cs | 45 +- .../Applicants/ApplicantSupplierAppService.cs | 9 +- .../Applicants/IApplicantAppService.cs | 2 + .../Intakes/ApplicantLookupService.cs | 2 +- .../Localization/GrantManager/en.json | 1 + .../GrantApplicationPermissions.cs | 1 + .../Applications/Applicant.cs | 9 + .../Applications/IApplicationRepository.cs | 3 + ...6000444_AddApplicantSoftDelete.Designer.cs | 5046 +++++++++++++++++ .../20260606000444_AddApplicantSoftDelete.cs | 50 + .../GrantTenantDbContextModelSnapshot.cs | 9 + .../Repositories/ApplicantRepository.cs | 10 +- .../Repositories/ApplicationRepository.cs | 6 + .../Pages/Applicants/Details.cshtml | 17 +- .../Pages/Applicants/Details.cshtml.cs | 8 +- .../ApplicantsActionBar/Default.cshtml | 8 + .../Components/ApplicantsActionBar/Default.js | 47 +- .../ApplicantsActionBar/ListMerge.js | 3 +- 19 files changed, 5265 insertions(+), 12 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index 1b84ea5227..b9ef4f56e3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -47,6 +47,7 @@ public override void Define(IPermissionDefinitionContext context) applicatPermissions.AddChild(GrantApplicationPermissions.Applicants.ViewList, L("Permission:GrantApplicationManagement.Applicants.ViewList")); applicatPermissions.AddChild(GrantApplicationPermissions.Applicants.Edit, L("Permission:GrantApplicationManagement.Applicants.Edit")); applicatPermissions.AddChild(GrantApplicationPermissions.Applicants.AssignApplicant, L("Permission:GrantApplicationManagement.Applicants.AssignApplicant")); + applicatPermissions.AddChild(GrantApplicationPermissions.Applicants.Delete, L("Permission:GrantApplicationManagement.Applicants.Delete")); var applicantInfoPermissions = applicatPermissions.AddChild( GrantApplicationPermissions.Applicants.ApplicantInfoDefault, L("Permission:GrantApplicationManagement.Applicants.ApplicantInfo")); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs index 0746a6744b..273a8c5f06 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -653,6 +653,15 @@ private async Task UpdateAddress(List applicantAddresses, Addr [RemoteService(true)] public async Task TransferApplicantApplicationsAsync(TransferApplicantApplicationsDto dto) { + var principal = await applicantRepository.GetAsync(dto.PrincipalApplicantId); + var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalApplicantId); + + if ((principal != null && principal.IsDeleted) || (nonPrincipal != null && nonPrincipal.IsDeleted)) + { + throw new UserFriendlyException( + "One or more selected applicants have been deleted. Please refresh the applicant list to update the view."); + } + var applications = await applicationRepository.GetByApplicantIdAsync(dto.NonPrincipalApplicantId); foreach (var application in applications) { @@ -669,6 +678,14 @@ public async Task SetDuplicatedAsync(SetApplicantDuplicateDto dto) { // Set principal as not duplicated var principal = await applicantRepository.GetAsync(dto.PrincipalApplicantId); + var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalApplicantId); + + if ((principal != null && principal.IsDeleted) || (nonPrincipal != null && nonPrincipal.IsDeleted)) + { + throw new UserFriendlyException( + "One or more selected applicants have been deleted. Please refresh the applicant list to update the view."); + } + if (principal != null && principal.IsDuplicated) { principal.IsDuplicated = false; @@ -676,7 +693,6 @@ public async Task SetDuplicatedAsync(SetApplicantDuplicateDto dto) } // Set non-principal as duplicated - var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalApplicantId); if (nonPrincipal != null && !nonPrincipal.IsDuplicated) { nonPrincipal.IsDuplicated = true; @@ -684,6 +700,30 @@ public async Task SetDuplicatedAsync(SetApplicantDuplicateDto dto) } } + [RemoteService(true)] + [Authorize(GrantApplicationPermissions.Applicants.Delete)] + public async Task HasSubmissionsAsync(Guid id) + { + return await applicationRepository.HasApplicationsByApplicantIdAsync(id); + } + + [RemoteService(true)] + [Authorize(GrantApplicationPermissions.Applicants.Delete)] + public async Task DeleteApplicantAsync(Guid id) + { + if (await applicationRepository.HasApplicationsByApplicantIdAsync(id)) + { + throw new UserFriendlyException( + "This applicant cannot be deleted because it is associated with one or more submissions."); + } + + var applicant = await applicantRepository.GetAsync(id); + applicant.IsDeleted = true; + applicant.DeletionTime = Clock.Now; + applicant.DeleterId = CurrentUser.Id; + await applicantRepository.UpdateAsync(applicant, autoSave: true); + } + private async Task UpdateApplicationFormSubmissionsAsync(Guid applicationId, Guid newApplicantId) { try @@ -733,6 +773,9 @@ public async Task> GetListAsync(ApplicantListRe { var query = await applicantRepository.GetQueryableAsync(); + // Exclude soft-deleted applicants from the list + query = query.Where(a => !a.IsDeleted); + // Apply default sorting (client-side DataTable handles search, sorting, and paging) query = query.OrderByDescending(a => a.CreationTime); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs index b49bfd1371..daa5c3be42 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs @@ -184,10 +184,17 @@ public async Task HandleSupplierAfterMergeAsync(HandleSupplierAfterMergeDto dto) await EnsureNoPendingPaymentsForApplicantAsync(dto.NonPrincipalId); var principal = await applicantRepository.GetAsync(dto.PrincipalId); + var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalId); + + if ((principal != null && principal.IsDeleted) || (nonPrincipal != null && nonPrincipal.IsDeleted)) + { + throw new UserFriendlyException( + "One or more selected applicants have been deleted. Please refresh the applicant list to update the view."); + } + principal.SupplierId = dto.SelectedSupplierId; await applicantRepository.UpdateAsync(principal); - var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalId); nonPrincipal.SupplierId = dto.SelectedSupplierId; await applicantRepository.UpdateAsync(nonPrincipal); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs index 8a359cc45c..0ac2b2f453 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs @@ -25,4 +25,6 @@ public interface IApplicantAppService : IApplicationService Task PartialUpdateApplicantSummaryAsync(Guid applicantId, PartialUpdateDto input); Task UpdateApplicantContactAddressesAsync(Guid applicantId, UpdateApplicantContactAddressesDto input); Task UpdateApplicantStatusAsync(Guid applicantId, string status); + Task DeleteApplicantAsync(Guid id); + Task HasSubmissionsAsync(Guid id); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/ApplicantLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/ApplicantLookupService.cs index 33cd8db501..48bdcb431f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/ApplicantLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/ApplicantLookupService.cs @@ -32,7 +32,7 @@ public async Task ApplicantLookupByApplicantId(string unityApplicantId) try { Applicant? applicant = await applicantRepository.GetByUnityApplicantIdAsync(unityApplicantId); - if (applicant == null) + if (applicant == null || applicant.IsDeleted) { throw new KeyNotFoundException("Applicant not found."); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 35d17e82b6..77d536ea19 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -144,6 +144,7 @@ "Permission:GrantApplicationManagement.Applicants.AssignApplicant": "Assign Applicant to an Application", "Permission:GrantApplicationManagement.Applicants.ApplicantInfo": "Applicant Info", "Permission:GrantApplicationManagement.Applicants.ApplicantInfo.EditRedStop": "Edit Red-Stop", + "Permission:GrantApplicationManagement.Applicants.Delete": "Delete Applicant", "Permission:GrantApplicationManagement.Assignments.Default": "Assignment", "Permission:GrantApplicationManagement.Assignments.AssignInitial": "Initial Assignment", "Permission:GrantApplicationManagement.Reviews.Default": "Reviews", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs index 73afc90266..da6a031bca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs @@ -37,6 +37,7 @@ public static class Applicants public const string Default = GroupName + ".Applicants"; public const string ViewList = Default + ".ViewList"; public const string Edit = Default + Operation.Update; + public const string Delete = Default + Operation.Delete; public const string AssignApplicant = Default + ".AssignApplicant"; public const string ApplicantInfoDefault = Default + ".ApplicantInfo"; public const string EditRedStop = ApplicantInfoDefault + ".EditRedStop"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs index b69fac3393..feac9027ac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs @@ -36,4 +36,13 @@ public class Applicant : AuditedAggregateRoot, IMultiTenant public string? IssueTrackingComments { get; set; } public string? AuditComments { get; set; } public string? ReportsComments { get; set; } + // Soft-delete fields are declared manually instead of implementing ABP's ISoftDelete. + // ISoftDelete would register a global EF Core query filter that applies to Include() joins — + // Application.Applicant is a required navigation (OnDelete NoAction), so the filter would + // silently drop Application rows whose Applicant is soft-deleted, breaking the application + // and payment list pages. Filtering is applied explicitly only where needed (applicant list, + // lookup, autocomplete) via .Where(a => !a.IsDeleted). + public bool IsDeleted { get; set; } + public DateTime? DeletionTime { get; set; } + public Guid? DeleterId { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs index 62a57ce6de..fe09b4433b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs @@ -43,5 +43,8 @@ Task> GetApplicationListRecordsAsync( // Get applications whose DefaultSiteId matches the given site id Task> GetApplicationsBySiteIdAsync(Guid siteId); + + // Check whether any applications exist for the given applicant + Task HasApplicationsByApplicantIdAsync(Guid applicantId); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.Designer.cs new file mode 100644 index 0000000000..99e71a03a2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.Designer.cs @@ -0,0 +1,5046 @@ +// +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("20260606000444_AddApplicantSoftDelete")] + partial class AddApplicantSoftDelete + { + /// + 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("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("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("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + + 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("IsDeleted") + .HasColumnType("boolean"); + + 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/20260606000444_AddApplicantSoftDelete.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.cs new file mode 100644 index 0000000000..c568581f32 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260606000444_AddApplicantSoftDelete.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AddApplicantSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeleterId", + table: "Applicants", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletionTime", + table: "Applicants", + type: "timestamp without time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Applicants", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeleterId", + table: "Applicants"); + + migrationBuilder.DropColumn( + name: "DeletionTime", + table: "Applicants"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Applicants"); + } + } +} 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 62fbafaadd..698a9e3994 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 @@ -825,6 +825,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("CreatorId"); + b.Property("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + b.Property("ExtraProperties") .IsRequired() .HasColumnType("text") @@ -842,6 +848,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IndigenousOrgInd") .HasColumnType("text"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("IsDuplicated") .HasColumnType("boolean"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs index 7f16fec56a..00facf5f20 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs @@ -28,14 +28,14 @@ public async Task> GetUnmatchedApplicantsAsync() { var dbContext = await GetDbContextAsync(); return await dbContext.Applicants - .Where(x => x.MatchPercentage == null) + .Where(x => x.MatchPercentage == null && !x.IsDeleted) .ToListAsync(); } public async Task GetByUnityApplicantIdAsync(string unityApplicantId) { var dbContext = await GetDbContextAsync(); - return await dbContext.Applicants.FirstOrDefaultAsync(x => x.UnityApplicantId == unityApplicantId); + return await dbContext.Applicants.FirstOrDefaultAsync(x => x.UnityApplicantId == unityApplicantId && !x.IsDeleted); } public async Task GetByUnityApplicantNameAsync(string unityApplicantName) @@ -47,7 +47,8 @@ public async Task> GetUnmatchedApplicantsAsync() // EF Core cannot translate StringComparison overloads to SQL, so we use ToLower() for database compatibility return await dbContext.Applicants .FirstOrDefaultAsync(a => a.ApplicantName != null && - a.ApplicantName.ToLower() == unityApplicantNameNormalized); + a.ApplicantName.ToLower() == unityApplicantNameNormalized && + !a.IsDeleted); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } @@ -55,7 +56,7 @@ public async Task> GetApplicantsWithUnityApplicantIdAsync() { var dbContext = await GetDbContextAsync(); return await dbContext.Applicants - .Where(x => x.UnityApplicantId != null) + .Where(x => x.UnityApplicantId != null && !x.IsDeleted) .ToListAsync(); } @@ -72,6 +73,7 @@ public async Task GetApplicantAutocompleteQueryAsync(string? appli var applicants = await dbContext.Applicants .AsNoTracking() + .Where(a => !a.IsDeleted) .ToListAsync(); var filtered = applicants diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs index 4133f618b6..b0b10699ec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs @@ -223,6 +223,12 @@ public async Task> GetByApplicantIdAsync(Guid applicantId) .ToListAsync(); } + public async Task HasApplicationsByApplicantIdAsync(Guid applicantId) + { + var query = await GetQueryableAsync(); + return await query.AnyAsync(a => a.ApplicantId == applicantId); + } + public async Task> GetApplicationListRecordsAsync( int skipCount, int maxResultCount, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml index c15d87e43f..5f8a17be6c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml @@ -31,7 +31,7 @@ @await Component.InvokeAsync("ApplicantBreadcrumbWidget", new { applicantId = @Model.ApplicantId }) -@if (await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.Edit)) +@if (!Model.ApplicantIsDeleted && await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.Edit)) {
@@ -68,6 +68,19 @@ +@if (Model.ApplicantIsDeleted) +{ +
+ +
+} +else +{ +
unity-applicant-loading @@ -234,3 +247,5 @@
+ +} @* end else (applicant not deleted) *@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs index 261fac752d..998b75cc1c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs @@ -22,6 +22,7 @@ public class DetailsModel : GrantManagerPageModel public Guid? ApplicationId { get; set; } = null; public Applicant? Applicant { get; set; } + public bool ApplicantIsDeleted { get; set; } public string ApplicantDisplayName { get; set; } = string.Empty; public string UnityApplicantId { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; @@ -69,11 +70,16 @@ public async Task OnGetAsync() { Applicant = await _applicantRepository.GetAsync(ApplicantId); - // Set properties for breadcrumb and display ApplicantDisplayName = !string.IsNullOrEmpty(Applicant.ApplicantName) ? Applicant.ApplicantName : "Applicant Name"; + if (Applicant.IsDeleted) + { + ApplicantIsDeleted = true; + return Page(); + } + UnityApplicantId = Applicant.UnityApplicantId ?? "N/A"; Status = Applicant.Status ?? string.Empty; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml index 52e9348106..e3cd75cd24 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml @@ -29,6 +29,14 @@ class="custom-table-btn flex-none btn btn-secondary d-none" button-type="Secondary" /> } + @if (await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.Delete)) + { + + }
@* Spacer to align buttons properly *@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js index ef5f2c8064..2f6eef7d9d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js @@ -37,10 +37,12 @@ $(function () { $('*[data-selector="applicants-table-actions"]').removeClass('action-bar-btn-unavailable'); $('.action-bar').addClass('active'); - // OPEN button is only visible when exactly 1 applicant is selected + // OPEN and DELETE buttons are only visible when exactly 1 applicant is selected $('#openApplicant').addClass('action-bar-btn-unavailable'); + $('#deleteApplicant').addClass('action-bar-btn-unavailable'); if (selectedApplicantIds.length === 1) { $('#openApplicant').removeClass('action-bar-btn-unavailable'); + $('#deleteApplicant').removeClass('action-bar-btn-unavailable'); } } @@ -69,6 +71,47 @@ $(function () { } }); + // DELETE button click — pre-check submissions, then show the correct modal + $('#deleteApplicant').on('click', function () { + if (selectedApplicantIds.length !== 1) return; + var applicantId = selectedApplicantIds[0]; + var applicantName = (selectedApplicants[0].applicantName || 'this applicant'); + + unity.grantManager.applicants.applicant + .hasSubmissions(applicantId) + .then(function (hasSubmissions) { + if (hasSubmissions) { + abp.message.warn( + 'This applicant cannot be deleted because it is associated with one or more submissions.', + 'Cannot Delete Applicant' + ); + } else { + abp.message.confirm( + 'Are you sure you want to delete the applicant "' + applicantName + '"?', + 'Delete Applicant', + function (confirmed) { + if (!confirmed) return; + unity.grantManager.applicants.applicant + .deleteApplicant(applicantId) + .then(function () { + PubSub.publish('deselect_applicant', 'reset_data'); + $('#ApplicantsTable').DataTable().ajax.reload(); + abp.notify.success('Applicant "' + applicantName + '" has been deleted.'); + }) + .catch(function (e) { + console.warn('deleteApplicant error:', e); + const msg = e?.responseJSON?.error?.message || 'An error occurred while deleting the applicant. Please try again.'; + abp.message.error(msg, 'Delete Failed'); + }); + } + ); + } + }) + .catch(function (e) { + console.warn('hasSubmissions error:', e); + }); + }); + // Handle search input $('#search').on('input', function () { let table = $('#ApplicantsTable').DataTable(); @@ -77,4 +120,4 @@ $(function () { // Initialize button states manageActionButtons(); -}); \ No newline at end of file +}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js index 79d5aa11f1..32655619e9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js @@ -216,7 +216,8 @@ abp.notify.success('Applicants merged successfully.'); }).catch(err => { console.warn('Merge failed:', err); - abp.notify.error('Merge failed. Please try again.'); + const msg = err?.responseJSON?.error?.message || 'Merge failed. Please try again.'; + abp.message.error(msg, 'Merge Failed'); }).always(() => { $('#listMergeSpinner').addClass('d-none'); $('#listMergeMergeBtn').prop('disabled', false); From c90ba24b8ed7d4d96bebc7d493c5dc89fb4c8c9e Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 8 Jun 2026 09:14:22 -0700 Subject: [PATCH 067/385] 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 068/385] 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 @@
+ +