diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index 3a39780fa..c2094e4c0 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -41,7 +41,7 @@ AuthServer__Realm="standard" #"unity-local" AuthServer__RequireHttpsMetadata="false" AuthServer__Audience="unity-4899" #"unity-web" AuthServer__ClientId="unity-4899" #"unity-web" -AuthServer__ClientSecret="="********"" +AuthServer__ClientSecret="********" AuthServer__IsBehindTlsTerminationProxy="false" AuthServer__SpecifyOidcParameters="true" AuthServer__OidcSignin="http://localhost:44342/signin-oidc" diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.css b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.css index 2abd758d1..d465d3b2d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.css +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.css @@ -19,8 +19,3 @@ #IdentityRolesWrapper { background-color: transparent; } - -#IdentityRolesWrapper .dt-scroll-body { - max-height: calc(100vh - 370px); - overflow-y: scroll; -} diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js index 74f5376cf..d54abb109 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/index.js @@ -168,7 +168,8 @@ $(function () { dataTableName: 'IdentityRolesTable', dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, - externalSearchId: 'search-roles' + externalSearchId: 'search-roles', + fixedHeaders: true }); _createModal.onResult(function () { diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.css b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.css index 3e2e3dfa9..26f3c597a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.css +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.css @@ -28,8 +28,3 @@ #UsersWrapper { background-color: transparent; } - -#UsersWrapper .dt-scroll-body { - max-height: calc(100vh - 370px); - overflow-y: scroll; -} diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js index 6cfa429cc..ca1c07d2e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/index.js @@ -266,7 +266,8 @@ $(function () { dataTableName: 'UsersTable', dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, - externalSearchId: 'search-users' + externalSearchId: 'search-users', + fixedHeaders: true }); _editModal.onResult(function () { 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 65eacb335..eb031554d 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 @@ -117,7 +117,9 @@ 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); + var emailObject = await GetEmailObjectAsync( + emailTo, body, subject, emailFrom, emailBodyType, emailTemplateName, emailCC, emailBCC, excludeTemplate: true); + var response = await chesClientService.SendAsync(emailObject); // Assuming SendAsync returns a HttpResponseMessage or equivalent: @@ -222,7 +224,8 @@ public async Task BuildEmailObjectWithAttachmentsAsync(EmailLog emailLo emailLog.BodyType, emailLog.TemplateName, emailLog.CC, - emailLog.BCC); + emailLog.BCC, + excludeTemplate: true); // Retrieve attachments from S3 var attachments = await emailAttachmentService.GetAttachmentsAsync(emailLog.Id); @@ -261,7 +264,8 @@ protected virtual async Task GetEmailObjectAsync( string? emailBodyType, string? emailTemplateName, string? emailCC = null, - string? emailBCC = null) + string? emailBCC = null, + bool excludeTemplate = false) { var toList = emailTo.ParseEmailList() ?? []; var ccList = emailCC.ParseEmailList(); @@ -274,28 +278,47 @@ protected virtual async Task GetEmailObjectAsync( emailObjectDictionary["body"] = body; emailObjectDictionary["bodyType"] = emailBodyType ?? "text"; - emailObjectDictionary["cc"] = ccList; - emailObjectDictionary["bcc"] = bccList; emailObjectDictionary["encoding"] = "utf-8"; emailObjectDictionary["from"] = emailFrom ?? defaultFromAddress ?? "NoReply@gov.bc.ca"; emailObjectDictionary["priority"] = "normal"; emailObjectDictionary["subject"] = subject; emailObjectDictionary["tag"] = "tag"; emailObjectDictionary["to"] = toList; - emailObjectDictionary["templateName"] = emailTemplateName; + + // Only include cc/bcc when provided CHES API expects arrays, not null. + if (ccList != null) + { + emailObjectDictionary["cc"] = ccList; + } + if (bccList != null) + { + emailObjectDictionary["bcc"] = bccList; + } + + // 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; + } return emailObject; } protected virtual EmailLog UpdateMappedEmailLog(EmailLog emailLog, dynamic emailDynamicObject) { + var dict = (IDictionary)emailDynamicObject; emailLog.Body = emailDynamicObject.body; emailLog.Subject = emailDynamicObject.subject; emailLog.BodyType = emailDynamicObject.bodyType; emailLog.FromAddress = emailDynamicObject.from; emailLog.ToAddress = string.Join(",", emailDynamicObject.to); - emailLog.CC = emailDynamicObject.cc != null ? string.Join(",", (IEnumerable)emailDynamicObject.cc) : string.Empty; - emailLog.BCC = emailDynamicObject.bcc != null ? string.Join(",", (IEnumerable)emailDynamicObject.bcc) : string.Empty; + emailLog.CC = dict.TryGetValue("cc", out var cc) && cc is IEnumerable ccList + ? string.Join(",", ccList) + : string.Empty; + emailLog.BCC = dict.TryGetValue("bcc", out var bcc) && bcc is IEnumerable bccList + ? string.Join(",", bccList) + : string.Empty; emailLog.TemplateName = emailDynamicObject.templateName; return emailLog; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Codes/CasPaymentRequestStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Codes/CasPaymentRequestStatus.cs index e10b259c9..3b292dda5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Codes/CasPaymentRequestStatus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Codes/CasPaymentRequestStatus.cs @@ -4,6 +4,7 @@ public static class CasPaymentRequestStatus { // Unity Status public const string SentToCas = "SentToCas"; + public const string SentToAccountsPayable = "SentToAccountsPayable"; // CAS INVOICE STATUS public const string ErrorFromCas = "Error"; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs index 73bd07d3c..31ebc7326 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs @@ -9,6 +9,7 @@ using Unity.Payments.Domain.Shared; using Unity.Payments.Domain.Workflow; using Unity.Payments.Enums; +using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Unity.Payments.Permissions; using Volo.Abp.Authorization.Permissions; @@ -150,6 +151,7 @@ public async Task TriggerAction(Guid paymentRequestsId, PaymentA if (preventPayment) { statusChangedTo = PaymentRequestStatus.FSB; + paymentRequest.SetInvoiceStatus(CasPaymentRequestStatus.SentToAccountsPayable); } else { 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 afee92a5b..be9999cd1 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 @@ -22,6 +22,8 @@ public PaymentRequestRepository(IDbContextProvider dbContextP { ReCheckStatusList.Add(CasPaymentRequestStatus.ServiceUnavailable); ReCheckStatusList.Add(CasPaymentRequestStatus.SentToCas); + ReCheckStatusList.Add(CasPaymentRequestStatus.NotFound); + ReCheckStatusList.Add(CasPaymentRequestStatus.SentToAccountsPayable); ReCheckStatusList.Add(CasPaymentRequestStatus.NeverValidated); FailedStatusList.Add(CasPaymentRequestStatus.ServiceUnavailable); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index 84b8cc8fa..7e2e287be 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Unity.Payments.Domain.PaymentRequests; using System; +using System.Linq; using Volo.Abp.Application.Services; using System.Collections.Generic; using Volo.Abp.TenantManagement; @@ -8,7 +9,7 @@ using Volo.Abp.Uow; using Microsoft.Extensions.Logging; using Unity.Payments.Integrations.Cas; -using System.Linq; +using Unity.Payments.Codes; using Unity.Payments.RabbitMQ.QueueMessages; using Unity.Notifications.Integrations.RabbitMQ; @@ -138,6 +139,11 @@ public async Task AddPaymentRequestsToReconciliationQueue() paymentReqeust = await _paymentRequestsRepository.GetAsync(PaymentRequestId); if (paymentReqeust != null) { + if(paymentReqeust.InvoiceStatus == CasPaymentRequestStatus.NotFound && result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + { + result.InvoiceStatus = CasPaymentRequestStatus.NotFound+"2"; + } + paymentReqeust.SetInvoiceStatus(result.InvoiceStatus ?? ""); paymentReqeust.SetPaymentStatus(result.PaymentStatus ?? ""); paymentReqeust.SetPaymentDate(result.PaymentDate ?? ""); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml index fc51153ea..7509ead8f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml @@ -80,7 +80,7 @@ @if (canOverride) - { + { @@ -182,10 +182,13 @@ - + $ + + @@ -256,3 +259,20 @@ + + diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js index 5054d9a1e..5d19c2279 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js @@ -263,7 +263,6 @@ function validateParentChildAmounts(correlationId) { } $(function () { - $('.unity-currency-input').maskMoney(); // Validate payment amounts on initial page load validateAllPaymentAmounts(); }); 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 945406074..dbab087b0 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 @@ -164,7 +164,8 @@ $(function () { languageSetValues: {}, dataTableName: 'PaymentRequestListTable', dynamicButtonContainerId: 'dynamicButtonContainerId', - useNullPlaceholder: true + useNullPlaceholder: true, + fixedHeaders: true }); // Attach the draw event to add custom row coloring logic @@ -186,10 +187,16 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); + history_button.disable(); dataTable.on('search.dt', () => handleSearch()); - function checkAllRowsHaveState(state) { - return dataTable.rows('.selected').data().toArray().every(row => row.status === state); + function checkAllRowsHaveState(states) { + const allowedStates = Array.isArray(states) ? states : [states]; + return dataTable + .rows('.selected') + .data() + .toArray() + .every(row => allowedStates.includes(row.status)); } $('#PaymentRequestListTable').on('click', 'tr td', function (e) { @@ -256,13 +263,13 @@ $(function () { } function checkActionButtons() { - let isOnlySubmittedToCas = checkAllRowsHaveState('Submitted'); - if (isOnlySubmittedToCas) { + let isInSentState = checkAllRowsHaveState(['Submitted', 'FSB']); + if (isInSentState) { payment_check_status_buttons.enable(); } else { payment_check_status_buttons.disable(); } - if (dataTable.rows({ selected: true }).indexes().length > 0 && !isOnlySubmittedToCas) { + if (dataTable.rows({ selected: true }).indexes().length > 0 && !isInSentState) { if (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L2ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')) { @@ -272,15 +279,11 @@ $(function () { payment_approve_buttons.disable(); } - if (dataTable.rows({ selected: true }).indexes().length == 1) { - history_button.enable(); - } else { - history_button.disable(); - } + checkEnableHistoryButton(dataTable, history_button); } else { payment_approve_buttons.disable(); - history_button.enable(); + checkEnableHistoryButton(dataTable, history_button); } } @@ -792,6 +795,14 @@ let casPaymentResponseModal = new abp.ModalManager({ viewUrl: '../PaymentRequests/CasPaymentRequestResponse' }); +function checkEnableHistoryButton(dataTable, history_button) { + if (dataTable.rows({ selected: true }).indexes().length == 1) { + history_button.enable(); + } else { + history_button.disable(); + } +} + function openCasResponseModal(casResponse) { casPaymentResponseModal.open({ casResponse: casResponse diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 999b15e07..33abb20eb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -215,6 +215,7 @@ dataTableName: 'ApplicationPaymentRequestListTable', externalSearchId: 'PaymentListSearch', dynamicButtonContainerId: 'dynamicButtonContainerId', + lengthMenu: [10, 25, 50, -1] }); dataTable.on('search.dt', () => handleSearch()); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.js index d872831bc..d7743f187 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.js @@ -88,7 +88,8 @@ dataTableName: 'EndpointsTable', dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, - externalSearchId: 'search-endpoints' + externalSearchId: 'search-endpoints', + fixedHeaders: true }); createModal.onResult(function () { diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs index 432c73904..f2472b27c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs @@ -47,6 +47,7 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.AddIfNotContains("/themes/ux2/zone-extensions.js"); context.Files.Add("/themes/ux2/layout.js"); context.Files.Add("/themes/ux2/plugins/filterRow.js"); + context.Files.Add("/themes/ux2/plugins/scrollResize.js"); context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); context.Files.Add("/themes/ux2/table-utils.js"); context.Files.Add("/js/DateUtils.js"); 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 9c2a56d3f..6fb5270b4 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 @@ -132,6 +132,11 @@ div.dt-container { overflow-x: hidden; } +div.dt-container.dt-scroll-resize { + max-height: none !important; + overflow-y: visible; +} + .dt-container .dt-scroll-head { min-height: 44px; } @@ -888,10 +893,21 @@ input.form-control.disabled:read-only, textarea.form-control.disabled:read-only, border: var(--bs-border-width) solid var(--bs-border-color); } +.dt-unity-footer { + width: 100%; + display: flex; + padding-bottom: 0.25rem; + align-items: center !important; + justify-content: space-between !important; +} + @media (max-height: 768px) { .dt-scroll-body { overflow-y: scroll !important; + } + + .dt-container:not(.dt-scroll-resize) .dt-scroll-body { max-height: calc(100vh - 42vh); } } @@ -899,6 +915,8 @@ input.form-control.disabled:read-only, textarea.form-control.disabled:read-only, @media (min-height: 769px) and (max-height: 1024px) { .dt-scroll-body { overflow-y: scroll !important; + } + .dt-container:not(.dt-scroll-resize) .dt-scroll-body { max-height: calc(100vh - 32vh); } } @@ -907,6 +925,8 @@ input.form-control.disabled:read-only, textarea.form-control.disabled:read-only, @media (min-height: 1025px) { .dt-scroll-body { overflow-y: scroll !important; + } + .dt-container:not(.dt-scroll-resize) .dt-scroll-body { max-height: calc(100vh - 30vh); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js index f536bb271..cfad9c7c2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js @@ -267,6 +267,7 @@ // Toggle filter row visibility $popover.find('#showFilter').on('click', function () { that.dom.filterRow.toggle(); + that.s.dt.trigger('filterRow-visibility', [that.dom.filterRow.is(':visible')]); }); // Clear all filters @@ -418,6 +419,7 @@ */ show: function () { this.dom.filterRow.show(); + this.s.dt.trigger('filterRow-visibility', [true]); return this; }, @@ -427,6 +429,7 @@ */ hide: function () { this.dom.filterRow.hide(); + this.s.dt.trigger('filterRow-visibility', [false]); return this; }, @@ -436,6 +439,7 @@ */ toggle: function () { this.dom.filterRow.toggle(); + this.s.dt.trigger('filterRow-visibility', [this.dom.filterRow.is(':visible')]); return this; }, diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/scrollResize.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/scrollResize.js new file mode 100644 index 000000000..a264e2cc0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/scrollResize.js @@ -0,0 +1,231 @@ +/** + * DataTables Feature Plugin: ScrollResize + * + * Dynamically calculates and sets the DataTable scroll body height so that + * the table header, body, and pagination footer all remain visible within + * the browser viewport at 100 % magnification, regardless of the number of + * columns, rows, or the visibility of auxiliary elements such as filter rows. + * + * Instead of relying on static CSS `calc()` values, this plugin measures the + * actual position of the scroll body in the viewport and subtracts all chrome + * (navbar, action bar, column headers, pagination) to arrive at the correct + * height. It recalculates on every event that can change the layout: + * - window resize + * - DataTables draw / column-visibility / column-reorder + * - FilterRow show / hide (custom `filterRow-visibility` event) + * - ResizeObserver on the scroll-head element (column title wrapping) + * + * Inspired by the official DataTables ScrollResize plugin + * (https://github.com/DataTables/Plugins/tree/main/features/scrollResize) + * but adapted for a full-viewport layout where `body { overflow: hidden }`. + * + * @summary Dynamic scroll-body sizing for DataTables + * @requires jQuery, DataTables 2+ + * + * @example + * // Automatic initialisation via initializeDataTable(): + * initializeDataTable({ ..., fixedHeaders: true }); + * + * @example + * // Manual initialisation after DataTable creation: + * let table = $('#example').DataTable({ scrollY: '100px', scrollCollapse: true }); + * new DataTable.ScrollResize(table); + */ + +(function ($) { + 'use strict'; + + let DataTable = $.fn.dataTable; + + if (!DataTable) { + throw new Error('DataTables ScrollResize requires DataTables'); + } + + // Resolve DataTables CSS class names from the canonical registry so the + // plugin stays correct if DataTables ever renames its classes. + const classes = DataTable.ext.classes; + const scrollClasses = classes.scrolling; + const CSS_SCROLL_BODY = scrollClasses.body; // 'dt-scroll-body' + const CSS_SCROLL_HEAD = scrollClasses.header.self; // 'dt-scroll-head' + const CSS_SCROLL_WRAP = scrollClasses.container; // 'dt-scroll' + const CSS_LAYOUT_ROW = classes.layout.row; // 'dt-layout-row' + + // Custom classes (not from DataTables) + const CSS_SCROLL_RESIZE = 'dt-scroll-resize'; + const CSS_UNITY_FOOTER = 'dt-unity-footer'; + + /** + * @param {DataTable.Api} dt - DataTables API instance + * @param {Object} opts - Configuration options + * @param {number} [opts.minHeight=150] Minimum scroll body height in px + * @param {number} [opts.buffer=32] Extra px subtracted as safety margin + * @param {number} [opts.throttleDelay=30] Throttle interval for resize (ms) + */ + class ScrollResize { + constructor(dt, opts) { + let table = dt.table(); + let container = $(table.container()); + + this.s = $.extend({ + minHeight: 150, + buffer: 32, + throttleDelay: 30 + }, opts); + + this.s.dt = dt; + this.s.table = $(table.node()); + this.s.container = container; + this.s.scrollBody = container.find('div.' + CSS_SCROLL_BODY); + this.s.scrollHead = container.find('div.' + CSS_SCROLL_HEAD); + this.s.namespace = '.dtScrollResize' + (ScrollResize._counter++); + + // Guard: scrollY must be enabled for a scroll body to exist + if (!this.s.scrollBody.length) { + console.warn('ScrollResize: no .' + CSS_SCROLL_BODY + ' found – is scrollY enabled?'); + return; + } + + // Mark container so CSS can opt out of static max-height rules + container.addClass(CSS_SCROLL_RESIZE); + + this._bindEvents(); + // Use a small delay so the table is fully laid out before the first calc + let that = this; + setTimeout(function () { that._size(); }, 0); + } + + /** + * Core sizing calculation. + * + * Uses getBoundingClientRect so we automatically account for every + * element above the scroll body (navbar, action bar, search row, + * column headers, filter row, ...) without hard-coding selectors. + */ + _size() { + let scrollBody = this.s.scrollBody; + if (!scrollBody.length || !scrollBody.is(':visible')) return; + + // 1. Where does the scroll body start in the viewport? + let scrollBodyRect = scrollBody[0].getBoundingClientRect(); + let topOffset = scrollBodyRect.top; + + // 2. How tall is the footer area below the scroll body? + let footerHeight = this._getFooterHeight(); + + // 3. Available height = viewport – top – footer – buffer + let available = window.innerHeight - topOffset - footerHeight - this.s.buffer; + let newHeight = Math.max(Math.round(available), this.s.minHeight); + + // 4. Apply – only touch the DOM when the value actually changed + let currentHeight = scrollBody[0].style.height; + let newHeightPx = newHeight + 'px'; + if (currentHeight !== newHeightPx) { + scrollBody.css({ 'height': newHeightPx, 'max-height': newHeightPx }); + } + } + + /** + * Measure the height of the footer area below the scroll body + * (pagination, info, page-length controls). + * + * Primary: looks for the .dt-unity-footer element inside the + * container and measures its parent .dt-layout-row. + * Fallback: traverses from .dt-scroll up to its parent + * .dt-layout-row and sums every subsequent sibling row. + */ + _getFooterHeight() { + let container = this.s.container; + let total = 0; + + // Primary: use the .dt-unity-footer marker class + let footer = container.find('.' + CSS_UNITY_FOOTER); + if (footer.length) { + // The footer element sits inside a .dt-layout-row wrapper; + // measure whichever is the outermost so margins are included. + let row = footer.closest('.' + CSS_LAYOUT_ROW); + total = (row.length ? row : footer).outerHeight(true) || 0; + return total; + } + + // Fallback: DOM traversal for non-standard layouts + let scrollWrapper = this.s.scrollBody.closest('.' + CSS_SCROLL_WRAP); + let tableLayoutRow = scrollWrapper.closest('.' + CSS_LAYOUT_ROW); + + if (tableLayoutRow.length) { + tableLayoutRow.nextAll().each(function () { + total += $(this).outerHeight(true) || 0; + }); + } + + return total; + } + + /** + * Bind all the events that should trigger a recalculation. + */ + _bindEvents() { + let that = this; + let ns = this.s.namespace; + let dt = this.s.dt; + + // --- Window resize (throttled) --- + let resizeTimer; + $(window).on('resize' + ns, function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { that._size(); }, that.s.throttleDelay); + }); + + // --- DataTables events --- + dt.on('draw' + ns, function () { that._size(); }); + dt.on('column-visibility' + ns, function () { + // Small delay so the DOM has reflowed after column toggle + setTimeout(function () { that._size(); }, 30); + }); + dt.on('column-reorder' + ns, function () { + setTimeout(function () { that._size(); }, 30); + }); + + // --- FilterRow visibility (custom event emitted by filterRow.js) --- + dt.on('filterRow-visibility' + ns, function () { + setTimeout(function () { that._size(); }, 30); + }); + + // --- ResizeObserver on scroll-head (detects header height changes) --- + if (typeof ResizeObserver !== 'undefined' && this.s.scrollHead.length) { + this._resizeObserver = new ResizeObserver(function () { + that._size(); + }); + this._resizeObserver.observe(this.s.scrollHead[0]); + } + + dt.on('destroy' + ns, function () { + that._destroy(); + }); + } + + /** + * Remove all bound listeners, observers, and inline styles. + */ + _destroy() { + let ns = this.s.namespace; + + $(window).off(ns); + this.s.dt.off(ns); + + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + + this.s.scrollBody.css({ 'height': '', 'max-height': '' }); + this.s.container.removeClass(CSS_SCROLL_RESIZE); + } + } + + ScrollResize._counter = 0; + + DataTable.ScrollResize = ScrollResize; + + return DataTable.ScrollResize; + +})(jQuery); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 937653639..10a02a00d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -185,6 +185,10 @@ if ($.fn.dataTable !== undefined && $.fn.dataTable.Api) { * @param {string} [options.externalSearchId='search'] - ID of external search input element * @param {boolean} [options.disableColumnSelect=false] - Disable column visibility toggle * @param {Array} [options.listColumnDefs] - Additional columnDefs configurations + * @param {Function} [options.onStateSaveParams] - Hook for additional state save parameters + * @param {Function} [options.onStateLoadParams] - Hook for additional state load parameters + * @param {Function} [options.onStateLoaded] - Hook called after state is loaded + * @param {boolean} [options.fixedHeaders=false] - Enable fixed header with dynamically sized scrollable body * @returns {DataTable} Initialized DataTable API instance * * @example @@ -216,9 +220,11 @@ function initializeDataTable(options) { externalSearchId = 'search', disableColumnSelect = false, listColumnDefs, - onStateSaveParams,//External hooks for save/load/loaded + onStateSaveParams, //External hooks for save/load/loaded onStateLoadParams, onStateLoaded, + fixedHeaders = false, + lengthMenu = [25, 50, 75, 100, -1] } = options; // Process columns and visibility @@ -235,8 +241,8 @@ function initializeDataTable(options) { // Add loading class initially dt.closest('.dt-container, .dataTables_wrapper').addClass('dt-loading'); - // Create the DataTable - let iDt = new DataTable(dt, { + // Create the DataTable Configuration object + let configuration = { serverSide: serverSideEnabled, paging: pagingEnabled, order: defaultSortOrder, @@ -264,16 +270,23 @@ function initializeDataTable(options) { language: { ...languageSetValues, lengthMenu: 'Show _MENU_ _ENTRIES_', + lengthLabels: { + '-1': 'All' + } }, + lengthMenu: lengthMenu, layout: { topStart: { search: { placeholder: 'Search' } }, topEnd: { buttons: updatedActionButtons }, bottomStart: null, bottomEnd: null, bottom1: { - info: { text: '_START_-_END_ of _TOTAL_' }, - paging: { buttons: 3, boundaryNumbers: true, firstLast: false }, - pageLength: { menu: [10, 25, 50, 100] }, + className: 'dt-unity-footer d-md-flex col-md', + features: [{ + info: { text: '_START_-_END_ of _TOTAL_' }, + paging: { buttons: 3, boundaryNumbers: true, firstLast: true }, + pageLength: {}, + }] }, }, initComplete: function () { @@ -331,11 +344,23 @@ function initializeDataTable(options) { } }, stateLoadParams: function (settings, data) { - if (data.externalSearch) { + if (data?.externalSearch) { let externalSearch = $(settings.oInit.externalSearchInputId); if (externalSearch.length) externalSearch.val(data.externalSearch); } + // Validate length value to prevent invalid state issues after updates to lengthMenu options + const availableLengths = Array.isArray(settings?.aLengthMenu) + ? settings.aLengthMenu + : []; + + if (data && availableLengths.length > 0) { + const currentLength = data.length; + if (currentLength === undefined || !availableLengths.includes(currentLength)) { + data.length = availableLengths[0]; + } + } + // Call custom stateLoad hook if provided if (typeof settings.oInit.onStateLoadParams === 'function') { settings.oInit.onStateLoadParams(settings, data); @@ -387,7 +412,13 @@ function initializeDataTable(options) { settings.oInit.onStateLoaded(dtApi, data); } }, - }); + }; + + if (fixedHeaders) { + configuration.scrollY = 'calc(100vh - 325px)'; // Initial value – ScrollResize plugin will recalculate dynamically + } + + let iDt = new DataTable(dt, configuration); // Initialize FilterRow plugin initializeFilterRowPlugin(iDt); @@ -406,6 +437,11 @@ function initializeDataTable(options) { if (originalEvent.target.nodeName.toLowerCase() === 'a') e.preventDefault(); }); + // Initialize ScrollResize plugin for dynamic scroll body sizing + if (fixedHeaders && DataTable.ScrollResize) { + iDt.settings()[0]._scrollResize = new DataTable.ScrollResize(iDt); + } + return iDt; } @@ -628,37 +664,6 @@ function moveButtonsToContainer(iDt, updatedActionButtons, dynamicButtonContaine } } -// ============================================================================ -// ======= RESIZE SCROLL BODY ================================================= -/** - * Dynamically adjusts the DataTable scroll body height based on container size. - * Leaves room for headers, filters, and paging. - * @param {DataTable.Api} iDt - */ -function resizeDataTableScrollBody(iDt) { - if (!iDt?.table?.()?.node) return; - - const $wrapper = $(iDt.table().container()); - const $scrollBody = $wrapper.find('.dt-scroll-body'); - if (!$scrollBody.length) return; - - let reservedHeight = 0; - reservedHeight += $wrapper.find('.dt-scroll-head').outerHeight(true) || 0; - reservedHeight += $wrapper.find('.dt-top, .dataTables_length, .dataTables_filter').outerHeight(true) || 0; - reservedHeight += $wrapper.find('.dt-bottom, .dataTables_paginate, .dataTables_info').outerHeight(true) || 0; - reservedHeight += 8; // buffer - - const $container = $wrapper.closest('.dt-container, .dataTables_wrapper'); - if (!$container.length) return; - const containerHeight = $container.innerHeight(); - if (!containerHeight) return; - - const newHeight = Math.max(containerHeight - reservedHeight, 150); - $scrollBody.css({ height: newHeight + 'px', maxHeight: newHeight + 'px' }); - - try { iDt.columns.adjust(); } catch (e) { console.warn('resizeDataTableScrollBody: columns.adjust failed', e); } -} - // ============================================================================ // Other previously existing functions (init, getSelectColumn, assignColumnIndices, etc.) remain unchanged @@ -681,7 +686,7 @@ function createNumberFormatter() { */ function addDataTableFixCSS() { if (!$('#dt-column-fix-css').length) { - $('').appendTo('head'); + $('').appendTo('head'); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentDto.cs index f11a22cc5..3dc9eb44c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentDto.cs @@ -15,6 +15,7 @@ public class AssessmentDto : EntityDto public AssessmentState Status { get; set; } public bool IsComplete { get; set; } public bool? ApprovalRecommended { get; set; } + public bool IsAiAssessment { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentListItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentListItemDto.cs index 9cb78cee7..f1e9606f6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentListItemDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/AssessmentListItemDto.cs @@ -17,7 +17,8 @@ public class AssessmentListItemDto public AssessmentState Status { get; set; } public bool IsComplete { get; set; } public bool? ApprovalRecommended { get; set; } - + public bool IsAiAssessment { get; set; } + public double SubTotal { get; set; } public int? FinancialAnalysis { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/IAssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/IAssessmentAppService.cs index 0ed44b973..6965da148 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/IAssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Assessments/IAssessmentAppService.cs @@ -16,4 +16,6 @@ public interface IAssessmentAppService : IApplicationService Task GetCurrentUserAssessmentId(Guid applicationId); Task UpdateAssessmentScore(AssessmentScoresDto dto); Task SaveScoresheetSectionAnswers(AssessmentScoreSectionDto dto); + Task CloneFromAiAsync(Guid aiAssessmentId); + Task GetDisplayList(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index 4a983ed32..c829cf27e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -56,6 +56,12 @@ public override void Define(IFeatureDefinitionContext context) displayName: LocalizableString .Create("AI Reporting"), valueType: new ToggleStringValueType()); + + myGroup.AddFeature("Unity.AI.Scoring", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Scoring"), + valueType: new ToggleStringValueType()); } } } 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 428f8a5d6..710a9bfca 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 @@ -48,6 +48,12 @@ 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")); + var applicantInfoPermissions = applicatPermissions.AddChild( + GrantApplicationPermissions.Applicants.ApplicantInfoDefault, + L("Permission:GrantApplicationManagement.Applicants.ApplicantInfo")); + applicantInfoPermissions.AddChild( + GrantApplicationPermissions.Applicants.EditRedStop, + L("Permission:GrantApplicationManagement.Applicants.ApplicantInfo.EditRedStop")); // Assignment var assignmentPermissions = grantApplicationPermissionsGroup.AddPermission(GrantApplicationPermissions.Assignments.Default, L("Permission:GrantApplicationManagement.Assignments.Default")); @@ -130,6 +136,11 @@ public override void Define(IPermissionDefinitionContext context) GrantApplicationPermissions.AI.AttachmentSummary.Default, L("Permission:AI.AttachmentSummary")) .RequireFeatures("Unity.AI.AttachmentSummaries"); + + aiPermissionsGroup.AddPermission( + GrantApplicationPermissions.AI.ScoringAssistant.Default, + L("Permission:AI.ScoringAssistant")) + .RequireFeatures("Unity.AI.Scoring"); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 6d5f5f19e..418c31ebc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -18,6 +18,14 @@ public class OpenAIService : IAIService, ITransientDependency private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; + private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; + private const string AttachmentSummaryPromptType = "AttachmentSummary"; + private const string ScoresheetAllPromptType = "ScoresheetAll"; + private const string ScoresheetSectionPromptType = "ScoresheetSection"; + private const string NoSummaryGeneratedMessage = "No summary generated."; + private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; + private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; + private const string SummaryFailedRetryMessage = "AI analysis failed - please try again later."; private string? ApiKey => _configuration["Azure:OpenAI:ApiKey"]; private string? ApiUrl => _configuration["Azure:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; @@ -68,22 +76,25 @@ public async Task GenerateApplicationAnalysisAsync( var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schemaJson = JsonSerializer.Serialize(request.Schema, JsonLogOptions); - var attachmentSummaries = request.Attachments - .Select(a => $"{a.Name}: {a.Summary}") - .ToList(); - - var applicationContent = $@"DATA -{dataJson}"; - - var formFieldConfiguration = $@"SCHEMA -{schemaJson}"; + var attachmentsPayload = request.Attachments + .Select(a => new + { + name = string.IsNullOrWhiteSpace(a.Name) ? "attachment" : a.Name.Trim(), + summary = string.IsNullOrWhiteSpace(a.Summary) ? string.Empty : a.Summary.Trim() + }) + .Cast(); - var raw = await AnalyzeApplicationAsync( - applicationContent, - attachmentSummaries, - request.Rubric ?? string.Empty, - formFieldConfiguration); - return ParseApplicationAnalysisResponse(raw); + var analysisContent = AnalysisPrompts.BuildUserPrompt( + schemaJson, + dataJson, + JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), + request.Rubric ?? string.Empty); + + var systemPrompt = AnalysisPrompts.SystemPrompt; + await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); + var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); + return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); } public async Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150) @@ -91,7 +102,7 @@ public async Task GenerateSummaryAsync(string content, string? prompt = if (string.IsNullOrEmpty(ApiKey)) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return "AI analysis not available - service not configured."; + return ServiceNotConfiguredMessage; } _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); @@ -129,12 +140,12 @@ public async Task GenerateSummaryAsync(string content, string? prompt = if (!response.IsSuccessStatusCode) { _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); - return "AI analysis failed - service temporarily unavailable."; + return ServiceTemporarilyUnavailableMessage; } if (string.IsNullOrWhiteSpace(responseContent)) { - return "No summary generated."; + return NoSummaryGeneratedMessage; } using var jsonDoc = JsonDocument.Parse(responseContent); @@ -142,15 +153,15 @@ public async Task GenerateSummaryAsync(string content, string? prompt = if (choices.GetArrayLength() > 0) { var message = choices[0].GetProperty("message"); - return message.GetProperty("content").GetString() ?? "No summary generated."; + return message.GetProperty("content").GetString() ?? NoSummaryGeneratedMessage; } - return "No summary generated."; + return NoSummaryGeneratedMessage; } catch (Exception ex) { _logger.LogError(ex, "Error generating AI summary"); - return "AI analysis failed - please try again later."; + return SummaryFailedRetryMessage; } } @@ -186,9 +197,9 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] var contentToAnalyze = AttachmentPrompts.BuildUserPrompt( JsonSerializer.Serialize(attachmentPayload, JsonLogOptions)); - await LogPromptInputAsync("AttachmentSummary", prompt, contentToAnalyze); + await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync("AttachmentSummary", modelOutput); + await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); return modelOutput; } catch (Exception ex) @@ -212,7 +223,7 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis if (string.IsNullOrEmpty(ApiKey)) { _logger.LogWarning("{Message}", MissingApiKeyMessage); - return "AI analysis not available - service not configured."; + return ServiceNotConfiguredMessage; } try @@ -254,9 +265,9 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis var systemPrompt = AnalysisPrompts.SystemPrompt; - await LogPromptInputAsync("ApplicationAnalysis", systemPrompt, analysisContent); + await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync("ApplicationAnalysis", rawAnalysis); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -264,7 +275,7 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis catch (Exception ex) { _logger.LogError(ex, "Error analyzing application"); - return "AI analysis failed - please try again later."; + return SummaryFailedRetryMessage; } } @@ -383,9 +394,9 @@ Base your answers on the application content and attachment summaries provided. Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. Respond only with valid JSON in the exact format requested."; - await LogPromptInputAsync("ScoresheetAll", systemPrompt, analysisContent); + await LogPromptInputAsync(ScoresheetAllPromptType, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync("ScoresheetAll", modelOutput); + await LogPromptOutputAsync(ScoresheetAllPromptType, modelOutput); return modelOutput; } catch (Exception ex) @@ -428,17 +439,20 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati name = sectionName, questions = sectionQuestionsPayload }; + var sectionPayloadJson = JsonSerializer.Serialize(sectionPayload, JsonLogOptions); + var responseTemplate = BuildScoresheetSectionResponseTemplate(sectionPayloadJson); var analysisContent = ScoresheetPrompts.BuildSectionUserPrompt( applicationContent, attachmentSummariesText, - JsonSerializer.Serialize(sectionPayload, JsonLogOptions)); + sectionPayloadJson, + responseTemplate); var systemPrompt = ScoresheetPrompts.SectionSystemPrompt; - await LogPromptInputAsync("ScoresheetSection", systemPrompt, analysisContent); + await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync("ScoresheetSection", modelOutput); + await LogPromptOutputAsync(ScoresheetSectionPromptType, modelOutput); return modelOutput; } catch (Exception ex) @@ -498,7 +512,7 @@ private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(stri { response.Dismissed = dismissed .EnumerateArray() - .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null) + .Select(GetStringValueOrNull) .Where(item => !string.IsNullOrWhiteSpace(item)) .Cast() .ToList(); @@ -519,6 +533,16 @@ private static bool TryGetStringProperty(JsonElement root, string propertyName, return !string.IsNullOrWhiteSpace(value); } + private static string? GetStringValueOrNull(JsonElement element) + { + if (element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + + return null; + } + private static List ParseFindings(JsonElement array) { var findings = new List(); @@ -529,15 +553,30 @@ private static List ParseFindings(JsonElement array) continue; } - var id = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String + var id = item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String ? idProp.GetString() : null; - var title = item.TryGetProperty("category", out var titleProp) && titleProp.ValueKind == JsonValueKind.String - ? titleProp.GetString() - : null; - var detail = item.TryGetProperty("message", out var detailProp) && detailProp.ValueKind == JsonValueKind.String - ? detailProp.GetString() - : null; + string? title = null; + if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + { + title = titleProp.GetString(); + } + else if (item.TryGetProperty("category", out var legacyTitleProp) && + legacyTitleProp.ValueKind == JsonValueKind.String) + { + title = legacyTitleProp.GetString(); + } + + string? detail = null; + if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) + { + detail = detailProp.GetString(); + } + else if (item.TryGetProperty("message", out var legacyDetailProp) && + legacyDetailProp.ValueKind == JsonValueKind.String) + { + detail = legacyDetailProp.GetString(); + } findings.Add(new ApplicationAnalysisFinding { @@ -575,7 +614,7 @@ private static ScoresheetSectionResponse ParseScoresheetSectionResponse(string r var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && confidenceProp.ValueKind == JsonValueKind.Number && confidenceProp.TryGetInt32(out var parsedConfidence) - ? parsedConfidence + ? NormalizeConfidence(parsedConfidence) : 0; response.Answers[property.Name] = new ScoresheetSectionAnswer @@ -589,6 +628,58 @@ private static ScoresheetSectionResponse ParseScoresheetSectionResponse(string r return response; } + private static int NormalizeConfidence(int confidence) + { + var clamped = Math.Clamp(confidence, 0, 100); + var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; + return Math.Clamp(rounded, 0, 100); + } + + private static string BuildScoresheetSectionResponseTemplate(string sectionPayloadJson) + { + try + { + using var doc = JsonDocument.Parse(sectionPayloadJson); + if (!doc.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) + { + return ScoresheetPrompts.SectionOutputTemplate; + } + + var template = new Dictionary(); + foreach (var question in questions.EnumerateArray()) + { + if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var questionId = idProp.GetString(); + if (string.IsNullOrWhiteSpace(questionId)) + { + continue; + } + + template[questionId] = new + { + answer = string.Empty, + rationale = string.Empty, + confidence = 0 + }; + } + + if (template.Count == 0) + { + return ScoresheetPrompts.SectionOutputTemplate; + } + + return JsonSerializer.Serialize(template, JsonLogOptions); + } + catch (JsonException) + { + return ScoresheetPrompts.SectionOutputTemplate; + } + } + private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs index 19c8a8b66..2e5441280 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs @@ -2,6 +2,43 @@ namespace Unity.GrantManager.AI { internal static class AnalysisPrompts { + public const string DefaultRubric = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: + +1. ELIGIBILITY REQUIREMENTS: + - Project must align with program objectives + - Applicant must be eligible entity type + - Budget must be reasonable and well-justified + - Project timeline must be realistic + +2. COMPLETENESS CHECKS: + - All required fields completed + - Necessary supporting documents provided + - Budget breakdown detailed and accurate + - Project description clear and comprehensive + +3. FINANCIAL REVIEW: + - Requested amount is within program limits + - Budget is reasonable for scope of work + - Matching funds or in-kind contributions identified + - Cost per outcome/beneficiary is reasonable + +4. RISK ASSESSMENT: + - Applicant capacity to deliver project + - Technical feasibility of proposed work + - Environmental or regulatory compliance + - Potential for cost overruns or delays + +5. QUALITY INDICATORS: + - Clear project objectives and outcomes + - Well-defined target audience/beneficiaries + - Appropriate project methodology + - Sustainability plan for long-term impact + +EVALUATION CRITERIA: +- HIGH: Meets all requirements, well-prepared application, low risk +- MEDIUM: Meets most requirements, minor issues or missing elements +- LOW: Missing key requirements, significant concerns, high risk"; + public const string ScoreRules = @"HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. MEDIUM: Application has some gaps or weaknesses that require reviewer attention. LOW: Application has significant gaps or risks across key rubric areas."; @@ -14,20 +51,20 @@ internal static class AnalysisPrompts ""rating"": ""HIGH/MEDIUM/LOW"", ""warnings"": [ { - ""category"": ""Brief summary of the warning"", - ""message"": ""Detailed warning message with full context and explanation"" + ""title"": ""Brief summary of the warning"", + ""detail"": ""Detailed warning message with full context and explanation"" } ], ""errors"": [ { - ""category"": ""Brief summary of the error"", - ""message"": ""Detailed error message with full context and explanation"" + ""title"": ""Brief summary of the error"", + ""detail"": ""Detailed error message with full context and explanation"" } ], ""summaries"": [ { - ""category"": ""Brief summary of the recommendation"", - ""message"": ""Detailed recommendation with specific actionable guidance"" + ""title"": ""Brief summary of the recommendation"", + ""detail"": ""Detailed recommendation with specific actionable guidance"" } ], ""dismissed"": [] @@ -38,18 +75,18 @@ internal static class AnalysisPrompts - Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material issues; avoid nitpicking. - Each error/warning/recommendation must describe one concrete issue or consideration and why it matters. -- Use 3-6 words for category. -- Each message must be 1-2 complete sentences. -- Each message must be grounded in concrete evidence from provided inputs. -- If attachment evidence is used, reference the attachment explicitly in the message. +- Use 3-6 words for title. +- Each detail must be 1-2 complete sentences. +- Each detail must be grounded in concrete evidence from provided inputs. +- If attachment evidence is used, reference the attachment explicitly in detail. - Do not provide applicant-facing advice. - Do not mention rubric section names in findings. - If no findings exist, return empty arrays. -- rating must be HIGH, MEDIUM, or LOW. -- Return values exactly as specified in OUTPUT. -- Do not return keys outside OUTPUT. -- Return valid JSON only. -- Return plain JSON only (no markdown)."; +- rating must be HIGH, MEDIUM, or LOW." + + "\n" + PromptCoreRules.ExactOutputShape + + "\n" + PromptCoreRules.NoExtraOutputKeys + + "\n" + PromptCoreRules.ValidJsonOnly + + "\n" + PromptCoreRules.PlainJsonOnly; public static readonly string SystemPrompt = PromptHeader.Build( "You are an expert grant analyst assistant for human reviewers.", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs index 24bc74cb4..2db4de742 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs @@ -4,43 +4,59 @@ internal static class ScoresheetPrompts { public static readonly string SectionSystemPrompt = PromptHeader.Build( "You are an expert grant application reviewer for the BC Government.", - "Using DATA, ATTACHMENTS, SECTION, RESPONSE, and RULES, answer only the questions in SECTION."); + "Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES, answer only the questions in SECTION."); public const string SectionOutputTemplate = @"{ """": { ""answer"": """", ""rationale"": """", - ""confidence"": 85 + ""confidence"": } }"; - public const string SectionRules = @"- Use only DATA and ATTACHMENTS as evidence. -- Do not invent missing application details. -- Return exactly one answer object per question ID in SECTION.questions. + public const string SectionRules = "- Use only DATA and ATTACHMENTS as evidence.\n" + + "- Do not invent missing application details.\n" + + @"- Return exactly one answer object per question ID in SECTION.questions. - Do not omit any question IDs from SECTION.questions. - Do not add keys that are not question IDs from SECTION.questions. - Use RESPONSE as the output contract and fill every placeholder value. -- Each answer object must include: answer, rationale, confidence. -- answer type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- For yes/no questions, answer must be exactly ""Yes"" or ""No"". +- Follow this process in order: (1) copy RESPONSE, (2) iterate SECTION.questions in order, (3) fill answer+rationale+confidence for each matching question ID, (4) run final completeness check. +- Each answer object must include: ""answer"", ""rationale"", and ""confidence"". +- Never omit ""answer"", ""rationale"", or ""confidence"" for any question type. +- The ""answer"" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. +- The ""rationale"" field must be 1-2 complete sentences and grounded in concrete DATA/ATTACHMENTS evidence. +- In ""rationale"", cite concrete source evidence from the provided input content; do not cite prompt section headers. +- For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. +- If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. +- Do not treat missing or non-contradictory information as evidence. +- The ""confidence"" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. +- Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. +- For yes/no questions, the ""answer"" field must be exactly ""Yes"" or ""No"". - For numeric questions, answer must be a numeric value within the allowed range. -- For select list questions, answer must be the selected availableOptions.number encoded as a string. -- For select list questions, never return option label text (for example: ""Yes"", ""No"", or ""N/A""); return the option number string. -- For text and text area questions, answer must be concise, grounded in evidence, and non-empty. -- rationale must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. -- For every question, rationale must justify both the selected answer and confidence level based on evidence strength. -- If evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- confidence must be an integer from 0 to 100. -- Confidence reflects certainty in the selected answer given available evidence, not application quality. -- Return values exactly as specified in RESPONSE. -- Do not return keys outside RESPONSE. -- Return valid JSON only. -- Return plain JSON only (no markdown)."; +- For numeric questions, answer must never be blank. +- If evidence is insufficient for a numeric question, return the minimum allowed numeric value and explain uncertainty in rationale. +- If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. +- For select list questions, return only the selected options.number as a string (the option index shown in options), never label text or points. +- For select list questions, the ""answer"" value must be one of question.allowed_answers exactly. +- Never return 0 for select list answers unless 0 exists as an explicit option number. +- For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. +- For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. +- For comment fields, summarize key evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable. +- Do not leave rationale empty when answer is populated. +- Final self-check before responding: every question ID in RESPONSE must have a non-empty ""answer"", non-empty ""rationale"", and ""confidence"". +- If any answer object is incomplete, regenerate the full JSON response before returning it. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; public static string BuildSectionUserPrompt( string applicationContent, string attachmentSummariesText, - string sectionPayloadJson) + string sectionPayloadJson, + string responseTemplateJson) { return $@"DATA {applicationContent} @@ -52,6 +68,9 @@ public static string BuildSectionUserPrompt( {sectionPayloadJson} RESPONSE +{responseTemplateJson} + +OUTPUT {SectionOutputTemplate} RULES diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index d55efddd1..9aad041a5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Logging; +using NPOI.SS.UserModel; +using NPOI.XWPF.UserModel; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -13,6 +16,12 @@ namespace Unity.GrantManager.AI public partial class TextExtractionService : ITextExtractionService, ITransientDependency { private const int MaxExtractedTextLength = 50000; + private const int MaxExcelSheets = 10; + private const int MaxExcelRowsPerSheet = 2000; + private const int MaxExcelCellsPerRow = 50; + private const int MaxDocxParagraphs = 2000; + private const int MaxDocxTableRows = 2000; + private const int MaxDocxTableCellsPerRow = 50; private readonly ILogger _logger; public TextExtractionService(ILogger logger) @@ -20,97 +29,91 @@ public TextExtractionService(ILogger logger) _logger = logger; } - public async Task ExtractTextAsync(string fileName, byte[] fileContent, string contentType) + public Task ExtractTextAsync(string fileName, byte[] fileContent, string contentType) { if (fileContent == null || fileContent.Length == 0) { _logger.LogDebug("File content is empty for {FileName}", fileName); - return string.Empty; + return Task.FromResult(string.Empty); } try { - // Normalize content type var normalizedContentType = contentType?.ToLowerInvariant() ?? string.Empty; var extension = Path.GetExtension(fileName)?.ToLowerInvariant() ?? string.Empty; string rawText; - // Handle text-based files if (normalizedContentType.Contains("text/") || extension == ".txt" || extension == ".csv" || extension == ".json" || extension == ".xml") { - rawText = await ExtractTextFromTextFileAsync(fileContent); - return NormalizeAndLimitText(rawText, fileName); + rawText = ExtractTextFromTextFile(fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } - // Handle PDF files if (normalizedContentType.Contains("pdf") || extension == ".pdf") { - rawText = await Task.FromResult(ExtractTextFromPdfFile(fileName, fileContent)); - return NormalizeAndLimitText(rawText, fileName); + rawText = ExtractTextFromPdfFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } - // Handle Word documents if (normalizedContentType.Contains("word") || normalizedContentType.Contains("msword") || normalizedContentType.Contains("officedocument.wordprocessingml") || extension == ".doc" || extension == ".docx") { - // For now, return empty string - can be enhanced with Word parsing library - _logger.LogDebug("Word document text extraction not yet implemented for {FileName}", fileName); - return string.Empty; + if (extension == ".docx" || normalizedContentType.Contains("officedocument.wordprocessingml")) + { + rawText = ExtractTextFromWordDocx(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); + return Task.FromResult(string.Empty); } - // Handle Excel files if (normalizedContentType.Contains("excel") || normalizedContentType.Contains("spreadsheet") || extension == ".xls" || extension == ".xlsx") { - // For now, return empty string - can be enhanced with Excel parsing library - _logger.LogDebug("Excel text extraction not yet implemented for {FileName}", fileName); - return string.Empty; + rawText = ExtractTextFromExcelFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } - // For other file types, return empty string _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", contentType, extension); - return string.Empty; + return Task.FromResult(string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error extracting text from {FileName}", fileName); - return string.Empty; + return Task.FromResult(string.Empty); } } - private async Task ExtractTextFromTextFileAsync(byte[] fileContent) + private string ExtractTextFromTextFile(byte[] fileContent) { try { - // Try UTF-8 first var text = Encoding.UTF8.GetString(fileContent); - // Check if the decoded text contains replacement characters (indicates encoding issue) if (text.Contains('\uFFFD')) { - // Try other encodings text = Encoding.ASCII.GetString(fileContent); } - // Limit the extracted text to a reasonable size. if (text.Length > MaxExtractedTextLength) { text = text.Substring(0, MaxExtractedTextLength); _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); } - return await Task.FromResult(text); + return text; } catch (Exception ex) { @@ -126,33 +129,252 @@ private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = PdfDocument.Open(stream); var builder = new StringBuilder(); + var pageTexts = document.GetPages() + .Select(page => page.Text) + .Where(pageText => !string.IsNullOrWhiteSpace(pageText)); + + AppendUntilLimit(builder, pageTexts); + + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PDF text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var document = new XWPFDocument(stream); + var builder = new StringBuilder(); + var paragraphTexts = document.Paragraphs + .Take(MaxDocxParagraphs) + .Select(paragraph => paragraph.ParagraphText) + .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); + + AppendUntilLimit(builder, paragraphTexts); + + TryAppendDocxTableText(document, builder); + + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Word (.docx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private static void TryAppendDocxTableText(XWPFDocument document, StringBuilder builder) + { + if (builder.Length >= MaxExtractedTextLength) + { + return; + } + + foreach (var table in document.Tables) + { + foreach (var row in table.Rows.Take(MaxDocxTableRows)) + { + var cellTexts = row.GetTableCells() + .Take(MaxDocxTableCellsPerRow) + .Select(cell => cell.GetText()) + .Where(cellText => !string.IsNullOrWhiteSpace(cellText)); + + if (AppendUntilLimit(builder, cellTexts)) + { + return; + } + } + } + } + + private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var workbook = WorkbookFactory.Create(stream); + var builder = new StringBuilder(); + var sheetCount = Math.Min(workbook.NumberOfSheets, MaxExcelSheets); - foreach (var pageText in document.GetPages().Select(page => page.Text)) + for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { if (builder.Length >= MaxExtractedTextLength) { break; } - if (!string.IsNullOrWhiteSpace(pageText)) + var sheet = workbook.GetSheetAt(sheetIndex); + var limitReached = TryAppendExcelSheet(sheet, builder); + if (limitReached) { - builder.AppendLine(pageText); + break; } } - var text = builder.ToString(); - if (text.Length > MaxExtractedTextLength) + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Excel text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + { + if (sheet == null) + { + return false; + } + + var processedRows = 0; + foreach (IRow row in sheet) + { + if (processedRows >= MaxExcelRowsPerSheet || builder.Length >= MaxExtractedTextLength) { - text = text.Substring(0, MaxExtractedTextLength); + break; } - return text; + var limitReached = TryAppendExcelRow(row, builder); + processedRows++; + if (limitReached) + { + return true; + } } - catch (Exception ex) + + return builder.Length >= MaxExtractedTextLength; + } + + private static bool TryAppendExcelRow(IRow row, StringBuilder builder) + { + var rowHasValue = false; + foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) + { + var value = GetCellText(cell); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + string? separator = null; + if (rowHasValue) + { + separator = " | "; + } + + var limitReached = AppendWithLimit(builder, value, MaxExtractedTextLength, separator); + rowHasValue = true; + if (limitReached) + { + return true; + } + } + + if (rowHasValue && + builder.Length + Environment.NewLine.Length <= MaxExtractedTextLength) + { + builder.Append(Environment.NewLine); + } + + return builder.Length >= MaxExtractedTextLength; + } + + private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? value) + { + var limitReached = AppendWithLimit(builder, value, MaxExtractedTextLength); + if (limitReached) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(value)) + { + AppendTrailingNewlineIfRoom(builder); + } + + return builder.Length >= MaxExtractedTextLength; + } + + private static bool AppendUntilLimit(StringBuilder builder, IEnumerable texts) + { + var limitReached = texts.Any(text => TryAppendWithTrailingNewline(builder, text)); + return limitReached || builder.Length >= MaxExtractedTextLength; + } + + private static void AppendTrailingNewlineIfRoom(StringBuilder builder) + { + if (builder.Length > 0 && + builder.Length + Environment.NewLine.Length <= MaxExtractedTextLength) + { + builder.Append(Environment.NewLine); + } + } + + private static bool AppendWithLimit(StringBuilder builder, string? value, int maxLength, string? separator = null) + { + if (string.IsNullOrWhiteSpace(value)) + { + return builder.Length >= maxLength; + } + + if (builder.Length >= maxLength) + { + return true; + } + + var remaining = maxLength - builder.Length; + if (remaining <= 0) + { + return true; + } + + if (!string.IsNullOrEmpty(separator) && builder.Length > 0) + { + if (separator.Length >= remaining) + { + builder.Append(separator.AsSpan(0, remaining)); + return true; + } + + builder.Append(separator); + remaining -= separator.Length; + } + + if (value.Length >= remaining) + { + builder.Append(value.AsSpan(0, remaining)); + return true; + } + + builder.Append(value); + return false; + } + + private static string GetCellText(NPOI.SS.UserModel.ICell cell) + { + if (cell == null) { - _logger.LogWarning(ex, "PDF text extraction failed for {FileName}", fileName); return string.Empty; } + + return (cell.CellType switch + { + CellType.String => cell.StringCellValue ?? string.Empty, + CellType.Numeric => DateUtil.IsCellDateFormatted(cell) + ? cell.DateCellValue.ToString() + : cell.NumericCellValue.ToString(), + CellType.Boolean => cell.BooleanCellValue ? "true" : "false", + CellType.Formula => cell.ToString(), + CellType.Blank => string.Empty, + _ => cell.ToString() ?? string.Empty + }) ?? string.Empty; } private string NormalizeAndLimitText(string text, string fileName) 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 0568af942..251105e31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -17,6 +17,7 @@ using Unity.Modules.Shared; using Unity.Modules.Shared.Utils; using Unity.Payments.Domain.Suppliers; +using Unity.GrantManager.Permissions; using Unity.Payments.Integrations.Cas; using Unity.Payments.Suppliers; using Volo.Abp.DependencyInjection; @@ -195,7 +196,8 @@ public async Task PartialUpdateApplicantSummaryAsync(Guid applicantId .Distinct(StringComparer.OrdinalIgnoreCase) .ToList() ?? []; - if (modifiedSummaryFields.Contains(nameof(UpdateApplicantSummaryDto.RedStop), StringComparer.OrdinalIgnoreCase)) + if (modifiedSummaryFields.Contains(nameof(UpdateApplicantSummaryDto.RedStop), StringComparer.OrdinalIgnoreCase) + && await AuthorizationService.IsGrantedAsync(GrantApplicationPermissions.Applicants.EditRedStop)) { applicant.RedStop = input.Data.RedStop; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs index 75ac9b3c6..c5a2c26f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Volo.Abp; using Microsoft.AspNetCore.Authorization.Infrastructure; using System; using System.Collections.Generic; @@ -13,6 +14,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Comments; using Unity.GrantManager.Exceptions; +using Unity.GrantManager.Permissions; using Unity.GrantManager.Workflow; using Unity.Modules.Shared; using Volo.Abp.Application.Services; @@ -80,13 +82,25 @@ public async Task> GetListAsync(Guid applicationId) { IQueryable queryableAssessments = _assessmentRepository.GetQueryableAsync().Result; var assessments = queryableAssessments.Where(c => c.ApplicationId.Equals(applicationId)).ToList(); - return await Task.FromResult>(ObjectMapper.Map, List>(assessments.OrderByDescending(s => s.CreationTime).ToList())); + return await Task.FromResult>( + ObjectMapper.Map, List>( + assessments.OrderByDescending(s => s.IsAiAssessment).ThenByDescending(s => s.CreationTime).ToList())); } public async Task GetDisplayList(Guid applicationId) { var assessments = await _assessmentRepository.GetListWithAssessorsAsync(applicationId); var assessmentList = ObjectMapper.Map, List>(assessments); + + // If AI Scoring feature is disabled, or user doesn't have permissions to view AI assessments, filter out AI assessments from the list + var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + var canViewAI = await AuthorizationService.IsGrantedAsync(GrantApplicationPermissions.AI.ScoringAssistant.Default); + assessmentList = assessmentList + .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI)) + .OrderByDescending(a => a.IsAiAssessment) + .ThenByDescending(a => a.StartDate) + .ToList(); + bool isApplicationUsingDefaultScoresheet = true; foreach (var assessment in assessmentList) { @@ -224,6 +238,10 @@ public async Task UpdateAssessmentRecommendation(UpdateAssessmentRecommendationD var assessment = await _assessmentRepository.GetAsync(dto.AssessmentId); if (assessment != null) { + if (assessment.IsAiAssessment) + { + throw new BusinessException(GrantManagerDomainErrorCodes.CannotModifyAiAssessment); + } assessment.ApprovalRecommended = dto.ApprovalRecommended; await _assessmentRepository.UpdateAsync(assessment); } @@ -278,6 +296,11 @@ public async Task ExecuteAssessmentAction(Guid assessmentId, Asse { var assessment = await _assessmentRepository.GetAsync(assessmentId); + if (assessment.IsAiAssessment) + { + throw new BusinessException(GrantManagerDomainErrorCodes.CannotModifyAiAssessment); + } + await AuthorizationService.CheckAsync(assessment, GetActionAuthorizationRequirement(triggerAction)); await ApplyAdditionalValidationsAsync(assessmentId, triggerAction); @@ -332,6 +355,10 @@ public async Task UpdateAssessmentScore(AssessmentScoresDto dto) var assessment = await _assessmentRepository.GetAsync(dto.AssessmentId); if (assessment != null) { + if (assessment.IsAiAssessment) + { + throw new BusinessException(GrantManagerDomainErrorCodes.CannotModifyAiAssessment); + } if (CurrentUser.GetId() != assessment.AssessorId) { throw new AbpValidationException("Error: You do not own this assessment record."); @@ -357,6 +384,156 @@ public async Task UpdateAssessmentScore(AssessmentScoresDto dto) } } + /// + /// Creates a new human assessment by cloning an existing AI assessment. + /// Copies the AI scoresheet answers (from Application.AIScoresheetAnswers) as real + /// Answer records on the new assessment's scoresheet instance, and carries over + /// ApprovalRecommended as a starting point for the reviewer. + /// + /// The ID of the source AI assessment to clone from. + /// The newly created human . + /// + /// Thrown when the specified assessment is not an AI assessment. + /// + public async Task CloneFromAiAsync(Guid aiAssessmentId) + { + var aiAssessment = await _assessmentRepository.GetAsync(aiAssessmentId); + if (!aiAssessment.IsAiAssessment) + { + throw new BusinessException(GrantManagerDomainErrorCodes.CannotCloneNonAiAssessment); + } + + var application = await _applicationRepository.GetAsync(aiAssessment.ApplicationId); + var currentUser = await _userLookupProvider.FindByIdAsync(CurrentUser.GetId()); + var newAssessment = await _assessmentManager.CreateAsync(application, currentUser); + + newAssessment.ApprovalRecommended = aiAssessment.ApprovalRecommended; + await _assessmentRepository.UpdateAsync(newAssessment); + + if (await _featureChecker.IsEnabledAsync(UnityFlex) && !string.IsNullOrEmpty(application.AIScoresheetAnswers)) + { + await CopyAiAnswersToAssessmentAsync(application.AIScoresheetAnswers, newAssessment.Id); + } + + return ObjectMapper.Map(newAssessment); + } + + /// + /// Parses Application.AIScoresheetAnswers (JSONB) and writes each AI answer as a + /// real Answer record on the new human assessment's scoresheet instance. + /// + /// Question types are resolved via so that each value + /// is stored in the correct serialized format. SelectList answers are converted from the + /// AI's 1-based numeric index to the actual option value before being persisted. + /// Questions not identified as Numeric, YesNo, or SelectList default to TextArea. + /// + /// + /// Answers are persisted by publishing a + /// local event, reusing the same pipeline as . + /// + /// + private async Task CopyAiAnswersToAssessmentAsync(string aiScoresheetAnswers, Guid newAssessmentId) + { + var rawAiAnswers = new Dictionary(); + try + { + using var doc = JsonDocument.Parse(aiScoresheetAnswers); + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (!Guid.TryParse(property.Name, out var questionId)) continue; + if (property.Value.ValueKind != JsonValueKind.Object) continue; + if (!property.Value.TryGetProperty("answer", out var answerProp)) continue; + rawAiAnswers[questionId] = answerProp.ToString(); + } + } + catch (JsonException) + { + return; + } + + if (rawAiAnswers.Count == 0) return; + + var questionIds = rawAiAnswers.Keys.ToList(); + var numericQuestionIds = (await _scoresheetAppService.GetNumericQuestionIdsAsync(questionIds)).ToHashSet(); + var yesNoQuestions = await _scoresheetAppService.GetYesNoQuestionsAsync(questionIds); + var selectListQuestions = await _scoresheetAppService.GetSelectListQuestionsAsync(questionIds); + var yesNoQuestionIds = yesNoQuestions.Select(q => q.Id).ToHashSet(); + var selectListQuestionIds = selectListQuestions.Select(q => q.Id).ToHashSet(); + + var assessmentAnswers = new List(); + foreach (var (questionId, rawAnswer) in rawAiAnswers) + { + QuestionType questionType; + string answer; + + if (numericQuestionIds.Contains(questionId)) + { + questionType = QuestionType.Number; + answer = rawAnswer; + } + else if (yesNoQuestionIds.Contains(questionId)) + { + questionType = QuestionType.YesNo; + answer = rawAnswer; + } + else if (selectListQuestionIds.Contains(questionId)) + { + questionType = QuestionType.SelectList; + var q = selectListQuestions.Find(x => x.Id == questionId); + answer = ConvertNumericAnswerToSelectListValue(rawAnswer, q?.Definition); + } + else + { + questionType = QuestionType.TextArea; + answer = rawAnswer; + } + + assessmentAnswers.Add(new AssessmentAnswersEto + { + QuestionId = questionId, + Answer = answer, + QuestionType = (int)questionType + }); + } + + if (assessmentAnswers.Count > 0) + { + await _localEventBus.PublishAsync(new PersistScoresheetSectionInstanceEto + { + AssessmentId = newAssessmentId, + AssessmentAnswers = assessmentAnswers + }); + } + } + + /// + /// Converts a 1-based numeric index (as returned by the AI for SelectList questions) + /// to the actual option value defined in the question's JSON definition. + /// Returns the original value unchanged if parsing fails or the index is out of range. + /// + private static string ConvertNumericAnswerToSelectListValue(string numericAnswer, string? definition) + { + if (string.IsNullOrEmpty(definition) || string.IsNullOrEmpty(numericAnswer)) + return numericAnswer; + try + { + if (!int.TryParse(numericAnswer.Trim(), out var optionNumber) || optionNumber <= 0) + return numericAnswer; + var selectListDefinition = JsonSerializer.Deserialize(definition); + if (selectListDefinition?.Options != null && selectListDefinition.Options.Count > 0) + { + var optionIndex = optionNumber - 1; + if (optionIndex < selectListDefinition.Options.Count) + return selectListDefinition.Options[optionIndex].Value; + } + } + catch (JsonException) + { + // Malformed definition — return the raw answer unchanged + } + return numericAnswer; + } + public async Task SaveScoresheetSectionAnswers(AssessmentScoreSectionDto dto) { var assessment = await _assessmentRepository.GetAsync(dto.AssessmentId); @@ -364,6 +541,10 @@ public async Task SaveScoresheetSectionAnswers(AssessmentScoreSectionDto dto) { if (assessment != null) { + if (assessment.IsAiAssessment) + { + throw new BusinessException(GrantManagerDomainErrorCodes.CannotModifyAiAssessment); + } if (CurrentUser.GetId() != assessment.AssessorId) { throw new AbpValidationException("Error: You do not own this assessment record."); 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 609d3fc84..3a8a4bbff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs @@ -24,6 +24,12 @@ protected override async Task HandleRequirementAsync( OperationAuthorizationRequirement requirement, Assessment resource) { + if (resource.IsAiAssessment) + { + context.Fail(); + return; + } + if (requirement.Name.Equals(UnitySelector.Review.AssessmentReviewList.Update.SendBack) && await CheckPolicyAsync(requirement.Name, context)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs index 5cced9c59..029466de6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Linq; using System.Threading.Tasks; using Unity.GrantManager.AI; @@ -27,6 +28,9 @@ public class AttachmentAppService( IAIService aiService, ISubmissionAppService submissionAppService) : ApplicationService, IAttachmentAppService { + private const string DefaultContentType = "application/octet-stream"; + private const string SummaryGenerationFailedMessage = "AI summary generation failed."; + public async Task> GetApplicationAsync(Guid applicationId) { return (await GetAttachmentsAsync(new AttachmentParametersDto(AttachmentType.APPLICATION, applicationId))) @@ -88,13 +92,13 @@ public async Task> GetAttachmentsAsync(AttachmentParam protected internal async Task> GetAttachmentsInternalAsync( IRepository repository, - Func predicate) where T : AbstractS3Attachment + Expression> predicate) where T : AbstractS3Attachment { - var attachments = await repository.GetQueryableAsync(); + var attachmentsQuery = await repository.GetQueryableAsync(); var people = await personUserRepository.GetQueryableAsync(); - var query = from attachment in attachments.AsEnumerable() - join person in people.AsEnumerable() on attachment.UserId equals person.Id - where predicate(attachment) + var filteredAttachments = attachmentsQuery.Where(predicate); + var query = from attachment in filteredAttachments + join person in people on attachment.UserId equals person.Id select new UnityAttachmentDto() { Id = attachment.Id, @@ -182,41 +186,42 @@ protected internal static async Task UpdateMetadataIntern private static Guid? GetCreatorId(T attachment) where T : AbstractAttachmentBase { - var property = typeof(T).GetProperty("CreatorId"); - return property?.GetValue(attachment) as Guid?; + return attachment.CreatorId; } public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) { - // Get the attachment + if (!await aiService.IsAvailableAsync()) + { + Logger.LogWarning("AI service is not available for attachment summary generation. AttachmentId: {AttachmentId}", attachmentId); + return SummaryGenerationFailedMessage; + } + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; + var (fileContent, contentType) = await GetAttachmentContentForSummaryAsync(attachment, fileName); - // Get file content from CHEFS - var fileDto = await submissionAppService.GetChefsFileAttachment( - Guid.Parse(attachment.ChefsSubmissionId ?? ""), - Guid.Parse(attachment.ChefsFileId ?? ""), - attachment.FileName ?? ""); - - if (fileDto?.Content == null) + var summaryResponse = await aiService.GenerateAttachmentSummaryAsync(new AttachmentSummaryRequest { - return "Unable to retrieve file content for AI analysis."; - } - - // Generate AI summary - var summary = await aiService.GenerateAttachmentSummaryAsync( - attachment.FileName ?? "", - fileDto.Content, - fileDto.ContentType); - - // Update attachment with summary - attachment.AISummary = summary; + FileName = fileName, + FileContent = fileContent, + ContentType = contentType + }); + + attachment.AISummary = summaryResponse.Summary; await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); - - return summary; + + return summaryResponse.Summary; } public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds) { + if (!await aiService.IsAvailableAsync()) + { + Logger.LogWarning("AI service is not available for bulk attachment summary generation."); + return attachmentIds.Select(_ => SummaryGenerationFailedMessage).ToList(); + } + var summaries = new List(); foreach (var attachmentId in attachmentIds) @@ -229,11 +234,45 @@ public async Task> GenerateAISummariesAttachmentsAsync(List a catch (Exception ex) { Logger.LogError(ex, "Error generating AI summary for attachment {AttachmentId}", attachmentId); - summaries.Add($"Error generating summary: {ex.Message}"); + summaries.Add(SummaryGenerationFailedMessage); } } return summaries; } + private async Task<(byte[] Content, string ContentType)> GetAttachmentContentForSummaryAsync(ApplicationChefsFileAttachment attachment, string fileName) + { + if (!Guid.TryParse(attachment.ChefsSubmissionId, out var submissionId) || + !Guid.TryParse(attachment.ChefsFileId, out var fileId)) + { + Logger.LogWarning( + "Attachment {AttachmentId} has invalid CHEFS IDs. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + + try + { + var fileDto = await submissionAppService.GetChefsFileAttachment(submissionId, fileId, fileName); + if (fileDto?.Content == null) + { + Logger.LogWarning( + "Attachment {AttachmentId} has no retrievable content. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + + return (fileDto.Content, string.IsNullOrWhiteSpace(fileDto.ContentType) ? DefaultContentType : fileDto.ContentType); + } + catch (Exception ex) + { + Logger.LogWarning( + ex, + "Failed retrieving CHEFS content for attachment {AttachmentId}. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + } + } 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 ef1103791..075d56866 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -9,9 +9,10 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; using Unity.GrantManager.Applicants; @@ -37,7 +38,7 @@ namespace Unity.GrantManager.GrantApplications; [Authorize] [Dependency(ReplaceServices = true)] [ExposeServices(typeof(GrantApplicationAppService), typeof(IGrantApplicationAppService))] -public class GrantApplicationAppService( +public class GrantApplicationAppService( IApplicationManager applicationManager, IApplicationRepository applicationRepository, IApplicationStatusRepository applicationStatusRepository, @@ -46,10 +47,20 @@ public class GrantApplicationAppService( IApplicationFormRepository applicationFormRepository, IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, - IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService) - : GrantManagerAppService, IGrantApplicationAppService -{ + IApplicantSupplierAppService applicantSupplierService, + IPaymentRequestAppService paymentRequestService) + : GrantManagerAppService, IGrantApplicationAppService +{ + private static readonly JsonSerializerOptions AiAnalysisReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions AiAnalysisWriteOptions = new() + { + WriteIndented = true + }; + public async Task> GetListAsync(GrantApplicationListInputDto input) { // 1️ Fetch applications with filters + paging in DB @@ -191,9 +202,9 @@ public async Task GetAsync(Guid id) appDto.ContactCellPhone = application.ApplicantAgent.Phone2; } - if (application.Applicant != null) - { - appDto.OrganizationName = application.Applicant.OrgName; + if (application.Applicant != null) + { + appDto.OrganizationName = application.Applicant.OrgName; appDto.OrgNumber = application.Applicant.OrgNumber; appDto.OrganizationSize = application.Applicant.OrganizationSize; appDto.OrgStatus = application.Applicant.OrgStatus; @@ -202,11 +213,13 @@ public async Task GetAsync(Guid id) appDto.Sector = application.Applicant.Sector; appDto.OrganizationType = application.Applicant.OrganizationType; appDto.SubSector = application.Applicant.SubSector; - appDto.SectorSubSectorIndustryDesc = application.Applicant.SectorSubSectorIndustryDesc; - } - - return appDto; - } + appDto.SectorSubSectorIndustryDesc = application.Applicant.SectorSubSectorIndustryDesc; + } + + appDto.AIAnalysisData = ParseAiAnalysisData(appDto.AIAnalysis); + + return appDto; + } public async Task GetApplicationFormAsync(Guid applicationFormId) { @@ -1041,104 +1054,118 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami return result; } - public async Task DismissAIIssueAsync(Guid applicationId, string issueId) - { - var application = await applicationRepository.GetAsync(applicationId); - - if (string.IsNullOrEmpty(application.AIAnalysis)) - { - throw new UserFriendlyException("No AI analysis available for this application."); - } - - try - { - var updatedAnalysis = ModifyDismissedItems(application.AIAnalysis, issueId, isDismiss: true); - application.AIAnalysis = updatedAnalysis; - await applicationRepository.UpdateAsync(application); - return updatedAnalysis; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error dismissing AI issue {IssueId} for application {ApplicationId}", issueId, applicationId); - throw new UserFriendlyException("Failed to dismiss the AI issue. Please try again."); - } - } - - public async Task RestoreAIIssueAsync(Guid applicationId, string issueId) - { - var application = await applicationRepository.GetAsync(applicationId); - - if (string.IsNullOrEmpty(application.AIAnalysis)) + public async Task DismissAIIssueAsync(Guid applicationId, string issueId) + { + return await UpdateAIIssueDismissStateAsync(applicationId, issueId, isDismiss: true); + } + + public async Task RestoreAIIssueAsync(Guid applicationId, string issueId) + { + return await UpdateAIIssueDismissStateAsync(applicationId, issueId, isDismiss: false); + } + + private async Task UpdateAIIssueDismissStateAsync(Guid applicationId, string issueId, bool isDismiss) + { + if (string.IsNullOrWhiteSpace(issueId)) + { + throw new UserFriendlyException("AI issue id is required."); + } + + var application = await applicationRepository.GetAsync(applicationId); + + if (string.IsNullOrEmpty(application.AIAnalysis)) { throw new UserFriendlyException("No AI analysis available for this application."); - } - - try - { - var updatedAnalysis = ModifyDismissedItems(application.AIAnalysis, issueId, isDismiss: false); - application.AIAnalysis = updatedAnalysis; - await applicationRepository.UpdateAsync(application); - return updatedAnalysis; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error restoring AI issue {IssueId} for application {ApplicationId}", issueId, applicationId); - throw new UserFriendlyException("Failed to restore the AI issue. Please try again."); - } - } - - private static string ModifyDismissedItems(string analysisJson, string issueId, bool isDismiss) - { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - var dismissedItems = new HashSet(); - if (jsonDoc.RootElement.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedArray)) - { - foreach (var item in dismissedArray.EnumerateArray()) - { - var itemValue = item.GetString(); - if (!string.IsNullOrWhiteSpace(itemValue)) - { - dismissedItems.Add(itemValue); - } - } - } - - // Modify the dismissed items set - if (isDismiss && !string.IsNullOrWhiteSpace(issueId)) - { - dismissedItems.Add(issueId); - } - else if (!isDismiss) - { - dismissedItems.Remove(issueId); - } - - // Write all properties - foreach (var property in jsonDoc.RootElement.EnumerateObject()) + } + + try + { + var updatedAnalysis = ModifyDismissedItems(application.AIAnalysis, issueId, isDismiss); + application.AIAnalysis = updatedAnalysis; + await applicationRepository.UpdateAsync(application); + return updatedAnalysis; + } + catch (Exception ex) + { + var action = isDismiss ? "dismissing" : "restoring"; + var userMessage = isDismiss + ? "Failed to dismiss the AI issue. Please try again." + : "Failed to restore the AI issue. Please try again."; + + Logger.LogError(ex, "Error {Action} AI issue {IssueId} for application {ApplicationId}", action, issueId, applicationId); + throw new UserFriendlyException(userMessage); + } + } + + private static string ModifyDismissedItems(string analysisJson, string issueId, bool isDismiss) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return analysisJson; + } + + JsonObject? root; + try + { + root = JsonNode.Parse(analysisJson) as JsonObject; + } + catch (System.Text.Json.JsonException) + { + return analysisJson; + } + + if (root == null) + { + return analysisJson; + } + + var dismissedItems = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + if (root[AIJsonKeys.Dismissed] is JsonArray dismissedArray) + { + foreach (var item in dismissedArray) { - if (property.Name != AIJsonKeys.Dismissed) + var id = item?.GetValue(); + if (string.IsNullOrWhiteSpace(id) || !seen.Add(id)) { - property.WriteTo(writer); + continue; } + + dismissedItems.Add(id); } + } - // Write updated dismissed array - writer.WritePropertyName(AIJsonKeys.Dismissed); - writer.WriteStartArray(); - foreach (var id in dismissedItems) - { - writer.WriteStringValue(id); - } - writer.WriteEndArray(); - - writer.WriteEndObject(); - } - - return System.Text.Encoding.UTF8.GetString(memoryStream.ToArray()); - } -} + if (isDismiss) + { + if (seen.Add(issueId)) + { + dismissedItems.Add(issueId); + } + } + else + { + dismissedItems.RemoveAll(id => string.Equals(id, issueId, StringComparison.Ordinal)); + } + + root[AIJsonKeys.Dismissed] = new JsonArray(dismissedItems.Select(id => JsonValue.Create(id)).ToArray()); + return root.ToJsonString(AiAnalysisWriteOptions); + } + + private static ApplicationAnalysisResponse? ParseAiAnalysisData(string? analysisJson) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return null; + } + + try + { + return System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); + } + catch (System.Text.Json.JsonException) + { + return null; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationAutoMapperProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationAutoMapperProfile.cs index a46909e6d..947c58760 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationAutoMapperProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationAutoMapperProfile.cs @@ -132,6 +132,7 @@ public GrantManagerApplicationAutoMapperProfile() CreateMap() .ForMember(dest => dest.IndigenousOrgInd, opt => opt.MapFrom(src => ConvertBoolToIndigenousOrgInd(src.IndigenousOrgInd))) + .ForMember(dest => dest.RedStop, opt => opt.Ignore()) .IgnoreNullAndDefaultValues(); CreateMap() .IgnoreNullAndDefaultValues(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/PaymentHistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/PaymentHistoryAppService.cs index db660367e..439afb7c0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/PaymentHistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/PaymentHistoryAppService.cs @@ -15,6 +15,12 @@ public async Task> GetPaymentHistoryList(Guid? entityId) { List historyList = []; CancellationToken cancellationToken = default; + + if (entityId == null || entityId == Guid.Empty) + { + return historyList; + } + var entityChanges = await extendedAuditLogRepository.GetEntityChangeByTypeWithUsernameAsync( entityId, HistoryConsts.PaymentEntityTypeFullNames, @@ -23,18 +29,30 @@ public async Task> GetPaymentHistoryList(Guid? entityId) foreach (var entityChange in entityChanges) { + // Add explicit filter to ensure only matching entityId records + if (entityChange.EntityChange.EntityId != entityId.ToString()) + { + continue; + } + foreach (var propertyChange in entityChange.EntityChange.PropertyChanges) { - string origninalValue = CleanValue(propertyChange.OriginalValue); + string originalValue = CleanValue(propertyChange.OriginalValue); string newValue = CleanValue(propertyChange.NewValue); string displayNewValue = MapFsbToDisplayText(newValue); + + // Don't display history if both original and new values are empty, as it doesn't provide useful information and may clutter the history with irrelevant entries. + if (string.IsNullOrEmpty(originalValue) && string.IsNullOrEmpty(newValue)) + { + continue; + } int changeType = (int)entityChange.EntityChange.ChangeType; DateTime utcDateTime = DateTime.SpecifyKind(entityChange.EntityChange.ChangeTime, DateTimeKind.Utc); HistoryDto historyDto = new() { EntityName = GetShortEntityName(entityChange.EntityChange.EntityTypeFullName), - PropertyName = propertyChange.PropertyName, // The name of the property on the entity class. - OriginalValue = origninalValue, + PropertyName = propertyChange.PropertyName, + OriginalValue = originalValue, NewValue = displayNewValue, ChangeTime = utcDateTime.ToLocalTime(), UserName = entityChange.UserName, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Events/AiScoresheetAnswersGeneratedEvent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Events/AiScoresheetAnswersGeneratedEvent.cs new file mode 100644 index 000000000..2090524f7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Events/AiScoresheetAnswersGeneratedEvent.cs @@ -0,0 +1,8 @@ +using Unity.GrantManager.Applications; + +namespace Unity.GrantManager.Intakes.Events; + +public class AiScoresheetAnswersGeneratedEvent +{ + public Application? Application { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs new file mode 100644 index 000000000..a69723573 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.Assessments; +using Unity.GrantManager.Intakes.Events; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus; +using Volo.Abp.Features; + +namespace Unity.GrantManager.Intakes.Handlers; + +public class CreateAiAssessmentHandler( + AssessmentManager assessmentManager, + IFeatureChecker featureChecker, + ILogger logger) : ILocalEventHandler, ITransientDependency +{ + public async Task HandleEventAsync(AiScoresheetAnswersGeneratedEvent eventData) + { + if (eventData?.Application == null) + { + logger.LogWarning("Event data or application is null in CreateAiAssessmentHandler."); + return; + } + + if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) + { + return; + } + + try + { + await assessmentManager.CreateAiAssessmentAsync(eventData.Application); + logger.LogInformation("Created AI assessment for application {ApplicationId}.", eventData.Application.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating AI assessment for application {ApplicationId}.", eventData.Application.Id); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs new file mode 100644 index 000000000..8ab8bb009 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -0,0 +1,1069 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.AI; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes.Events; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus; +using Volo.Abp.EventBus.Local; +using Unity.Flex.Domain.Scoresheets; +using System.Text.Json; +using Volo.Abp.Features; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.Intakes.Handlers +{ + public class GenerateAIContentHandler : ILocalEventHandler, ITransientDependency + { + private const string DefaultContentType = "application/octet-stream"; + private const string ArrayFieldType = "array"; + private const string ObjectFieldType = "object"; + private const string AttachmentSummariesFeatureName = "Unity.AI.AttachmentSummaries"; + private const string ApplicationAnalysisFeatureName = "Unity.AI.ApplicationAnalysis"; + private readonly IAIService _aiService; + private readonly ISubmissionAppService _submissionAppService; + private readonly IApplicationChefsFileAttachmentRepository _attachmentRepository; + private readonly IApplicationRepository _applicationRepository; + private readonly IApplicationFormSubmissionRepository _applicationFormSubmissionRepository; + private readonly ILogger _logger; + private readonly IScoresheetRepository _scoresheetRepository; + private readonly IApplicationFormRepository _applicationFormRepository; + private readonly IApplicationFormVersionRepository _applicationFormVersionRepository; + private readonly IFeatureChecker _featureChecker; + public ILocalEventBus LocalEventBus { get; set; } = NullLocalEventBus.Instance; + private const string ComponentsKey = "components"; + private static readonly HashSet NonDataComponentTypes = new() + { + "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" + }; + private static readonly HashSet ExcludedPromptDataKeys = new(StringComparer.OrdinalIgnoreCase) + { + "simplefile", + "applicantAgent", + "submit", + "lateEntry", + "metadata", + "full_application_form_submission" + }; + private static readonly string[] AllowedAnalysisRootProperties = + { + AIJsonKeys.Rating, + AIJsonKeys.Errors, + AIJsonKeys.Warnings, + AIJsonKeys.Summaries, + AIJsonKeys.Dismissed + }; + private static readonly string[] AllowedFindingProperties = + { + AIJsonKeys.Id, + AIJsonKeys.Title, + AIJsonKeys.Detail + }; + private static readonly string[] AllowedScoresheetAnswerProperties = + { + AIJsonKeys.Answer, + AIJsonKeys.Rationale, + AIJsonKeys.Confidence + }; + + private readonly JsonSerializerOptions _jsonOptionsIndented = new JsonSerializerOptions + { + WriteIndented = true + }; + + public GenerateAIContentHandler( + IAIService aiService, + ISubmissionAppService submissionAppService, + IApplicationChefsFileAttachmentRepository attachmentRepository, + IApplicationRepository applicationRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + ILogger logger, + IScoresheetRepository scoresheetRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IFeatureChecker featureChecker) + { + _aiService = aiService; + _submissionAppService = submissionAppService; + _attachmentRepository = attachmentRepository; + _applicationRepository = applicationRepository; + _applicationFormSubmissionRepository = applicationFormSubmissionRepository; + _logger = logger; + _scoresheetRepository = scoresheetRepository; + _applicationFormRepository = applicationFormRepository; + _applicationFormVersionRepository = applicationFormVersionRepository; + _featureChecker = featureChecker; + } + + /// + /// Generate AI summaries for attachments when a new application is processed + /// + /// + /// + public async Task HandleEventAsync(ApplicationProcessEvent eventData) + { + if (eventData?.Application == null) + { + _logger.LogWarning("Event data or application is null in GenerateAIContentHandler."); + return; + } + + // Check if either AI feature is enabled + var attachmentSummariesEnabled = await _featureChecker.IsEnabledAsync(AttachmentSummariesFeatureName); + var applicationAnalysisEnabled = await _featureChecker.IsEnabledAsync(ApplicationAnalysisFeatureName); + + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled) + { + _logger.LogDebug("All AI features are disabled, skipping AI generation for application {ApplicationId}.", eventData.Application.Id); + return; + } + + // Check if AI service is available + if (!await _aiService.IsAvailableAsync()) + { + _logger.LogWarning("AI service is not available, skipping AI generation for application {ApplicationId}.", eventData.Application.Id); + return; + } + + _logger.LogInformation("Generating AI content for application {ApplicationId}.", eventData.Application.Id); + + try + { + // Get all CHEFS attachments for this application + var attachments = await _attachmentRepository.GetListAsync(a => a.ApplicationId == eventData.Application.Id); + + // Generate attachment summaries if feature is enabled + if (attachmentSummariesEnabled) + { + foreach (var attachment in attachments) + { + await ProcessAttachmentSummaryAsync(attachment, eventData.Application.Id); + } + } + + // Generate application analysis and scoresheet if feature is enabled + if (applicationAnalysisEnabled) + { + // After processing all attachments, perform application analysis + await GenerateApplicationAnalysisAsync(eventData.Application, attachments); + + // Generate AI scoresheet answers + await GenerateScoresheetAnalysisAsync(eventData.Application, attachments); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating AI content for application {ApplicationId}", eventData.Application.Id); + // Don't throw - this should not break the main submission processing + } + } + + private async Task ProcessAttachmentSummaryAsync(ApplicationChefsFileAttachment attachment, Guid applicationId) + { + try + { + if (!string.IsNullOrWhiteSpace(attachment.AISummary)) + { + _logger.LogDebug("Skipping AI summary for attachment {FileName} - already has summary", attachment.FileName); + return; + } + + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; + _logger.LogDebug("Generating AI summary for attachment {FileName}", fileName); + + var (fileContent, contentType) = await GetAttachmentContentForSummaryAsync(attachment, fileName); + var summary = await _aiService.GenerateAttachmentSummaryAsync(new AttachmentSummaryRequest + { + FileName = fileName, + FileContent = fileContent, + ContentType = contentType + }); + await SaveAttachmentSummaryAsync(attachment, fileName, summary.Summary, fileContent.Length == 0); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating AI summary for attachment {FileName} in application {ApplicationId}", + attachment.FileName, applicationId); + // Continue processing other attachments even if one fails + } + } + + private async Task<(byte[] Content, string ContentType)> GetAttachmentContentForSummaryAsync(ApplicationChefsFileAttachment attachment, string fileName) + { + if (!Guid.TryParse(attachment.ChefsSubmissionId, out var submissionId) || + !Guid.TryParse(attachment.ChefsFileId, out var fileId)) + { + _logger.LogWarning("Attachment {FileName} has invalid CHEFS IDs. Falling back to metadata-only summary.", fileName); + return (Array.Empty(), DefaultContentType); + } + + try + { + var fileDto = await _submissionAppService.GetChefsFileAttachment(submissionId, fileId, fileName); + if (fileDto?.Content == null) + { + _logger.LogWarning("Could not retrieve content for attachment {FileName}. Falling back to metadata-only summary.", fileName); + return (Array.Empty(), DefaultContentType); + } + + _logger.LogDebug( + "Processing {FileName} ({ContentType}, {Size} bytes) for AI summary generation", + fileName, + fileDto.ContentType, + fileDto.Content.Length); + + var resolvedContentType = string.IsNullOrWhiteSpace(fileDto.ContentType) + ? DefaultContentType + : fileDto.ContentType; + return (fileDto.Content, resolvedContentType); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not access CHEFS file {FileName}. Falling back to metadata-only summary.", fileName); + return (Array.Empty(), DefaultContentType); + } + } + + private async Task SaveAttachmentSummaryAsync(ApplicationChefsFileAttachment attachment, string fileName, string summary, bool usedMetadataFallback) + { + attachment.AISummary = summary; + await _attachmentRepository.UpdateAsync(attachment); + + if (usedMetadataFallback) + { + _logger.LogDebug("Generated fallback AI summary for attachment {FileName} from metadata only", fileName); + return; + } + + var preview = summary is { Length: > 0 } s + ? string.Concat(s.AsSpan(0, Math.Min(100, s.Length)), "...") + : "..."; + _logger.LogDebug("Successfully generated AI summary for attachment {FileName}: {SummaryPreview}", fileName, preview); + } + + private async Task GenerateApplicationAnalysisAsync(Application application, List attachments) + { + try + { + _logger.LogDebug("Starting application analysis for {ApplicationId}", application.Id); + + // Load the application from repository to ensure proper change tracking + var trackedApplication = await _applicationRepository.GetAsync(application.Id); + + // Skip if application already has analysis + if (!string.IsNullOrEmpty(trackedApplication.AIAnalysis)) + { + _logger.LogDebug("Skipping application analysis for {ApplicationId} - already has analysis", application.Id); + return; + } + + var analysisAttachments = BuildAnalysisAttachments(attachments); + + // Get form submission content including rendered HTML + var formSubmission = await _applicationFormSubmissionRepository + .GetByApplicationAsync(application.Id); + + var formFieldSchema = BuildEmptyFormFieldSchema(); + + if (formSubmission?.ApplicationFormVersionId is Guid formVersionId) + { + formFieldSchema = await ExtractFormFieldConfigurationSchemaAsync(formVersionId); + _logger.LogDebug("Extracted form field schema for application {ApplicationId}", + application.Id); + } + else + { + _logger.LogWarning("Could not extract form field schema for application {ApplicationId} - ApplicationFormVersionId is null", + application.Id); + } + + var analysisData = BuildAnalysisDataPayload(application, formSubmission); + _logger.LogInformation("Generating analysis for application {ApplicationId}", application.Id); + + _logger.LogDebug("Generating AI analysis for application {ApplicationId} with {AttachmentCount} attachment summaries", + application.Id, analysisAttachments.Count); + + var analysisRequest = new ApplicationAnalysisRequest + { + Schema = formFieldSchema, + Data = analysisData, + Attachments = analysisAttachments, + Rubric = AnalysisPrompts.DefaultRubric + }; + + var analysis = await _aiService.GenerateApplicationAnalysisAsync(analysisRequest); + var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); + if (!IsValidAnalysisPayload(analysisJson)) + { + _logger.LogWarning("Skipping invalid AI analysis payload for application {ApplicationId}.", application.Id); + return; + } + + // Update the tracked application with the analysis + trackedApplication.AIAnalysis = analysisJson; + await _applicationRepository.UpdateAsync(trackedApplication); + + _logger.LogInformation("Successfully generated AI analysis for application {ApplicationId}", application.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating application analysis for {ApplicationId}", application.Id); + // Don't throw - this should not break the main submission processing + } + } + + private static List BuildAnalysisAttachments(List 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(); + } + + private JsonElement BuildAnalysisDataPayload(Application application, ApplicationFormSubmission? formSubmission) + { + var fallbackPayload = BuildFallbackAnalysisDataPayload(application); + + if (string.IsNullOrWhiteSpace(formSubmission?.Submission)) + { + return JsonSerializer.SerializeToElement(fallbackPayload); + } + + try + { + using var submissionDoc = JsonDocument.Parse(formSubmission.Submission); + var root = submissionDoc.RootElement; + + JsonElement submissionData = root; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("data", out var dataElement) && + dataElement.ValueKind == JsonValueKind.Object) + { + submissionData = dataElement; + } + else if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("submission", out var submissionElement) && + submissionElement.ValueKind == JsonValueKind.Object && + submissionElement.TryGetProperty("data", out var nestedDataElement) && + nestedDataElement.ValueKind == JsonValueKind.Object) + { + submissionData = nestedDataElement; + } + + if (submissionData.ValueKind != JsonValueKind.Object) + { + return JsonSerializer.SerializeToElement(fallbackPayload); + } + + var values = BuildPromptDataValues(submissionData); + + return JsonSerializer.SerializeToElement(values); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to parse form submission JSON for application {ApplicationId}; falling back to curated analysis data.", + application.Id); + return JsonSerializer.SerializeToElement(fallbackPayload); + } + } + + private static object BuildFallbackAnalysisDataPayload(Application application) + { + var notSpecified = "Not specified"; + return new + { + project_name = application.ProjectName, + reference_number = application.ReferenceNo, + requested_amount = application.RequestedAmount, + total_project_budget = application.TotalProjectBudget, + project_summary = application.ProjectSummary ?? "Not provided", + city = application.City ?? notSpecified, + economic_region = application.EconomicRegion ?? notSpecified, + community = application.Community ?? notSpecified, + project_start_date = application.ProjectStartDate, + project_end_date = application.ProjectEndDate, + submission_date = application.SubmissionDate + }; + } + + private async Task GenerateScoresheetAnalysisAsync(Application application, List attachments) + { + try + { + _logger.LogDebug("Starting scoresheet analysis for {ApplicationId}", application.Id); + + // Load the application from repository to ensure proper change tracking + var trackedApplication = await _applicationRepository.GetAsync(application.Id); + + // Skip if application already has scoresheet analysis + if (!string.IsNullOrEmpty(trackedApplication.AIScoresheetAnswers)) + { + _logger.LogDebug("Skipping scoresheet analysis for {ApplicationId} - already has scoresheet answers", application.Id); + return; + } + + // Get the scoresheet for this application's form (using direct relationship like AssessmentManager does) + _logger.LogDebug("Getting ApplicationForm for application {ApplicationId} with ApplicationFormId {ApplicationFormId}", + application.Id, application.ApplicationFormId); + + var applicationForm = await _applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm == null) + { + _logger.LogDebug("ApplicationForm not found with ID {ApplicationFormId} for application {ApplicationId}", + application.ApplicationFormId, application.Id); + return; + } + + _logger.LogDebug("Found ApplicationForm {ApplicationFormName} with ScoresheetId {ScoresheetId} for application {ApplicationId}", + applicationForm.ApplicationFormName, applicationForm.ScoresheetId, application.Id); + + if (applicationForm.ScoresheetId == null) + { + _logger.LogDebug("No scoresheet found for application {ApplicationId} - ApplicationForm {ApplicationFormId} has null ScoresheetId", + application.Id, application.ApplicationFormId); + return; + } + + var scoresheet = await _scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null) + { + _logger.LogDebug("Scoresheet not found for application {ApplicationId}", application.Id); + return; + } + + var allSectionResults = new Dictionary(); + var scoresheetAttachments = BuildScoresheetAttachments(attachments); + var formSubmission = await _applicationFormSubmissionRepository.GetByApplicationAsync(application.Id); + var scoresheetData = BuildScoresheetDataPayload(application, formSubmission); + LogFormSubmissionPreview(formSubmission?.RenderedHTML); + + foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) + { + var sectionSchema = BuildScoresheetSectionSchema(section.Fields); + await ProcessScoresheetSectionAsync( + section.Name, + section.Fields.Count, + sectionSchema, + application.Id, + scoresheetData, + scoresheetAttachments, + allSectionResults); + } + + await SaveScoresheetResultsAsync(trackedApplication, allSectionResults); + await LocalEventBus.PublishAsync(new AiScoresheetAnswersGeneratedEvent + { + Application = application + }); + + _logger.LogInformation("Successfully generated and saved AI scoresheet answers for application {ApplicationId}. Answers will be parsed when scoresheet instance is created.", application.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating scoresheet analysis for {ApplicationId}", application.Id); + // Don't throw - this should not break the main submission processing + } + } + + private static List BuildScoresheetAttachments(List attachments) + { + return 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(); + } + + private JsonElement BuildScoresheetDataPayload(Application application, ApplicationFormSubmission? formSubmission) + { + var fallbackContent = BuildScoresheetFallbackContent(application, formSubmission?.RenderedHTML); + + if (string.IsNullOrWhiteSpace(formSubmission?.Submission)) + { + return JsonSerializer.SerializeToElement(new { submission_content = fallbackContent }); + } + + try + { + using var submissionDoc = JsonDocument.Parse(formSubmission.Submission); + var root = submissionDoc.RootElement; + + JsonElement submissionData = root; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("data", out var dataElement) && + dataElement.ValueKind == JsonValueKind.Object) + { + submissionData = dataElement; + } + else if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("submission", out var submissionElement) && + submissionElement.ValueKind == JsonValueKind.Object && + submissionElement.TryGetProperty("data", out var nestedDataElement) && + nestedDataElement.ValueKind == JsonValueKind.Object) + { + submissionData = nestedDataElement; + } + + if (submissionData.ValueKind == JsonValueKind.Object) + { + return JsonSerializer.SerializeToElement(BuildPromptDataValues(submissionData)); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to parse scoresheet submission JSON for application {ApplicationId}; falling back to summary content.", + application.Id); + } + + return JsonSerializer.SerializeToElement(new { submission_content = fallbackContent }); + } + + private static Dictionary BuildPromptDataValues(JsonElement submissionData) + { + var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? + new Dictionary(); + var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); + + foreach (var excludedKey in ExcludedPromptDataKeys) + { + values.Remove(excludedKey); + } + + return values; + } + + private static string BuildScoresheetFallbackContent(Application application, string? renderedFormHtml) + { + var notSpecified = "Not specified"; + return $@" +Project Name: {application.ProjectName} +Reference Number: {application.ReferenceNo} +Requested Amount: ${application.RequestedAmount:N2} +Total Project Budget: ${application.TotalProjectBudget:N2} +Project Summary: {application.ProjectSummary ?? "Not provided"} +City: {application.City ?? notSpecified} +Economic Region: {application.EconomicRegion ?? notSpecified} +Community: {application.Community ?? notSpecified} +Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} +Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} +Submission Date: {application.SubmissionDate.ToShortDateString()} + +FULL APPLICATION FORM SUBMISSION: +{renderedFormHtml ?? "Form submission content not available"} +"; + } + + private void LogFormSubmissionPreview(string? renderedFormHtml) + { + _logger.LogInformation("Form submission HTML length: {HtmlLength} characters", renderedFormHtml?.Length ?? 0); + if (renderedFormHtml?.Length > 100) + { + _logger.LogDebug("Form submission HTML is present and non-trivial."); + } + else + { + _logger.LogWarning("Form submission HTML is missing or very short."); + } + } + + private async Task ProcessScoresheetSectionAsync( + string sectionName, + int questionCount, + JsonElement sectionSchema, + Guid applicationId, + JsonElement scoresheetData, + List scoresheetAttachments, + Dictionary allSectionResults) + { + try + { + _logger.LogDebug("Processing section {SectionName} for application {ApplicationId}", + sectionName, applicationId); + var sectionAnswers = await _aiService.GenerateScoresheetSectionAnswersAsync(new ScoresheetSectionRequest + { + Data = scoresheetData, + Attachments = scoresheetAttachments, + SectionName = sectionName, + SectionSchema = sectionSchema + }); + + if (sectionAnswers.Answers.Count == 0) + { + return; + } + + var expectedQuestionIds = ExtractSectionQuestionIds(sectionSchema); + var returnedQuestionIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var answerEntry in sectionAnswers.Answers) + { + returnedQuestionIds.Add(answerEntry.Key); + allSectionResults[answerEntry.Key] = new Dictionary + { + [AIJsonKeys.Answer] = answerEntry.Value.Answer, + [AIJsonKeys.Rationale] = answerEntry.Value.Rationale, + [AIJsonKeys.Confidence] = answerEntry.Value.Confidence + }; + } + + var missingQuestionIds = expectedQuestionIds.Except(returnedQuestionIds, StringComparer.OrdinalIgnoreCase).ToArray(); + if (missingQuestionIds.Length > 0) + { + _logger.LogWarning( + "AI scoresheet response missing question answers for section {SectionName} in application {ApplicationId}. Expected: {ExpectedCount}, Returned: {ReturnedCount}, MissingIds: {MissingIds}.", + sectionName, + applicationId, + expectedQuestionIds.Count, + returnedQuestionIds.Count, + string.Join(",", missingQuestionIds)); + } + else + { + _logger.LogDebug( + "AI scoresheet response complete for section {SectionName} in application {ApplicationId}. Returned {ReturnedCount} answers.", + sectionName, + applicationId, + returnedQuestionIds.Count); + } + + _logger.LogDebug("Successfully processed section {SectionName} with {QuestionCount} questions", + sectionName, questionCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing section {SectionName} for application {ApplicationId}", + sectionName, applicationId); + } + } + + private static HashSet ExtractSectionQuestionIds(JsonElement sectionSchema) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + if (sectionSchema.ValueKind != JsonValueKind.Array) + { + return ids; + } + + foreach (var question in sectionSchema.EnumerateArray()) + { + if (question.ValueKind == JsonValueKind.Object && + question.TryGetProperty("id", out var idProp) && + idProp.ValueKind == JsonValueKind.String) + { + var id = idProp.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + ids.Add(id); + } + } + } + + return ids; + } + + private static JsonElement BuildScoresheetSectionSchema(IEnumerable fields) + { + var sectionQuestionsData = fields + .OrderBy(f => f.Order) + .Select(field => + { + var options = ExtractSelectListOptions(field); + return new + { + id = field.Id.ToString(), + question = field.Label, + description = field.Description, + type = field.Type.ToString(), + options, + allowed_answers = ExtractSelectListOptionNumbers(options) + }; + }) + .ToList(); + + return JsonSerializer.SerializeToElement(sectionQuestionsData); + } + + private async Task SaveScoresheetResultsAsync(Application trackedApplication, Dictionary allSectionResults) + { + var combinedResults = JsonSerializer.Serialize(allSectionResults, _jsonOptionsIndented); + if (!IsValidScoresheetAnswersPayload(combinedResults)) + { + _logger.LogWarning("Skipping invalid AI scoresheet payload for application {ApplicationId}.", trackedApplication.Id); + return; + } + + trackedApplication.AIScoresheetAnswers = combinedResults; + await _applicationRepository.UpdateAsync(trackedApplication); + } + + private static object[]? ExtractSelectListOptions(Unity.Flex.Domain.Scoresheets.Question field) + { + if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) + return null; + + try + { + var definition = JsonSerializer.Deserialize(field.Definition); + if (definition?.Options != null && definition.Options.Count > 0) + { + return definition.Options + .Select((option, index) => + (object)new + { + number = index + 1, + value = option.Value + }) + .ToArray(); + } + } + catch (JsonException) + { + // If definition parsing fails, return null + } + + return null; + } + + private static string[]? ExtractSelectListOptionNumbers(object[]? options) + { + if (options == null || options.Length == 0) + { + return null; + } + + return options + .Select((_, index) => (index + 1).ToString()) + .ToArray(); + } + + /// + /// Extracts form field metadata keyed by field key for analysis schema prompts. + /// + private async Task ExtractFormFieldConfigurationSchemaAsync(Guid formVersionId) + { + try + { + var formVersion = await _applicationFormVersionRepository.GetAsync(formVersionId); + if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + { + return BuildEmptyFormFieldSchema(); + } + + var schema = JObject.Parse(formVersion.FormSchema); + var components = schema[ComponentsKey] as JArray; + + if (components == null || components.Count == 0) + { + return BuildEmptyFormFieldSchema(); + } + + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + ExtractFieldRequirements(components, fields, string.Empty); + return JsonSerializer.SerializeToElement(fields); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting form field schema for form version {FormVersionId}", formVersionId); + return BuildEmptyFormFieldSchema(); + } + } + + private static JsonElement BuildEmptyFormFieldSchema() + { + return JsonSerializer.SerializeToElement(new Dictionary()); + } + + /// + /// Recursively extracts form field metadata from form components + /// + private static void ExtractFieldRequirements( + JArray components, + Dictionary fields, + 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)) + { + ProcessNestedFieldRequirements(component, type, fields, currentPath); + continue; + } + + var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; + var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; + + if (component["input"]?.Value() == true) + { + fields[key] = NormalizeFieldType(type, component); + } + + ProcessNestedFieldRequirements(component, type, fields, fullPath); + } + } + + /// + /// Processes nested components for different container types + /// + private static void ProcessNestedFieldRequirements( + JObject component, + string? type, + Dictionary fields, + string currentPath) + { + switch (type) + { + case "panel": + case "simplepanel": + case "fieldset": + case "well": + case "container": + case "datagrid": + case "table": + var nestedComponents = component[ComponentsKey] as JArray; + if (nestedComponents != null) + { + ExtractFieldRequirements(nestedComponents, fields, currentPath); + } + break; + + case "columns": + case "simplecols2": + case "simplecols3": + case "simplecols4": + var columns = component["columns"] as JArray; + if (columns != null) + { + foreach (var column in columns.OfType()) + { + var columnComponents = column[ComponentsKey] as JArray; + if (columnComponents != null) + { + ExtractFieldRequirements(columnComponents, fields, currentPath); + } + } + } + break; + + case "tabs": + case "simpletabs": + var tabs = component[ComponentsKey] as JArray; + if (tabs != null) + { + foreach (var tab in tabs.OfType()) + { + var tabComponents = tab[ComponentsKey] as JArray; + if (tabComponents != null) + { + ExtractFieldRequirements(tabComponents, fields, currentPath); + } + } + } + break; + } + } + + private static string NormalizeFieldType(string rawType, JObject component) + { + if (component["multiple"]?.Value() == true) + { + return ArrayFieldType; + } + + return rawType.ToLowerInvariant() switch + { + "number" => "number", + "currency" => "number", + "checkbox" => "boolean", + "datetime" => "date", + "day" => "date", + "date" => "date", + "time" => "date", + "datagrid" => ArrayFieldType, + "editgrid" => ArrayFieldType, + "table" => ArrayFieldType, + "container" => ObjectFieldType, + "panel" => ObjectFieldType, + "fieldset" => ObjectFieldType, + "well" => ObjectFieldType, + _ => "string" + }; + } + + private static bool IsValidAnalysisPayload(string analysisJson) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(analysisJson); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!root.TryGetProperty(AIJsonKeys.Rating, out var overallScore) || + overallScore.ValueKind != JsonValueKind.String) + { + return false; + } + + if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || + !root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) || + !root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) || + !root.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedItems)) + { + return false; + } + + if (!HasOnlyAllowedProperties(root, AllowedAnalysisRootProperties)) + { + return false; + } + + if (!IsValidFindingsArray(errors) || !IsValidFindingsArray(warnings) || !IsValidFindingsArray(summaries)) + { + return false; + } + + if (dismissedItems.ValueKind != JsonValueKind.Array || + dismissedItems.EnumerateArray().Any(id => id.ValueKind != JsonValueKind.String)) + { + return false; + } + + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool IsValidFindingsArray(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var finding in element.EnumerateArray()) + { + if (finding.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!finding.TryGetProperty(AIJsonKeys.Title, out var title) || title.ValueKind != JsonValueKind.String) + { + return false; + } + + if (!finding.TryGetProperty(AIJsonKeys.Detail, out var detail) || detail.ValueKind != JsonValueKind.String) + { + return false; + } + + if (!HasOnlyAllowedProperties(finding, AllowedFindingProperties)) + { + return false; + } + } + + return true; + } + + private static bool IsValidScoresheetAnswersPayload(string scoresheetJson) + { + if (string.IsNullOrWhiteSpace(scoresheetJson)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(scoresheetJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + foreach (var question in doc.RootElement.EnumerateObject().Select(question => question.Value)) + { + if (question.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!question.TryGetProperty(AIJsonKeys.Answer, out var answer)) + { + return false; + } + + var validAnswerType = answer.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False; + if (!validAnswerType) + { + return false; + } + + if (!question.TryGetProperty(AIJsonKeys.Rationale, out var rationale) || rationale.ValueKind != JsonValueKind.String) + { + return false; + } + + if (!question.TryGetProperty(AIJsonKeys.Confidence, out var confidence) || !confidence.TryGetInt32(out var confidenceValue)) + { + return false; + } + + if (confidenceValue < 0 || confidenceValue > 100) + { + return false; + } + + if (!HasOnlyAllowedProperties(question, AllowedScoresheetAnswerProperties)) + { + return false; + } + } + + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool HasOnlyAllowedProperties(JsonElement element, IReadOnlyCollection allowedProperties) + { + if (element.ValueKind != JsonValueKind.Object) + { + return false; + } + + var allowed = new HashSet(allowedProperties, StringComparer.Ordinal); + return element.EnumerateObject().All(property => allowed.Contains(property.Name)); + } + + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAISummaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAISummaryHandler.cs deleted file mode 100644 index dbc69472b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAISummaryHandler.cs +++ /dev/null @@ -1,742 +0,0 @@ -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.GrantManager.AI; -using Unity.GrantManager.Applications; -using Unity.GrantManager.Intakes.Events; -using Volo.Abp.DependencyInjection; -using Volo.Abp.EventBus; -using Unity.Flex.Domain.Scoresheets; -using System.Text.Json; -using Volo.Abp.Features; -using Newtonsoft.Json.Linq; -using System.Text; - -namespace Unity.GrantManager.Intakes.Handlers -{ - public class GenerateAiSummaryHandler : ILocalEventHandler, ITransientDependency - { - private readonly IAIService _aiService; - private readonly ISubmissionAppService _submissionAppService; - private readonly IApplicationChefsFileAttachmentRepository _attachmentRepository; - private readonly IApplicationRepository _applicationRepository; - private readonly IApplicationFormSubmissionRepository _applicationFormSubmissionRepository; - private readonly ILogger _logger; - private readonly IScoresheetRepository _scoresheetRepository; - private readonly IApplicationFormRepository _applicationFormRepository; - private readonly IApplicationFormVersionRepository _applicationFormVersionRepository; - private readonly IFeatureChecker _featureChecker; - const string ComponentsKey = "components"; - - readonly JsonSerializerOptions jsonOptions = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - readonly JsonSerializerOptions jsonOptionsIndented = new JsonSerializerOptions - { - WriteIndented = true - }; - - public GenerateAiSummaryHandler( - IAIService aiService, - ISubmissionAppService submissionAppService, - IApplicationChefsFileAttachmentRepository attachmentRepository, - IApplicationRepository applicationRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - ILogger logger, - IScoresheetRepository scoresheetRepository, - IApplicationFormRepository applicationFormRepository, - IApplicationFormVersionRepository applicationFormVersionRepository, - IFeatureChecker featureChecker) - { - _aiService = aiService; - _submissionAppService = submissionAppService; - _attachmentRepository = attachmentRepository; - _applicationRepository = applicationRepository; - _applicationFormSubmissionRepository = applicationFormSubmissionRepository; - _logger = logger; - _scoresheetRepository = scoresheetRepository; - _applicationFormRepository = applicationFormRepository; - _applicationFormVersionRepository = applicationFormVersionRepository; - _featureChecker = featureChecker; - } - - /// - /// Generate AI summaries for attachments when a new application is processed - /// - /// - /// - public async Task HandleEventAsync(ApplicationProcessEvent eventData) - { - if (eventData?.Application == null) - { - _logger.LogWarning("Event data or application is null in GenerateAiSummaryHandler."); - return; - } - - // Check if either AI feature is enabled - var attachmentSummariesEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); - var applicationAnalysisEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); - - if (!attachmentSummariesEnabled && !applicationAnalysisEnabled) - { - _logger.LogDebug("All AI features are disabled, skipping AI generation for application {ApplicationId}.", eventData.Application.Id); - return; - } - - // Check if AI service is available - if (!await _aiService.IsAvailableAsync()) - { - _logger.LogWarning("AI service is not available, skipping AI generation for application {ApplicationId}.", eventData.Application.Id); - return; - } - - _logger.LogInformation("Generating AI content for application {ApplicationId}.", eventData.Application.Id); - - try - { - // Get all CHEFS attachments for this application - var attachments = await _attachmentRepository.GetListAsync(a => a.ApplicationId == eventData.Application.Id); - - // Generate attachment summaries if feature is enabled - if (attachmentSummariesEnabled) - { - foreach (var attachment in attachments) - { - try - { - // Skip if already has an AI summary (don't regenerate) - if (!string.IsNullOrEmpty(attachment.AISummary)) - { - _logger.LogDebug("Skipping AI summary for attachment {FileName} - already has summary", attachment.FileName); - continue; - } - - _logger.LogDebug("Generating AI summary for attachment {FileName}", attachment.FileName); - - try - { - // Get the file content from CHEFS (now accessible via [AllowAnonymous]) - var fileDto = await _submissionAppService.GetChefsFileAttachment( - Guid.Parse(attachment.ChefsSubmissionId ?? ""), - Guid.Parse(attachment.ChefsFileId ?? ""), - attachment.FileName ?? ""); - - if (fileDto?.Content != null) - { - _logger.LogDebug("Processing {FileName} ({ContentType}, {Size} bytes) for AI summary generation", - attachment.FileName, fileDto.ContentType, fileDto.Content.Length); - - // Generate AI summary with text extraction and file content analysis - var summary = await _aiService.GenerateAttachmentSummaryAsync( - attachment.FileName ?? "", - fileDto.Content, - fileDto.ContentType); - - // Update the attachment with the AI summary - attachment.AISummary = summary; - await _attachmentRepository.UpdateAsync(attachment); - - var preview = summary is { Length: > 0 } s - ? string.Concat(s.AsSpan(0, Math.Min(100, s.Length)), "...") - : "..."; - - _logger.LogDebug("Successfully generated AI summary for attachment {FileName}: {SummaryPreview}", - attachment.FileName, preview); - } - else - { - _logger.LogWarning("Could not retrieve content for attachment {FileName}", attachment.FileName); - - // Generate summary from filename only as fallback - var summary = await _aiService.GenerateAttachmentSummaryAsync( - attachment.FileName ?? "", - Array.Empty(), - "application/octet-stream"); - - attachment.AISummary = summary; - await _attachmentRepository.UpdateAsync(attachment); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not access CHEFS file {FileName}. Generating summary from filename only.", attachment.FileName); - - // Fallback: Generate summary from filename only - var summary = await _aiService.GenerateAttachmentSummaryAsync( - attachment.FileName ?? "", - Array.Empty(), - "application/octet-stream"); - - attachment.AISummary = summary; - await _attachmentRepository.UpdateAsync(attachment); - - _logger.LogDebug("Generated fallback AI summary for attachment {FileName} from filename only", attachment.FileName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating AI summary for attachment {FileName} in application {ApplicationId}", - attachment.FileName, eventData.Application.Id); - // Continue processing other attachments even if one fails - } - } - } - - // Generate application analysis and scoresheet if feature is enabled - if (applicationAnalysisEnabled) - { - // After processing all attachments, perform application analysis - await GenerateApplicationAnalysisAsync(eventData.Application, attachments); - - // Generate AI scoresheet answers - await GenerateScoresheetAnalysisAsync(eventData.Application, attachments); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating AI content for application {ApplicationId}", eventData.Application.Id); - // Don't throw - this should not break the main submission processing - } - } - - private async Task GenerateApplicationAnalysisAsync(Application application, List attachments) - { - try - { - _logger.LogDebug("Starting application analysis for {ApplicationId}", application.Id); - - // Load the application from repository to ensure proper change tracking - var trackedApplication = await _applicationRepository.GetAsync(application.Id); - - // Skip if application already has analysis - if (!string.IsNullOrEmpty(trackedApplication.AIAnalysis)) - { - _logger.LogDebug("Skipping application analysis for {ApplicationId} - already has analysis", application.Id); - return; - } - - // Collect all attachment summaries that were generated - var attachmentSummaries = attachments - .Where(a => !string.IsNullOrEmpty(a.AISummary)) - .Select(a => $"{a.FileName}: {a.AISummary}") - .ToList(); - - // Get form submission content including rendered HTML - var formSubmission = await _applicationFormSubmissionRepository - .GetByApplicationAsync(application.Id); - - // Extract form field configuration (required vs optional fields) - string formFieldConfiguration = "Form configuration not available."; - if (formSubmission?.ApplicationFormVersionId != null) - { - formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); - _logger.LogDebug("Extracted form field configuration for application {ApplicationId}: {Configuration}", - application.Id, formFieldConfiguration); - } - else - { - _logger.LogWarning("Could not extract form field configuration for application {ApplicationId} - ApplicationFormVersionId is null", - application.Id); - } - - // Get application content including the full form submission - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; - _logger.LogInformation("Generating analysis for following application: {Application}", applicationContent); - - // Hardcoded rubric for now - var rubric = @" -BC GOVERNMENT GRANT EVALUATION RUBRIC: - -1. ELIGIBILITY REQUIREMENTS: - - Project must align with program objectives - - Applicant must be eligible entity type - - Budget must be reasonable and well-justified - - Project timeline must be realistic - -2. COMPLETENESS CHECKS: - - All required fields completed - - Necessary supporting documents provided - - Budget breakdown detailed and accurate - - Project description clear and comprehensive - -3. FINANCIAL REVIEW: - - Requested amount is within program limits - - Budget is reasonable for scope of work - - Matching funds or in-kind contributions identified - - Cost per outcome/beneficiary is reasonable - -4. RISK ASSESSMENT: - - Applicant capacity to deliver project - - Technical feasibility of proposed work - - Environmental or regulatory compliance - - Potential for cost overruns or delays - -5. QUALITY INDICATORS: - - Clear project objectives and outcomes - - Well-defined target audience/beneficiaries - - Appropriate project methodology - - Sustainability plan for long-term impact - -EVALUATION CRITERIA: -- HIGH: Meets all requirements, well-prepared application, low risk -- MEDIUM: Meets most requirements, minor issues or missing elements -- LOW: Missing key requirements, significant concerns, high risk -"; - - _logger.LogDebug("Generating AI analysis for application {ApplicationId} with {AttachmentCount} attachment summaries", - application.Id, attachmentSummaries.Count); - - // Generate the analysis with form field configuration - var analysis = await _aiService.AnalyzeApplicationAsync(applicationContent, attachmentSummaries, rubric, formFieldConfiguration); - - // Clean the response to remove any markdown formatting - var cleanedAnalysis = CleanJsonResponse(analysis); - - // Update the tracked application with the analysis - trackedApplication.AIAnalysis = cleanedAnalysis; - await _applicationRepository.UpdateAsync(trackedApplication); - - _logger.LogInformation("Successfully generated AI analysis for application {ApplicationId}", application.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating application analysis for {ApplicationId}", application.Id); - // Don't throw - this should not break the main submission processing - } - } - - private async Task GenerateScoresheetAnalysisAsync(Application application, List attachments) - { - try - { - _logger.LogDebug("Starting scoresheet analysis for {ApplicationId}", application.Id); - - // Load the application from repository to ensure proper change tracking - var trackedApplication = await _applicationRepository.GetAsync(application.Id); - - // Skip if application already has scoresheet analysis - if (!string.IsNullOrEmpty(trackedApplication.AIScoresheetAnswers)) - { - _logger.LogDebug("Skipping scoresheet analysis for {ApplicationId} - already has scoresheet answers", application.Id); - return; - } - - // Get the scoresheet for this application's form (using direct relationship like AssessmentManager does) - _logger.LogDebug("Getting ApplicationForm for application {ApplicationId} with ApplicationFormId {ApplicationFormId}", - application.Id, application.ApplicationFormId); - - var applicationForm = await _applicationFormRepository.GetAsync(application.ApplicationFormId); - if (applicationForm == null) - { - _logger.LogDebug("ApplicationForm not found with ID {ApplicationFormId} for application {ApplicationId}", - application.ApplicationFormId, application.Id); - return; - } - - _logger.LogDebug("Found ApplicationForm {ApplicationFormName} with ScoresheetId {ScoresheetId} for application {ApplicationId}", - applicationForm.ApplicationFormName, applicationForm.ScoresheetId, application.Id); - - if (applicationForm.ScoresheetId == null) - { - _logger.LogDebug("No scoresheet found for application {ApplicationId} - ApplicationForm {ApplicationFormId} has null ScoresheetId", - application.Id, application.ApplicationFormId); - return; - } - - var scoresheet = await _scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); - if (scoresheet == null) - { - _logger.LogDebug("Scoresheet not found for application {ApplicationId}", application.Id); - return; - } - - // Process each section individually for better AI focus - var allSectionResults = new Dictionary(); - - // Collect all attachment summaries that were generated - var attachmentSummaries = attachments - .Where(a => !string.IsNullOrEmpty(a.AISummary)) - .Select(a => $"{a.FileName}: {a.AISummary}") - .ToList(); - - // Get form submission for rendered HTML content - var formSubmission = await _applicationFormSubmissionRepository.GetByApplicationAsync(application.Id); - - // Get application content including the full form submission - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; - - _logger.LogInformation("Form submission HTML length: {HtmlLength} characters", formSubmission?.RenderedHTML?.Length ?? 0); - if (formSubmission?.RenderedHTML?.Length > 100) - { - _logger.LogDebug("Form submission HTML preview: {HtmlPreview}...", - formSubmission.RenderedHTML.Substring(0, Math.Min(500, formSubmission.RenderedHTML.Length))); - } - else - { - _logger.LogWarning("Form submission HTML is missing or very short: {FullHtml}", formSubmission?.RenderedHTML); - } - - // Process each section individually - foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) - { - try - { - _logger.LogDebug("Processing section {SectionName} for application {ApplicationId}", - section.Name, application.Id); - - // Build section-specific JSON - var sectionQuestionsData = new List(); - foreach (var field in section.Fields.OrderBy(f => f.Order)) - { - var questionData = new - { - id = field.Id.ToString(), - question = field.Label, - description = field.Description, - type = field.Type.ToString(), - definition = field.Definition, - availableOptions = ExtractSelectListOptions(field) - }; - sectionQuestionsData.Add(questionData); - } - - var sectionJson = JsonSerializer.Serialize(sectionQuestionsData, jsonOptions); - - // Generate AI answers for this section - var sectionAnswers = await _aiService.GenerateScoresheetSectionAnswersAsync( - applicationContent, - attachmentSummaries, - sectionJson, - section.Name); - - // Parse and store section results - if (!string.IsNullOrWhiteSpace(sectionAnswers)) - { - var cleanedJson = CleanJsonResponse(sectionAnswers); - try - { - using var sectionDoc = JsonDocument.Parse(cleanedJson); - foreach (var property in sectionDoc.RootElement.EnumerateObject()) - { - allSectionResults[property.Name] = property.Value.Clone(); - } - - _logger.LogDebug("Successfully processed section {SectionName} with {QuestionCount} questions", - section.Name, sectionQuestionsData.Count); - } - catch (JsonException ex) - { - _logger.LogError(ex, "Failed to parse AI response for section {SectionName} in application {ApplicationId}. Content: {InvalidJson}", - section.Name, application.Id, sectionAnswers); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing section {SectionName} for application {ApplicationId}", - section.Name, application.Id); - // Continue with other sections even if one fails - } - } - - // Combine all section results into final JSON - var combinedResults = JsonSerializer.Serialize(allSectionResults, jsonOptionsIndented); - - var scoresheetAnswers = combinedResults; - - // Validate and sanitize the JSON before saving - string validatedJson = "{}"; // Default empty JSON - try - { - if (!string.IsNullOrWhiteSpace(scoresheetAnswers)) - { - // Try to parse the JSON to validate it - using var jsonDoc = JsonDocument.Parse(scoresheetAnswers); - validatedJson = scoresheetAnswers; - _logger.LogDebug("AI generated valid JSON for scoresheet answers: {JsonPreview}", - scoresheetAnswers); - } - else - { - _logger.LogWarning("AI service returned empty or null scoresheet answers for application {ApplicationId}", application.Id); - } - } - catch (JsonException ex) - { - _logger.LogError(ex, "AI service returned invalid JSON for scoresheet answers for application {ApplicationId}. Content: {InvalidJson}", - application.Id, scoresheetAnswers); - validatedJson = "{}"; // Use empty JSON as fallback - } - - // Store AI scoresheet answers in the application for later parsing - trackedApplication.AIScoresheetAnswers = validatedJson; - await _applicationRepository.UpdateAsync(trackedApplication); - - _logger.LogInformation("Successfully generated and saved AI scoresheet answers for application {ApplicationId}. Answers will be parsed when scoresheet instance is created.", application.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet analysis for {ApplicationId}", application.Id); - // Don't throw - this should not break the main submission processing - } - } - - private static string CleanJsonResponse(string response) - { - if (string.IsNullOrWhiteSpace(response)) - return response; - - // Remove markdown code block delimiters - var cleaned = response.Trim(); - - // Handle ```json opening tag - if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) - { - var startIndex = cleaned.IndexOf('\n'); - if (startIndex >= 0) - { - cleaned = cleaned.Substring(startIndex + 1); - } - } - - // Handle closing ``` tag - if (cleaned.EndsWith("```")) - { - var lastIndex = cleaned.LastIndexOf("```"); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, lastIndex); - } - } - - return cleaned.Trim(); - } - - private static (int number, string value, long numericValue)[]? ExtractSelectListOptions(Unity.Flex.Domain.Scoresheets.Question field) - { - if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) - return null; - - try - { - var definition = JsonSerializer.Deserialize(field.Definition); - if (definition?.Options != null && definition.Options.Count > 0) - { - return definition.Options - .Select((option, index) => - (number: index, - value: option.Value, - numericValue: option.NumericValue)) - .ToArray(); - } - } - catch (JsonException) - { - // If definition parsing fails, return null - } - - return null; - } - - /// - /// Extracts field configuration from form schema to identify which fields are required vs optional - /// - /// The form version ID to extract configuration from - /// A formatted string describing required and optional fields - private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) - { - try - { - var formVersion = await _applicationFormVersionRepository.GetAsync(formVersionId); - if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) - { - return "Form configuration not available."; - } - - var schema = JObject.Parse(formVersion.FormSchema); - var components = schema[ComponentsKey] as JArray; - - if (components == null || components.Count == 0) - { - return "No form fields configured."; - } - - var requiredFields = new List(); - var optionalFields = new List(); - - ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); - - var configurationText = new StringBuilder(); - - configurationText.AppendLine("FORM FIELD CONFIGURATION:"); - configurationText.AppendLine(); - - if (requiredFields.Count > 0) - { - configurationText.AppendLine("REQUIRED FIELDS (must be completed):"); - foreach (var field in requiredFields) - { - configurationText.AppendLine($"- {field}"); - } - configurationText.AppendLine(); - } - - if (optionalFields.Count > 0) - { - configurationText.AppendLine("OPTIONAL FIELDS (may be left blank):"); - foreach (var field in optionalFields) - { - configurationText.AppendLine($"- {field}"); - } - } - - - return configurationText.ToString(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); - return "Form configuration could not be extracted."; - } - } - - /// - /// Recursively extracts field requirements from form components - /// - 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(); - - // Skip container components that don't represent actual data fields - var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type)) - { - // Still process nested components even if we skip the container - ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); - continue; - } - - var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; - var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; - - // Check if the field is required - var validate = component["validate"] as JObject; - var isRequired = validate?["required"]?.Value() ?? false; - - // Add to appropriate list (only for data input fields) - if (component["input"]?.Value() == true) - { - if (isRequired) - { - requiredFields.Add(fullPath); - } - else - { - optionalFields.Add(fullPath); - } - } - - // Process nested components - ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath); - } - } - - /// - /// Processes nested components for different container types - /// - 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": - var nestedComponents = component[ComponentsKey] as JArray; - if (nestedComponents != null) - { - ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath); - } - break; - - case "columns": - case "simplecols2": - case "simplecols3": - case "simplecols4": - var columns = component["columns"] as JArray; - if (columns != null) - { - foreach (var column in columns.OfType()) - { - var columnComponents = column[ComponentsKey] as JArray; - if (columnComponents != null) - { - ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath); - } - } - } - break; - - case "tabs": - case "simpletabs": - var tabs = component[ComponentsKey] as JArray; - if (tabs != null) - { - foreach (var tab in tabs.OfType()) - { - var tabComponents = tab[ComponentsKey] as JArray; - if (tabComponents != null) - { - ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); - } - } - } - break; - } - } - - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj index 8ec3e53bc..ff57bfd94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj @@ -33,6 +33,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs index b06c20bc5..25a6d4f8c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs @@ -12,6 +12,8 @@ public static class GrantManagerDomainErrorCodes /* ASSESSMENTS */ public const string AssessmentNotFound = "GrantManager:AssessmentNotFound"; + public const string CannotModifyAiAssessment = "GrantManager:CannotModifyAiAssessment"; + public const string CannotCloneNonAiAssessment = "GrantManager:CannotCloneNonAiAssessment"; public const string AssessmentUserAssignmentAlreadyExists = "GrantManager:AssessmentUserAssignmentAlreadyExists"; public const string CantCreateAssessmentForClosedApplication = "GrantManager:CantCreateAssessmentForClosedApplication"; public const string CantUpdateAssessmentForClosedApplication = "GrantManager:CantUpdateAssessmentForClosedApplication"; 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 667bc316f..85143a12d 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 @@ -100,6 +100,7 @@ "ReviewerList:InclusiveGrowth": "Inclusive Growth", "ReviewerList:CleanGrowth": "Clean Growth", "ReviewerList:Subtotal": "Subtotal", + "ReviewerList:CloneAssessment": "Clone Assessment", "AssessmentResultAttachments:Id": "#", "AssessmentResultAttachments:DocumentName": "Document Name", @@ -131,6 +132,8 @@ "Permission:GrantApplicationManagement.Applicants.Edit": "Edit Applicant", "Permission:GrantApplicationManagement.Applicants.ViewList": "View Applicant List", "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.Assignments.Default": "Assignment", "Permission:GrantApplicationManagement.Assignments.AssignInitial": "Initial Assignment", "Permission:GrantApplicationManagement.Reviews.Default": "Reviews", @@ -154,6 +157,7 @@ "Permission:AI.Reporting": "AI Reporting", "Permission:AI.ApplicationAnalysis": "AI Application Analysis", "Permission:AI.AttachmentSummary": "AI Attachment Summary", + "Permission:AI.ScoringAssistant": "AI Scoring Assistant", "ApplicationForms": "Forms", "ApplicationForms:Description": "Description", @@ -247,6 +251,8 @@ "GrantManager:PayableFormRequiresHierarchy": "Please select a form hierarchy before saving a payable form.", "GrantManager:ChildFormRequiresParentForm": "Please select a parent form when the form hierarchy is set to Child.", "GrantManager:ChildFormCannotReferenceSelf": "A form cannot reference itself as the parent.", + "GrantManager:CannotModifyAiAssessment": "AI assessments are read-only.", + "GrantManager:CannotCloneNonAiAssessment": "Only AI assessments can be cloned.", "AssessmentResultsView:ApprovalTitle": "Approval", "AssessmentResultsView:AssessmentResultsTitle": "Application Assessment Results", 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 b46f5c310..171c99656 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 @@ -34,10 +34,12 @@ public static class Applications 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 AssignApplicant = Default + ".AssignApplicant"; + public const string Default = GroupName + ".Applicants"; + public const string ViewList = Default + ".ViewList"; + public const string Edit = Default + Operation.Update; + public const string AssignApplicant = Default + ".AssignApplicant"; + public const string ApplicantInfoDefault = Default + ".ApplicantInfo"; + public const string EditRedStop = ApplicantInfoDefault + ".EditRedStop"; } public static class AI @@ -58,6 +60,11 @@ public static class AttachmentSummary { public const string Default = GroupName + ".AttachmentSummary"; } + + public static class ScoringAssistant + { + public const string Default = GroupName + ".ScoringAssistant"; + } } public static class Assignments diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AIScoringConstants.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AIScoringConstants.cs new file mode 100644 index 000000000..d74b69ae8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AIScoringConstants.cs @@ -0,0 +1,12 @@ +using System; + +namespace Unity.GrantManager.Assessments; + +public static class AIScoringConstants +{ + // Well-known fixed GUID for the AI Scoring Person record (one per tenant) + public static readonly Guid AiPersonId = new("00000000-0000-0000-0000-000000000001"); + public const string AiOidcSub = "ai-scoring"; + public const string AiDisplayName = "AI Scoring"; + public const string AiBadge = "AI"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/Assessment.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/Assessment.cs index e4c0edab9..3f573a3e6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/Assessment.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/Assessment.cs @@ -27,11 +27,13 @@ public virtual Application Application public bool? ApprovalRecommended { get; set; } + public bool IsAiAssessment { get; set; } + public AssessmentState Status { get; private set; } - public int? FinancialAnalysis { get; set; } - public int? EconomicImpact { get; set; } - public int? InclusiveGrowth { get; set; } + public int? FinancialAnalysis { get; set; } + public int? EconomicImpact { get; set; } + public int? InclusiveGrowth { get; set; } public int? CleanGrowth { get; set; } @@ -86,4 +88,4 @@ private void OnReopened() EndDate = null; } } - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs index 7aedd767f..977b65615 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentManager.cs @@ -62,7 +62,7 @@ public async Task CreateAsync( var form = await _applicationFormRepository.GetAsync(application.ApplicationFormId); var otherAssessments = await _assessmentRepository.GetListByApplicationId(application.Id); - bool hasOtherAssessments = otherAssessments != null && otherAssessments.Count != 0; + bool hasOtherAssessments = otherAssessments != null && otherAssessments.Any(a => !a.IsAiAssessment); var assessment = await _assessmentRepository.InsertAsync( new Assessment( @@ -91,6 +91,50 @@ await _localEventBus.PublishAsync(new CreateScoresheetInstanceEto() return assessment; } + /// + /// Creates and inserts a read-only AI Scoring for an application. + /// No user is required — the well-known AI Scoring Person is used as the assessor. + /// Does not affect application state. Idempotent: returns the existing record if one already exists. + /// + /// The application being assessed. + /// A new or existing AI for the . + public async Task CreateAiAssessmentAsync(Application application) + { + // Idempotency: only one AI assessment per application + var existing = await _assessmentRepository.FirstOrDefaultAsync( + x => x.ApplicationId == application.Id && x.IsAiAssessment); + if (existing != null) + { + return existing; + } + + var form = await _applicationFormRepository.GetAsync(application.ApplicationFormId); + + var newAssessment = new Assessment( + GuidGenerator.Create(), + application.Id, + AIScoringConstants.AiPersonId, + AssessmentState.COMPLETED) + { + IsAiAssessment = true + }; + + var assessment = await _assessmentRepository.InsertAsync(newAssessment, autoSave: true); + + if (form.ScoresheetId != null && await _featureChecker.IsEnabledAsync("Unity.Flex")) + { + await _localEventBus.PublishAsync(new CreateScoresheetInstanceEto() + { + ScoresheetId = form.ScoresheetId ?? Guid.Empty, + CorrelationId = assessment.Id, + CorrelationProvider = "Assessment", + RelatedCorrelationId = null + }); + } + + return assessment; + } + /// /// Checks if a user has already been assigned an for an . /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentWithAssessorQueryResultItem.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentWithAssessorQueryResultItem.cs index 28640316b..99a8cdbd8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentWithAssessorQueryResultItem.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Assessments/AssessmentWithAssessorQueryResultItem.cs @@ -16,9 +16,10 @@ public class AssessmentWithAssessorQueryResultItem public AssessmentState Status { get; set; } public bool IsComplete { get; set; } public bool? ApprovalRecommended { get; set; } - public int? FinancialAnalysis { get; set; } - public int? EconomicImpact { get; set; } - public int? InclusiveGrowth { get; set; } + public bool IsAiAssessment { get; set; } + public int? FinancialAnalysis { get; set; } + public int? EconomicImpact { get; set; } + public int? InclusiveGrowth { get; set; } public int? CleanGrowth { get; set; } } - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index a3ee4b5a9..6291c358c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.GrantManager.Assessments; using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Identity; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -9,7 +11,8 @@ namespace Unity.GrantManager; public class GrantManagerDataSeederContributor( - IApplicationStatusRepository applicationStatusRepository) : IDataSeedContributor, ITransientDependency + IApplicationStatusRepository applicationStatusRepository, + IPersonRepository personRepository) : IDataSeedContributor, ITransientDependency { public static class GrantApplicationStates { @@ -37,6 +40,7 @@ public async Task SeedAsync(DataSeedContext context) } await SeedApplicationStatusAsync(); + await SeedAiScoringPersonAsync(context.TenantId); } @@ -67,4 +71,21 @@ private async Task SeedApplicationStatusAsync() } } } + + private async Task SeedAiScoringPersonAsync(System.Guid? tenantId) + { + var existing = await personRepository.FirstOrDefaultAsync(p => p.Id == AIScoringConstants.AiPersonId); + if (existing == null) + { + await personRepository.InsertAsync(new Person + { + Id = AIScoringConstants.AiPersonId, + OidcSub = AIScoringConstants.AiOidcSub, + OidcDisplayName = AIScoringConstants.AiDisplayName, + FullName = AIScoringConstants.AiDisplayName, + Badge = AIScoringConstants.AiBadge, + TenantId = tenantId + }); + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260227210826_AddIsAiAssessmentToAssessment.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260227210826_AddIsAiAssessmentToAssessment.Designer.cs new file mode 100644 index 000000000..c68720af3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260227210826_AddIsAiAssessmentToAssessment.Designer.cs @@ -0,0 +1,4575 @@ +// +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("20260227210826_AddIsAiAssessmentToAssessment")] + partial class AddIsAiAssessmentToAssessment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .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("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("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("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("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + 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("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + 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.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.ToTable("ApplicantAgents", (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("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.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.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("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("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("RenderFormIoToHtml") + .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.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("RenderedHTML") + .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.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.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.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.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.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("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("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.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.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.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) + .IsRequired(); + + 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/20260227210826_AddIsAiAssessmentToAssessment.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260227210826_AddIsAiAssessmentToAssessment.cs new file mode 100644 index 000000000..1f02f8752 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260227210826_AddIsAiAssessmentToAssessment.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AddIsAiAssessmentToAssessment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAiAssessment", + table: "Assessments", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AlterColumn( + name: "Prefix", + table: "ApplicationForms", + type: "character varying(100)", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAiAssessment", + table: "Assessments"); + + migrationBuilder.AlterColumn( + name: "Prefix", + table: "ApplicationForms", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100, + oldNullable: true); + } + } +} 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 67f1c6df2..491ceaa2c 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 @@ -2332,6 +2332,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("InclusiveGrowth") .HasColumnType("integer"); + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + b.Property("IsComplete") .HasColumnType("boolean"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/AssessmentRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/AssessmentRepository.cs index bad0bf58d..bb14a784d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/AssessmentRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/AssessmentRepository.cs @@ -61,13 +61,14 @@ public async Task> GetListWithAssess EndDate = assessment.EndDate, Status = assessment.Status, IsComplete = assessment.IsComplete, - ApprovalRecommended = assessment.ApprovalRecommended, - FinancialAnalysis = assessment.FinancialAnalysis, - EconomicImpact = assessment.EconomicImpact, - InclusiveGrowth = assessment.InclusiveGrowth, + ApprovalRecommended = assessment.ApprovalRecommended, + IsAiAssessment = assessment.IsAiAssessment, + FinancialAnalysis = assessment.FinancialAnalysis, + EconomicImpact = assessment.EconomicImpact, + InclusiveGrowth = assessment.InclusiveGrowth, CleanGrowth = assessment.CleanGrowth }); return await query.ToListAsync(); - } + } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js index 09f86ab77..0ff706509 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js @@ -528,6 +528,7 @@ $(function () { pagingEnabled: true, reorderEnabled: true, languageSetValues, + fixedHeaders: true, dataTableName: 'ApplicantsTable', dynamicButtonContainerId: 'dynamicButtonContainerId', // Add state handling to validate and clear corrupted states diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.css index d3a841326..890f0aeb4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.css @@ -12,11 +12,6 @@ margin: 0.25rem; } -#ApplicationFormsTable_wrapper .dt-scroll-body { - max-height: calc(100vh - 350px); - overflow-y: scroll; -} - #FormsManageDropdown a { cursor: pointer; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.js index df0268574..05cd641f8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Index.js @@ -128,6 +128,7 @@ dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, externalSearchId: 'search-forms', + fixedHeaders: true }); createModal.onResult(function () { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index f4894efe2..b8d0fcaf5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -411,7 +411,7 @@

AI analysis results will appear here when available.

- @* Accordion list for errors, warnings, and recommendations *@ + @* Accordion list for errors, warnings, and summary *@
@* Accordion items will be dynamically generated here *@
@@ -453,11 +453,11 @@
- @* Template for recommendation item *@ -
-
-
-
+ @* Template for summary item *@ +
+
+
+
} @@ -466,4 +466,4 @@ - \ No newline at end of file + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 4ab9b69a5..9de9b4752 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -95,18 +95,10 @@ $(function () { dt.order(initialSortOrder); // Reset date range filters - UIElements.quickDateRange.val(defaultQuickDateRange); - toggleCustomDateInputs(defaultQuickDateRange === 'custom'); - const range = getDateRange(defaultQuickDateRange); + setDateRangeFilters(defaultQuickDateRange, range); setDateRangeLocalStorage(defaultQuickDateRange, range); - - if (range) { - UIElements.submittedFromInput.val(range.fromDate); - UIElements.submittedToInput.val(range.toDate); - grantTableFilters.submittedFromDate = range.fromDate; - grantTableFilters.submittedToDate = range.toDate; - } + toggleCustomDateInputs(defaultQuickDateRange === 'custom'); // Reload table data with updated filters dt.ajax.reload(null, false); @@ -185,32 +177,28 @@ $(function () { } function initializeSubmittedFilterDates() { - const fromDate = localStorage.getItem('GrantApplications_FromDate'); - const toDate = localStorage.getItem('GrantApplications_ToDate'); - const savedRange = localStorage.getItem('GrantApplications_QuickRange') || defaultQuickDateRange; - - // Set the dropdown value - UIElements.quickDateRange.val(savedRange); - - // Show/hide custom date inputs based on saved selection - toggleCustomDateInputs(savedRange === 'custom'); + let savedQuickRange = localStorage.getItem('GrantApplications_QuickRange') || defaultQuickDateRange; + let savedFromDate = localStorage.getItem('GrantApplications_FromDate'); + let savedToDate = localStorage.getItem('GrantApplications_ToDate'); + + let isCustomRange = savedQuickRange === 'custom'; + toggleCustomDateInputs(isCustomRange); + + let range = !isCustomRange + ? getDateRange(savedQuickRange) + : { + fromDate: savedFromDate || '', + toDate: savedToDate || '' + }; - // If we have saved dates, use them - if (fromDate && toDate) { - UIElements.submittedFromInput.val(fromDate); - UIElements.submittedToInput.val(toDate); - grantTableFilters.submittedFromDate = fromDate; - grantTableFilters.submittedToDate = toDate; - } else { - const range = getDateRange(defaultQuickDateRange); - if (range?.fromDate && range?.toDate) { - UIElements.submittedFromInput.val(range.fromDate); - UIElements.submittedToInput.val(range.toDate); - grantTableFilters.submittedFromDate = range.fromDate; - grantTableFilters.submittedToDate = range.toDate; - } + if (!isCustomRange && !range) { + savedQuickRange = defaultQuickDateRange; + range = getDateRange(savedQuickRange); } + setDateRangeFilters(savedQuickRange, range); + setDateRangeLocalStorage(savedQuickRange, range); + // Set max date to today for both inputs const today = formatDate(new Date()); UIElements.submittedToInput.attr({ 'max': today }); @@ -255,7 +243,40 @@ $(function () { return true; } - // Returns a formated { fromDate, toDate } for the filter fields. + function setDateRangeFilters(quickDateRange, range) { + UIElements.quickDateRange.val(quickDateRange); + + if (range) { + const fromDate = range.fromDate ?? ''; + const toDate = range.toDate ?? ''; + UIElements.submittedFromInput.val(fromDate); + UIElements.submittedToInput.val(toDate); + grantTableFilters.submittedFromDate = fromDate; + grantTableFilters.submittedToDate = toDate; + } + } + + function setDateRangeLocalStorage(quickDateRange, fromToRange) { + localStorage.setItem('GrantApplications_QuickRange', quickDateRange || defaultQuickDateRange); + if (fromToRange) { + const fromDate = fromToRange.fromDate; + const toDate = fromToRange.toDate; + if (fromDate) { + localStorage.setItem('GrantApplications_FromDate', fromDate); + } + else { + localStorage.removeItem('GrantApplications_FromDate'); + } + if (toDate) { + localStorage.setItem('GrantApplications_ToDate', toDate); + } + else { + localStorage.removeItem('GrantApplications_ToDate'); + } + } + } + + // Returns a formatted { fromDate, toDate } for the filter fields. // Null if 'custom' or no input provided (assumes custom is default break) function getDateRange(rangeType) { let today = new Date(); @@ -279,10 +300,9 @@ $(function () { fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 6))); break; case 'alltime': - fromDate = null; return { fromDate: null, toDate: null }; case 'custom': - default: + default: return null; // Don't modify dates for custom } @@ -328,26 +348,9 @@ $(function () { dtInstance.ajax.reload(null, true); } - - function setDateRangeLocalStorage(quickDateRange, fromToRange) { - localStorage.setItem('GrantApplications_QuickRange', quickDateRange || defaultQuickDateRange); - if (fromToRange) { - if (fromToRange.fromDate && fromToRange.toDate) { - localStorage.setItem('GrantApplications_FromDate', fromToRange.fromDate); - localStorage.setItem('GrantApplications_ToDate', fromToRange.toDate); - } else { - // For "All time", clear the date filters - localStorage.removeItem('GrantApplications_FromDate'); - localStorage.removeItem('GrantApplications_ToDate'); - } - } - } - function handleQuickDateRangeChange() { const selectedRange = $(this).val(); - localStorage.setItem('GrantApplications_QuickRange', selectedRange); - if (selectedRange === 'custom') { // Show the custom date inputs and don't modify their values toggleCustomDateInputs(true); @@ -359,18 +362,12 @@ $(function () { // Get the date range for the selected option const range = getDateRange(selectedRange); + setDateRangeFilters(selectedRange, range); setDateRangeLocalStorage(selectedRange, range); - if (range) { - // Populate the hidden date fields - UIElements.submittedFromInput.val(range.fromDate || ''); - UIElements.submittedToInput.val(range.toDate || ''); - grantTableFilters.submittedFromDate = range.fromDate; - grantTableFilters.submittedToDate = range.toDate; - // Reload the table with new filters - const dtInstance = $('#GrantApplicationsTable').DataTable(); - dtInstance.ajax.reload(null, true); - } + // Reload the table with new filters + const dtInstance = $('#GrantApplicationsTable').DataTable(); + dtInstance.ajax.reload(null, true); } function initializeDataTableAndEvents() { @@ -396,6 +393,7 @@ $(function () { serverSideEnabled: false, pagingEnabled: true, reorderEnabled: true, + fixedHeaders: true, languageSetValues, dataTableName: 'GrantApplicationsTable', dynamicButtonContainerId: 'dynamicButtonContainerId', @@ -408,7 +406,7 @@ $(function () { }; }, onStateLoadParams: function (settings, data) { - if (data?.customFilters) { + if (!initialLoad && data?.customFilters) { // If there is any date change, this will refresh post load // to ensure the correct data is shown based on the saved filters. data.refreshTableWithDates = @@ -465,21 +463,28 @@ $(function () { $('.grp-savedStates').text('Save View'); $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); - // Helper function to restore custom filters + // Helper function to restore custom filters when loading table views. function restoreCustomFilters(filters) { UIElements.searchField.val(filters.searchValue || ''); - UIElements.quickDateRange.val(filters.quickDateRange || defaultQuickDateRange); - toggleCustomDateInputs(filters.quickDateRange === 'custom'); + let quickRange = filters.quickDateRange || defaultQuickDateRange; + let isCustomRange = filters.quickDateRange === 'custom'; + toggleCustomDateInputs(isCustomRange); - UIElements.submittedFromInput.val(filters.submittedFromDate || ''); - UIElements.submittedToInput.val(filters.submittedToDate || ''); + let range = !isCustomRange + ? getDateRange(quickRange) + : { + fromDate: filters.submittedFromDate || '', + toDate: filters.submittedToDate || '' + }; - grantTableFilters.submittedFromDate = filters.submittedFromDate || null; - grantTableFilters.submittedToDate = filters.submittedToDate || null; + if (!isCustomRange && !range) { + quickRange = defaultQuickDateRange; + range = getDateRange(quickRange); + } - // Update localStorage to stay in sync - setDateRangeLocalStorage(filters?.quickDateRange, { fromDate: filters.submittedFromDate, toDate: filters.submittedToDate }); + setDateRangeFilters(quickRange, range); + setDateRangeLocalStorage(quickRange, range); } 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 3555ea8a3..ece7839f1 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 @@ -3,17 +3,6 @@ * Handles rendering and management of AI-generated analysis results */ -// Simple hash function to create stable IDs from content -function simpleHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.codePointAt(i) || 0; - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(36); -} - /** * Helper function to create an item from a template * @param {string} templateName - Name of the template to clone @@ -71,18 +60,52 @@ function createAccordionGroup(id, type, iconClass, title, content) { return $group; } +function getFindingDetailText(item) { + if (!item || typeof item !== 'object') { + return ''; + } + + const raw = item.detail ?? ''; + if (typeof raw === 'string') { + return raw; + } + + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } +} + +function getVisibleIssueCount(activeWarnings, activeErrors, summaries) { + return activeWarnings.length + activeErrors.length + summaries.length; +} + function renderRealAIAnalysis(analysisData) { - // Generate STABLE IDs based on content hash - const warnings = (analysisData.warnings || []).map((w, i) => ({ - ...w, - id: w.id || 'warning-' + simpleHash((w.category || '') + (w.message || '')) - })); - const errors = (analysisData.errors || []).map((e, i) => ({ - ...e, - id: e.id || 'error-' + simpleHash((e.category || '') + (e.message || '')) + const rawWarnings = analysisData.warnings || []; + const rawErrors = analysisData.errors || []; + const rawSummaries = analysisData.summaries || analysisData.recommendations || []; + const warnings = rawWarnings + .filter(w => w) + .map((w, i) => ({ + ...w, + id: w.id || `warning-${i}`, + title: w.title || w.category || 'Warning', + detail: w.detail || w.message || '' + })); + const errors = rawErrors + .filter(e => e) + .map((e, i) => ({ + ...e, + id: e.id || `error-${i}`, + title: e.title || e.category || 'Error', + detail: e.detail || e.message || '' + })); + const summaries = rawSummaries.map((s) => ({ + title: s?.title || s?.category || 'Summary', + detail: s?.detail || s?.message || s || '' })); - const recommendations = analysisData.recommendations || []; - const dismissedItems = new Set((analysisData.dismissed_items || []).filter(Boolean)); + const dismissedItems = new Set((analysisData.dismissed || analysisData.dismissed_items || []).filter(Boolean)); // Get all valid IDs from current errors and warnings const allValidIds = new Set([...errors.map(e => e.id), ...warnings.map(w => w.id)]); @@ -105,11 +128,10 @@ function renderRealAIAnalysis(analysisData) { // Add errors section if there are any if (activeErrors.length > 0) { const errorItems = activeErrors.map(error => { - const errorId = error.id || 'error-unknown-' + Date.now(); return createItemFromTemplate('dismissible-item', { - category: error.category || 'Error', - message: error.message || '', - 'dismiss-btn': { id: errorId, type: 'error' } + category: error.title || 'Error', + message: getFindingDetailText(error), + 'dismiss-btn': { id: error.id, type: 'error' } }); }); @@ -129,11 +151,10 @@ function renderRealAIAnalysis(analysisData) { // Add warnings section if there are any if (activeWarnings.length > 0) { const warningItems = activeWarnings.map(warning => { - const warningId = warning.id || 'warning-unknown-' + Date.now(); return createItemFromTemplate('dismissible-item', { - category: warning.category || 'Warning', - message: warning.message || '', - 'dismiss-btn': { id: warningId, type: 'warning' } + category: warning.title || 'Warning', + message: getFindingDetailText(warning), + 'dismiss-btn': { id: warning.id, type: 'warning' } }); }); @@ -150,27 +171,27 @@ function renderRealAIAnalysis(analysisData) { $accordionList.append(accordionItem); } - // Add recommendations section if there are any - if (recommendations.length > 0) { - const recommendationItems = recommendations.map(rec => { - const category = typeof rec === 'object' ? (rec.category || 'Recommendation') : 'Recommendation'; - const message = typeof rec === 'object' ? (rec.message || rec) : rec; + // Add summary section if there are any + if (summaries.length > 0) { + const summaryItems = summaries.map(rec => { + const category = typeof rec === 'object' ? (rec.title || 'Summary') : 'Summary'; + const message = typeof rec === 'object' ? getFindingDetailText(rec) : rec; - return createItemFromTemplate('recommendation-item', { + return createItemFromTemplate('summary-item', { category: category, message: message }); }); - const $recommendationsContainer = $('
'); - recommendationItems.forEach(item => $recommendationsContainer.append(item)); + const $summaryContainer = $('
'); + summaryItems.forEach(item => $summaryContainer.append(item)); const accordionItem = createAccordionGroup( - 'recommendations', + 'summary', 'info', 'fl-info-circle', - `Recommendations (${recommendations.length})`, - $recommendationsContainer + `Summary (${summaries.length})`, + $summaryContainer ); $accordionList.append(accordionItem); } @@ -179,19 +200,17 @@ function renderRealAIAnalysis(analysisData) { if (dismissedErrors.length > 0 || dismissedWarnings.length > 0) { const dismissedItems = [ ...dismissedErrors.map(error => { - const errorId = error.id || 'error-dismissed-' + Date.now(); return createItemFromTemplate('dismissed-item', { - category: error.category || 'Error', - message: error.message || '', - 'restore-btn': { id: errorId, type: 'error' } + category: error.title || 'Error', + message: getFindingDetailText(error), + 'restore-btn': { id: error.id, type: 'error' } }); }), ...dismissedWarnings.map(warning => { - const warningId = warning.id || 'warning-dismissed-' + Date.now(); return createItemFromTemplate('dismissed-item', { - category: warning.category || 'Warning', - message: warning.message || '', - 'restore-btn': { id: warningId, type: 'warning' } + category: warning.title || 'Warning', + message: getFindingDetailText(warning), + 'restore-btn': { id: warning.id, type: 'warning' } }); }) ]; @@ -208,15 +227,16 @@ function renderRealAIAnalysis(analysisData) { ); $accordionList.append(accordionItem); - let totalLength = recommendations.length + activeWarnings.length + activeErrors.length; - PubSub.publish('update_ai_analysis_count', { - itemCount: totalLength, - }); } + const totalLength = getVisibleIssueCount(activeWarnings, activeErrors, summaries); + PubSub.publish('update_ai_analysis_count', { + itemCount: totalLength, + }); + // If no items, show the no-data message; otherwise hide it const $noDataMessage = $('#aiAnalysisNoData'); - if (activeErrors.length === 0 && activeWarnings.length === 0 && recommendations.length === 0 && dismissedErrors.length === 0 && dismissedWarnings.length === 0) { + if (activeErrors.length === 0 && activeWarnings.length === 0 && summaries.length === 0 && dismissedErrors.length === 0 && dismissedWarnings.length === 0) { $noDataMessage.show(); $accordionList.hide(); } else { @@ -225,7 +245,7 @@ function renderRealAIAnalysis(analysisData) { } // Update tab badge with total count - const totalIssues = activeWarnings.length + activeErrors.length + recommendations.length; + const totalIssues = getVisibleIssueCount(activeWarnings, activeErrors, summaries); $('#ai-analysis-tab').html(`(${totalIssues})`); // Remove all previous event handlers from accordion list @@ -300,7 +320,7 @@ function toggleAccordionItem($header) { } function loadAIAnalysis() { - if($('#AIAnalysisFeatureEnabled') == 'False') { + if ($('#AIAnalysisFeatureEnabled').val() === 'False') { return; } const urlParams = new URL(globalThis.location.toLocaleString()).searchParams; @@ -313,52 +333,35 @@ function loadAIAnalysis() { // Get the application data including AI analysis unity.grantManager.grantApplications.grantApplication.get(applicationId) .done(function(application) { - console.debug('Application data received:', application); - console.debug('AI Analysis field:', application.aiAnalysis); - - // Use the camelCase version that should come from the API - const aiAnalysis = application.aiAnalysis; - - if (application && aiAnalysis) { + let aiAnalysisData = application.aiAnalysisData; + if (!aiAnalysisData && application.aiAnalysis) { try { - console.debug('Raw AI analysis:', aiAnalysis); - - // Clean the JSON response (remove markdown code blocks if present) - let cleanedJson = aiAnalysis.trim(); - if (cleanedJson.startsWith('```json') || cleanedJson.startsWith('```')) { - const startIndex = cleanedJson.indexOf('\n'); - if (startIndex >= 0) { - cleanedJson = cleanedJson.substring(startIndex + 1); + let cleaned = String(application.aiAnalysis).trim(); + if (cleaned.startsWith('```json') || cleaned.startsWith('```')) { + const firstBreak = cleaned.indexOf('\n'); + if (firstBreak >= 0) { + cleaned = cleaned.substring(firstBreak + 1); } } - if (cleanedJson.endsWith('```')) { - const lastIndex = cleanedJson.lastIndexOf('```'); - if (lastIndex > 0) { - cleanedJson = cleanedJson.substring(0, lastIndex); - } + if (cleaned.endsWith('```')) { + cleaned = cleaned.substring(0, cleaned.lastIndexOf('```')); } - cleanedJson = cleanedJson.trim(); - - // Remove trailing commas before closing brackets/braces (common JSON error from AI) - cleanedJson = cleanedJson.replaceAll(/,(\s*[}\]])/g, '$1'); + cleaned = cleaned.replaceAll(/,(\s*[}\]])/g, '$1').trim(); + aiAnalysisData = JSON.parse(cleaned); + } catch (parseError) { + console.warn('Failed to parse aiAnalysis JSON fallback:', parseError); + } + } - const analysisData = JSON.parse(cleanedJson); - console.debug('Parsed analysis data:', analysisData); - renderRealAIAnalysis(analysisData); + if (application && aiAnalysisData) { + try { + renderRealAIAnalysis(aiAnalysisData); } catch (e) { - console.warn('Failed to parse AI analysis JSON, showing demo data:', e); + console.warn('Failed to render AI analysis data:', e); } - } else { - console.debug('No AI analysis found, showing demo'); } }) .fail(function(error) { - console.warn('Failed to load application data, showing demo AI analysis', error); + console.warn('Failed to load application data', error); }); } - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.css index 9c9f00933..e5f1d20fb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.css @@ -11,8 +11,3 @@ #IntakesTable_filter input { margin: 0.25rem; } - -#IntakesTable_wrapper .dt-scroll-body { - max-height: calc(100vh - 350px); - overflow-y: scroll; -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.js index bac333052..18b6f5f0f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Intakes/Index.js @@ -107,7 +107,8 @@ dataTableName: 'IntakesTable', dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, - externalSearchId: 'search-intakes' + externalSearchId: 'search-intakes', + fixedHeaders: true }); createModal.onResult(function () { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml index 552b70232..7e4bb1b76 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml @@ -1,9 +1,14 @@ +@using Unity.GrantManager.Permissions @using Unity.GrantManager.Web.Views.Shared.Components.ApplicantOrganizationInfo +@using Volo.Abp.Authorization.Permissions + +@inject IPermissionChecker PermissionChecker @model ApplicantOrganizationInfoViewModel @{ Layout = null; + bool canEditRedStop = await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.EditRedStop); }