diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Comments/CommentType.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Comments/CommentType.cs
new file mode 100644
index 0000000000..3d003cfe8e
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Comments/CommentType.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace Unity.Notifications.Comments;
+
+/// Edit Unity.GrantManager.Comments.CommentType if changing this enum as it is shared between the two projects and used in the API layer.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum CommentType
+{
+ ApplicationComment,
+ AssessmentComment,
+ ApplicantComment,
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs
index 475368d829..4dbe0f3c53 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs
@@ -1,6 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -12,9 +12,9 @@
using Volo.Abp.DependencyInjection;
using Volo.Abp.EventBus;
using Volo.Abp.EventBus.Local;
-
-namespace Unity.Payments.Handlers
-{
+
+namespace Unity.Payments.Handlers
+{
public class UpsertSupplierHandler(ISupplierAppService supplierAppService,
SiteAppService siteAppService,
ILogger logger,
@@ -22,11 +22,14 @@ public class UpsertSupplierHandler(ISupplierAppService supplierAppService,
IApplicationRepository applicationRepository) : ILocalEventHandler, ITransientDependency
{
- public async Task HandleEventAsync(UpsertSupplierEto eventData)
+ public async Task HandleEventAsync(UpsertSupplierEto eventData)
{
SupplierDto supplierDto = await GetSupplierFromEvent(eventData);
var existingSites = await siteAppService.GetSitesBySupplierIdAsync(supplierDto.Id);
- var existingSitesDictionary = existingSites?.ToDictionary(s => s.Number) ?? new Dictionary();
+ var existingSitesDictionary = existingSites?
+ .GroupBy(s => s.Number)
+ .ToDictionary(g => g.Key, g => g.First())
+ ?? new Dictionary();
var defaultPaymentGroup = await ResolveDefaultPaymentGroupAsync(eventData);
await UpsertSitesFromEventDtoAsync(existingSitesDictionary, supplierDto.Id, eventData, defaultPaymentGroup);
@@ -35,34 +38,40 @@ public async Task HandleEventAsync(UpsertSupplierEto eventData)
await localEventBus.PublishAsync(
new ApplicantSupplierEto
{
- SupplierId = supplierDto.Id,
- ApplicantId = eventData.CorrelationId,
- ExistingSitesDictionary = existingSitesDictionary,
- SiteEtos = eventData.SiteEtos
- }
- );
- }
-
+ SupplierId = supplierDto.Id,
+ ApplicantId = eventData.CorrelationId,
+ ExistingSitesDictionary = existingSitesDictionary,
+ SiteEtos = eventData.SiteEtos
+ }
+ );
+ }
+
private async Task> UpsertSitesFromEventDtoAsync(
Dictionary existingSitesDictionary,
Guid supplierId,
UpsertSupplierEto upsertSupplierEto,
PaymentGroup defaultPaymentGroup)
{
- foreach (var siteEto in upsertSupplierEto.SiteEtos)
+ // Deduplicate incoming SiteEtos by SupplierSiteCode — CAS can return duplicate site codes
+ var uniqueSiteEtos = upsertSupplierEto.SiteEtos
+ .GroupBy(s => s.SupplierSiteCode)
+ .Select(g => g.First())
+ .ToList();
+
+ foreach (var siteEto in uniqueSiteEtos)
{
var siteDto = supplierAppService.GetSiteDtoFromSiteEto(siteEto, supplierId, defaultPaymentGroup);
- if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite))
- {
- siteDto.Id = existingSite.Id;
- await siteAppService.UpdateAsync(siteDto);
- }
- else
- {
- await siteAppService.InsertAsync(siteDto);
- }
- }
+ if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite))
+ {
+ siteDto.Id = existingSite.Id;
+ await siteAppService.UpdateAsync(siteDto);
+ }
+ else
+ {
+ await siteAppService.InsertAsync(siteDto);
+ }
+ }
return existingSitesDictionary;
}
@@ -107,57 +116,57 @@ private async Task GetSupplierFromEvent(UpsertSupplierEto eventData
{
var existing = await supplierAppService.GetBySupplierNumberAsync(eventData.Number);
logger.LogInformation("Upserting supplier from event data: {Existing}", existing);
-
- // This is subject to some business rules and a domain implementation
- if (existing != null)
- {
- existing.Number = eventData.Number;
- UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData);
- SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto);
- return updatedSupplierDto;
- }
-
- CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData);
- SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto);
-
- return supplierDto;
- }
-
-
- private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData)
- {
- return new UpdateSupplierDto()
- {
- Name = eventData.Name,
- Number = eventData.Number,
- Subcategory = eventData.Subcategory,
- ProviderId = eventData.ProviderId,
- BusinessNumber = eventData.BusinessNumber,
- Status = eventData.Status,
- SupplierProtected = eventData.SupplierProtected,
- StandardIndustryClassification = eventData.StandardIndustryClassification,
- LastUpdatedInCAS = eventData.LastUpdatedInCAS,
- CorrelationId = eventData.CorrelationId,
- CorrelationProvider = eventData.CorrelationProvider,
- };
- }
-
- private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData)
- {
- return new CreateSupplierDto()
- {
- Name = eventData.Name,
- Number = eventData.Number,
- Subcategory = eventData.Subcategory,
- ProviderId = eventData.ProviderId,
- BusinessNumber = eventData.BusinessNumber,
- Status = eventData.Status,
- SupplierProtected = eventData.SupplierProtected,
- StandardIndustryClassification = eventData.StandardIndustryClassification,
- LastUpdatedInCAS = eventData.LastUpdatedInCAS,
- CorrelationId = eventData.CorrelationId,
- CorrelationProvider = eventData.CorrelationProvider,
- };
- }
- }
-}
+
+ // This is subject to some business rules and a domain implementation
+ if (existing != null)
+ {
+ existing.Number = eventData.Number;
+ UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData);
+ SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto);
+ return updatedSupplierDto;
+ }
+
+ CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData);
+ SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto);
+
+ return supplierDto;
+ }
+
+
+ private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData)
+ {
+ return new UpdateSupplierDto()
+ {
+ Name = eventData.Name,
+ Number = eventData.Number,
+ Subcategory = eventData.Subcategory,
+ ProviderId = eventData.ProviderId,
+ BusinessNumber = eventData.BusinessNumber,
+ Status = eventData.Status,
+ SupplierProtected = eventData.SupplierProtected,
+ StandardIndustryClassification = eventData.StandardIndustryClassification,
+ LastUpdatedInCAS = eventData.LastUpdatedInCAS,
+ CorrelationId = eventData.CorrelationId,
+ CorrelationProvider = eventData.CorrelationProvider,
+ };
+ }
+
+ private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData)
+ {
+ return new CreateSupplierDto()
+ {
+ Name = eventData.Name,
+ Number = eventData.Number,
+ Subcategory = eventData.Subcategory,
+ ProviderId = eventData.ProviderId,
+ BusinessNumber = eventData.BusinessNumber,
+ Status = eventData.Status,
+ SupplierProtected = eventData.SupplierProtected,
+ StandardIndustryClassification = eventData.StandardIndustryClassification,
+ LastUpdatedInCAS = eventData.LastUpdatedInCAS,
+ CorrelationId = eventData.CorrelationId,
+ CorrelationProvider = eventData.CorrelationProvider,
+ };
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs
index b9286fee55..a3cca3fa53 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs
@@ -40,6 +40,18 @@ public virtual async Task InsertAsync(SiteDto siteDto)
{
try
{
+ // Guard against duplicates — if a site with the same Number already exists
+ // for this supplier, update it instead of creating a second row
+ var existing = (await siteRepository.GetBySupplierAsync(siteDto.SupplierId))
+ .Find(s => s.Number == siteDto.Number);
+
+ if (existing != null)
+ {
+ logger.LogWarning("Site with Number {Number} already exists for SupplierId {SupplierId}. Updating instead of inserting.", siteDto.Number, siteDto.SupplierId);
+ siteDto.Id = existing.Id;
+ return await UpdateAsync(siteDto);
+ }
+
Site site = new Site(siteDto);
await siteRepository.InsertAsync(site, true);
return site.Id;
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
index e0c4c9f100..22ae8658f5 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
@@ -209,6 +209,17 @@ public async Task OnGetAsync(string cacheKey)
errorList.Add("The selected Application is not Approved. To continue please remove the item from the list.");
}
+ var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id);
+ var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id);
+ if (parentLink != null)
+ {
+ var parentApplication = await applicationService.GetAsync(parentLink.ApplicationId);
+ if (parentApplication.Id == Guid.Empty || parentApplication.StatusCode != GrantApplicationState.GRANT_APPROVED)
+ {
+ errorList.Add("Payment cannot be processed because the linked parent submission is not approved. Please ensure the parent submission is approved before creating a payment.");
+ }
+ }
+
if (!application.ApplicationForm.Payable)
{
errorList.Add("The selected application is not Payable. To continue please remove the item from the list.");
@@ -308,7 +319,7 @@ private async Task GetRemainingAmountAllowedByApplicationAsync(GrantApp
// Get parent links for this application
var allLinks = await applicationLinksService.GetListByApplicationAsync(application.Id);
- var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent);
+ var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != application.Id);
// Rule 2: No parent link exists
if (parentLink == null)
@@ -474,7 +485,7 @@ private async Task PopulateParentChildValidationData()
// Parent not in submission, get from first child's link
var firstChild = await applicationService.GetAsync(children[0].CorrelationId);
var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id);
- var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent);
+ var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id);
if (parentLink == null)
{
@@ -558,7 +569,7 @@ private async Task> ValidateParentChildPaymentAmounts()
// Parent not in submission, get from first child's link
var firstChild = await applicationService.GetAsync(children[0].CorrelationId);
var allLinks = await applicationLinksService.GetListByApplicationAsync(firstChild.Id);
- var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent);
+ var parentLink = allLinks.Find(link => link.LinkType == ApplicationLinkType.Parent && link.ApplicationId != firstChild.Id);
if (parentLink == null)
{
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 dc9371b52e..ba06ae76b7 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
@@ -2,6 +2,7 @@ $(function () {
const l = abp.localization.getResource('Payments');
const nullPlaceholder = '—';
const formatter = createNumberFormatter();
+ const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
let dt = $('#PaymentRequestListTable');
let dataTable;
let isApprove = false;
@@ -377,7 +378,29 @@ $(function () {
name: 'applicantName',
data: 'payeeName',
className: 'data-table-header',
- index: columnIndex
+ index: columnIndex,
+ render: function (data, type, row) {
+ let applicantName = (typeof data !== 'string' || data.trim() === '') ? 'Applicant Name' : data;
+
+ if (type === 'sort' || type === 'filter') {
+ return applicantName;
+ }
+
+ const safeApplicantName = $.fn.dataTable.render.text().display(applicantName);
+
+ if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) {
+ const applicantId = row?.correlationId;
+ const isGuid = applicantId && guidPattern.test(applicantId);
+
+ if (row?.correlationProvider === 'Application' && isGuid) {
+ return `${safeApplicantName}`;
+ }
+
+ return safeApplicantName;
+ }
+
+ return applicantName;
+ },
};
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js
index 6e9b9346f5..a53d13f64d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js
@@ -212,6 +212,9 @@ $(function () {
return;
}
+ const $btn = $(this);
+ $btn.attr('disabled', 'disabled');
+
const applicantId = $('#PaymentInfo_ApplicantId').val();
const applicationId = $('#PaymentInfoViewApplicationId').val() || '';
$.ajax({
@@ -222,6 +225,9 @@ $(function () {
console.error('Error loading sites:', error);
abp.notify.error('Failed to refresh sites');
},
+ complete: function () {
+ $btn.removeAttr('disabled');
+ },
});
});
}
@@ -297,10 +303,10 @@ $(function () {
{
text: 'Filter',
className: 'custom-table-btn flex-none btn btn-secondary',
- id: 'btn-toggle-filter',
+ id: 'btn-supplier-toggle-filter',
action: function (e, dt, node, config) { },
attr: {
- id: 'btn-toggle-filter',
+ id: 'btn-supplier-toggle-filter',
},
},
];
@@ -328,6 +334,9 @@ $(function () {
dynamicButtonContainerId: 'siteDynamicButtonContainerId',
});
+ initializeFilterRowPlugin(dataTable, 'btn-supplier-toggle-filter');
+
+
function getColumns() {
let columnIndex = 0;
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs
index d0498cde3b..01042b9ebc 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertColumnMappingDto.cs
@@ -3,7 +3,9 @@
///
/// Data transfer object containing user-specified field-to-column mapping overrides for report configuration.
/// Allows users to customize column names for specific fields instead of relying entirely on auto-generated names.
- /// Fields not included in this mapping will receive auto-generated column names based on their labels.
+ /// Fields not included in this mapping will receive auto-generated column names based on the correlation provider:
+ /// for form versions, column names are derived from field keys (CHEFS Property Names); for worksheets and scoresheets,
+ /// column names are derived from field labels.
///
public class UpsertColumnMappingDto
{
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertReportColumnsMapDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertReportColumnsMapDto.cs
index 74ae0b8e75..c063c59a61 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertReportColumnsMapDto.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/UpsertReportColumnsMapDto.cs
@@ -24,7 +24,8 @@ public class UpsertReportColumnsMapDto
///
/// Gets or sets the optional column mapping configuration containing user-specified field-to-column mappings.
/// When provided, these mappings override auto-generated column names for specific fields.
- /// Empty mappings will result in fully auto-generated column names based on field labels.
+ /// Empty mappings will result in fully auto-generated column names derived from field keys
+ /// (for form versions) or field labels (for worksheets and scoresheets).
///
public UpsertColumnMappingDto Mapping { get; set; } = new();
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs
index f23f9b2441..99b0400aae 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingUtils.cs
@@ -16,6 +16,25 @@ internal static partial class ReportMappingUtils
{
private const int MaxColumnNameLength = 60;
+ ///
+ /// Determines the appropriate source value for auto-generating a default column name based on the correlation provider.
+ /// For the "formversion" provider, the field's Key (CHEFS Property Name) is used because it provides more stable,
+ /// developer-friendly identifiers. For all other providers (worksheets, scoresheets), the field's Label is used
+ /// as it provides more human-readable column names.
+ ///
+ /// The field metadata containing both Key and Label properties.
+ /// The correlation provider identifier (e.g., "formversion", "worksheet", "scoresheet").
+ /// The Key for formversion providers, or the Label (falling back to empty string) for all other providers.
+ internal static string GetDefaultColumnNameSource(FieldPathTypeDto field, string correlationProvider)
+ {
+ if (string.Equals(correlationProvider, Providers.FormVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ return field.Key ?? string.Empty;
+ }
+
+ return field.Label ?? string.Empty;
+ }
+
///
/// Generates sanitized and unique PostgreSQL-compatible column names from a dictionary of field keys and their display labels.
/// Processes each label through sanitization to remove invalid characters, enforces uniqueness with numeric suffixes,
@@ -269,6 +288,8 @@ private static bool IsValidColumnName(string columnName)
/// derived from field metadata. Prioritizes user-specified column names where provided, while automatically generating
/// sanitized and unique column names for unmapped fields. Ensures all column names are PostgreSQL-compliant and unique
/// across the entire mapping configuration.
+ /// For the "formversion" provider, auto-generated column names are derived from the field Key (CHEFS Property Name)
+ /// rather than the Label, providing more stable and developer-friendly default names.
///
/// DTO containing correlation information and optional user-provided column mappings organized by field path.
/// Tuple containing an array of field metadata (with keys, labels, types, paths) and additional mapping metadata from the correlation provider.
@@ -299,12 +320,14 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe
var usedColumnNames = new HashSet(userProvidedMappings.Values, StringComparer.OrdinalIgnoreCase);
// Generate column names for fields without user-provided mappings
+ // For formversion provider, use Key (CHEFS Property Name) as the source; for others, use Label
var autoGeneratedColumnNames = new Dictionary();
foreach (var field in fieldsMap.Fields)
{
if (!userProvidedMappings.ContainsKey(field.Path))
{
- var sanitizedName = SanitizeColumnName(field.Label ?? string.Empty);
+ var columnNameSource = GetDefaultColumnNameSource(field, upsertReportColmnsMapDto.CorrelationProvider);
+ var sanitizedName = SanitizeColumnName(columnNameSource);
var uniqueName = EnsureUniqueness(sanitizedName, usedColumnNames);
autoGeneratedColumnNames[field.Path] = uniqueName;
usedColumnNames.Add(uniqueName);
@@ -355,6 +378,8 @@ internal static ReportColumnsMap CreateNewMap(UpsertReportColumnsMapDto upsertRe
/// and user-provided updates. Implements a three-tier priority system: user-provided column names take precedence,
/// followed by existing column names from the database, with auto-generated names for new fields. Maintains column
/// name uniqueness across the entire mapping while preserving established mappings where possible.
+ /// For newly discovered fields in the "formversion" provider, auto-generated column names are derived from the
+ /// field Key (CHEFS Property Name) rather than the Label.
///
/// DTO containing optional user-provided column mappings to update or add, organized by field path.
/// The existing ReportColumnsMap entity from the database containing current mapping configuration and correlation details.
@@ -412,9 +437,11 @@ internal static ReportColumnsMap UpdateExistingMap(UpsertReportColumnsMapDto upd
columnName = existingRow.ColumnName;
}
// Priority 3: Auto-generate for new fields
+ // For formversion provider, use Key (CHEFS Property Name) as the source; for others, use Label
else
{
- var sanitizedName = SanitizeColumnName(field.Label ?? string.Empty);
+ var columnNameSource = GetDefaultColumnNameSource(field, updateReportColumnsMapDto.CorrelationProvider);
+ var sanitizedName = SanitizeColumnName(columnNameSource);
columnName = EnsureUniqueness(sanitizedName, usedColumnNames);
usedColumnNames.Add(columnName);
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs
index 7df63808e4..d6e91808e7 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs
@@ -33,15 +33,6 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context)
/// A completed task representing the synchronous menu item addition operations.
private static Task ConfigureReportingMenuAsync(MenuConfigurationContext context)
{
- // Add Reconciliation menu item for IT Admin users
- context.Menu.AddItem(
- new ApplicationMenuItem(
- ReportingMenus.Prefix,
- displayName: "Reconciliation",
- "~/TenantManagement/Reconciliation",
- requiredPermissionName: IdentityConsts.ITAdminPermissionName
- ));
-
// Add Reporting Configuration menu item for IT Admin users
context.Menu.AddItem(
new ApplicationMenuItem(
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml
index 871fa26a82..c8ded88444 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml
@@ -13,11 +13,12 @@
+
-
+
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css
index 3ad76c57a9..d130b52d4c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css
@@ -1,13 +1,11 @@
.report-config-content {
- height: calc(97vh - 400px);
- overflow: scroll;
- overflow-x: hidden;
+ overflow: visible;
}
.provider-toggle-section {
border-bottom: 1px solid #dee2e6;
- padding-bottom: 1rem;
- margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ margin-bottom: 0.5rem;
}
.provider-toggle-section .btn-group {
@@ -17,8 +15,8 @@
.provider-toggle-section .btn-group .btn {
flex: none;
- min-width: 120px;
- padding: 0.5rem 1rem;
+ min-width: 100px;
+ padding: 0.375rem 0.75rem;
font-weight: 500;
transition: all 0.15s ease-in-out;
}
@@ -70,7 +68,7 @@
.report-config-content .action-bar {
flex-shrink: 0;
- margin-bottom: 1rem;
+ margin-bottom: 0.5rem;
}
.report-config-footer {
@@ -160,6 +158,17 @@
.report-config-controls {
display: flex !important;
justify-content: space-between;
+ align-items: flex-end;
+}
+
+/* Compact the version selector form-group within controls */
+.report-config-controls .form-group {
+ margin-bottom: 0;
+}
+
+/* Reduce tab pane padding when reporting config is active */
+#nav-reporting-configuration.tab-pane {
+ padding: 0.5rem 0;
}
.report-config-btn-height-fix {
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
index c094098fb2..bd0d4be183 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
@@ -440,7 +440,7 @@ $(function () {
// Helper function to process detected changes alert (module-level to reduce nesting)
function processDetectedChanges(detectedChanges) {
- if (detectedChanges && detectedChanges.trim() !== '') {
+ if (detectedChanges?.trim() !== '') {
setTimeout(function () {
displayDetectedChangesAlert(detectedChanges);
}, 100);
@@ -467,6 +467,18 @@ $(function () {
};
}
+ // Helper function to get the default column name source based on the current provider.
+ // Mirrors the server-side ReportMappingUtils.GetDefaultColumnNameSource() logic exactly:
+ // - formversion: use Key only (CHEFS Property Name), no fallback to Label
+ // - worksheet/scoresheet: use Label only, no fallback to Key
+ // No cross-field fallback ensures the client and server produce identical defaults.
+ function getDefaultColumnNameSource(field) {
+ if (currentProvider === 'formversion') {
+ return field.key || '';
+ }
+ return field.label || '';
+ }
+
// Helper function to transform fields metadata (module-level to reduce nesting)
function transformFieldsMetadata(fieldsMetadata) {
const items = fieldsMetadata.fields.map(field => ({
@@ -475,7 +487,7 @@ $(function () {
type: field.type,
path: field.path,
dataPath: field.dataPath,
- columnName: field.label || field.key,
+ columnName: getDefaultColumnNameSource(field),
typePath: field.typePath
}));
@@ -670,10 +682,7 @@ $(function () {
dynamicButtonContainerId: 'reportConfigDynamicButtons',
useNullPlaceholder: true,
externalSearchId: 'search-report-config',
- // Enable scrolling - let CSS handle the height
- scrollY: true,
- scrollCollapse: true,
- responsive: true
+ fixedHeaders: true
});
// Add event handler for when DataTable completes drawing
@@ -688,34 +697,8 @@ $(function () {
// Check for dynamic columns placeholder and update warning
const hasDynamicColumns = checkForDynamicColumnsInTable();
updateDynamicColumnsWarning(hasDynamicColumns);
-
- // Force column adjustment after draw if tab is visible
- setTimeout(function () {
- if (isReportingTabVisible()) {
- forceTableColumnAdjustment();
- }
- }, 50);
});
- // Add event handler for when DataTable data is loaded
- dataTable.on('xhr.dt', function () {
- // Force layout adjustment after data is loaded
- setTimeout(function () {
- if (isReportingTabVisible()) {
- adjustTableLayout();
- forceTableColumnAdjustment();
- }
- }, 100);
- });
-
- // Initial adjustment with longer delay to ensure DOM is ready
- setTimeout(function () {
- if (isReportingTabVisible()) {
- adjustTableLayout();
- forceTableColumnAdjustment();
- }
- }, 500);
-
// Track changes on column name inputs with validation
$('#ReportConfigurationTable').on('input', '.column-name-input', function () {
const $input = $(this);
@@ -851,38 +834,6 @@ $(function () {
return reportingPanel?.classList.contains('show', 'active');
}
- // Enhanced function to force table column adjustment
- function forceTableColumnAdjustment() {
- if (!dataTable) return;
-
- try {
- // Multiple approaches to ensure columns are properly sized
- // 1. Force recalc of column widths
- dataTable.columns.adjust();
-
- // 2. Trigger responsive recalc if responsive is enabled
- if (dataTable.responsive) {
- dataTable.responsive.recalc();
- }
-
- // 3. Force a layout recalculation by temporarily changing display
- const wrapper = $('#ReportConfigurationTable_wrapper');
- if (wrapper.length) {
- // Force browser reflow
- wrapper.hide();
- wrapper.show();
-
- // Final column adjustment after showing
- setTimeout(function () {
- dataTable.columns.adjust();
- }, 10);
- }
-
- } catch (error) {
- console.warn('Error during force column adjustment:', error);
- }
- }
-
// Column name sanitization function
function sanitizeColumnName(name) {
let sanitized = sanitizeSqlNames(name);
@@ -914,8 +865,8 @@ $(function () {
errors.push(`Column name exceeds maximum length of ${COLUMN_VALIDATION.MAX_LENGTH} characters`);
}
- // Check format (should be alphanumeric + underscores, not starting with number)
- if (!/^[a-z_][a-z0-9_]*$/i.test(name)) {
+ // Check format (alphanumeric + underscores, not starting with number)
+ if (!/^[a-zA-Z_]\w*$/.test(name)) {
errors.push('Column name must start with a letter or underscore and contain only letters, numbers, and underscores');
}
@@ -1014,14 +965,14 @@ $(function () {
function sanitizeSqlNames(name) {
if (!name || typeof name !== 'string') return '';
- // Convert to lowercase and trim
- let sanitized = name.toLowerCase().trim();
+ // Trim whitespace
+ let sanitized = name.trim();
// Replace multiple spaces/hyphens with single underscore
- sanitized = sanitized.replace(/[\s-]+/g, '_');
+ sanitized = sanitized.replaceAll(/[\s-]+/g, '_');
// Remove all non-alphanumeric characters except underscores
- sanitized = sanitized.replace(/[^a-z0-9_]/g, '');
+ sanitized = sanitized.replaceAll(/\W/g, '');
// Remove leading/trailing underscores safely without vulnerable regex
while (sanitized.startsWith('_')) {
@@ -1046,7 +997,7 @@ $(function () {
// View name sanitization function
function sanitizeViewName(name) {
- let sanitized = sanitizeSqlNames(name);
+ let sanitized = sanitizeSqlNames(name).toLowerCase();
// Truncate to max length
if (sanitized.length > VIEW_NAME_VALIDATION.MAX_LENGTH) {
@@ -1081,7 +1032,7 @@ $(function () {
}
// Check format (should be alphanumeric + underscores, not starting with number)
- if (!/^[a-z_][a-z0-9_]*$/i.test(name)) {
+ if (!/^[a-zA-Z_]\w*$/.test(name)) {
errors.push('View name must start with a letter or underscore and contain only letters, numbers, and underscores');
}
@@ -1238,6 +1189,36 @@ $(function () {
return;
}
+ // Check if a view has been generated and warn the user
+ const viewStatus = ($('#reportingViewStatus').val() || '').toUpperCase();
+ const hasGeneratedView = viewStatus === 'SUCCESS' ||
+ $('.view-status-compact').find('.fl-checkmark').length > 0;
+
+ if (hasGeneratedView) {
+ Swal.fire({
+ title: 'Existing View Detected',
+ text: 'A database view has already been generated. These mapping changes will only take effect after you delete and re-create the view.',
+ icon: 'warning',
+ showCancelButton: true,
+ confirmButtonText: 'Save Anyway',
+ cancelButtonText: 'Cancel',
+ customClass: {
+ confirmButton: 'btn btn-primary',
+ cancelButton: 'btn btn-secondary'
+ }
+ }).then(function (result) {
+ if (result.isConfirmed) {
+ performSave(correlationId);
+ }
+ });
+ return;
+ }
+
+ performSave(correlationId);
+ });
+
+ // Extracted save logic into a reusable function
+ function performSave(correlationId) {
// Disable all control buttons during save
setControlButtonsLoadingState(true);
@@ -1302,7 +1283,7 @@ $(function () {
// Re-enable all control buttons after save completes (success or error)
setControlButtonsLoadingState(false);
});
- });
+ }
// Helper function to update view name validation UI (module-level to reduce nesting)
function updateViewNameValidationUI(isValid, errors, $viewNameInput, $confirmButton, $feedback) {
@@ -1573,7 +1554,7 @@ $(function () {
try {
const errorResponse = JSON.parse(xhr.responseText);
if (errorResponse?.error?.message) {
- errorMessage = errorResponse.error.message;
+ errorMessage = errorResponse?.error?.message;
} else if (typeof errorResponse === 'string') {
errorMessage = errorResponse;
} else {
@@ -1595,9 +1576,9 @@ $(function () {
try {
const parsedError = JSON.parse(xhr.responseText);
if (parsedError?.error?.message) {
- errorMessage = parsedError.error.message;
- } else if (parsedError.message) {
- errorMessage = parsedError.message;
+ errorMessage = parsedError?.error?.message;
+ } else if (parsedError?.message) {
+ errorMessage = parsedError?.message;
} else {
errorMessage = xhr.responseText;
}
@@ -1636,7 +1617,7 @@ $(function () {
const viewName = result.viewName;
const $deleteViewListItem = $('#deleteViewListItem');
- if (viewName && viewName.trim() !== '') {
+ if (viewName?.trim()) {
// Update the view name in the list item and show it
$deleteViewListItem.html(`The associated database view (${viewName})`);
$deleteViewListItem.show();
@@ -1718,6 +1699,9 @@ $(function () {
dataTable.ajax.reload();
}
+ // Clear the cached view status after deletion
+ $('#reportingViewStatus').val('');
+
// Refresh view status widget
refreshViewStatusWidget(correlationId, getCorrelationProvider());
@@ -1734,7 +1718,7 @@ $(function () {
} else if (xhr.status === 400) {
const errorResponse = JSON.parse(xhr.responseText);
if (errorResponse?.error?.message) {
- errorMessage = errorResponse.error.message;
+ errorMessage = errorResponse?.error?.message;
} else if (typeof errorResponse === 'string') {
errorMessage = errorResponse;
} else {
@@ -1751,9 +1735,9 @@ $(function () {
} else if (xhr.responseText) {
const parsedError = JSON.parse(xhr.responseText);
if (parsedError?.error?.message) {
- errorMessage = parsedError.error.message;
- } else if (parsedError.message) {
- errorMessage = parsedError.message;
+ errorMessage = parsedError?.error?.message;
+ } else if (parsedError?.message) {
+ errorMessage = parsedError?.message;
} else {
errorMessage = xhr.responseText;
}
@@ -1820,79 +1804,253 @@ $(function () {
// Initialize the delete button functionality
updateCheckConfigurationExistsCallbacks();
- // Initialize version selector visibility based on provider
- updateVersionSelectorVisibility();
+ // ======================================================================
+ // JSON Export / Import / Edit
+ // ======================================================================
- // Function to adjust table layout dynamically
- function adjustTableLayout() {
- if (dataTable && dataTable.settings().length > 0) {
- try {
- const container = $('.report-config-table-container');
- const containerHeight = container.height();
-
- if (containerHeight > 100) { // Only adjust if container has reasonable height
- // Calculate available height for the table body
- // Updated for DataTables v2 class names
- const wrapper = $('#ReportConfigurationTable_wrapper');
- const header = wrapper.find('.dt-scroll-head');
- const info = wrapper.find('.dt-info');
- const paginate = wrapper.find('.dt-paging');
-
- const headerHeight = header.length ? header.outerHeight(true) : 0;
- const infoHeight = info.length ? info.outerHeight(true) : 0;
- const paginateHeight = paginate.length ? paginate.outerHeight(true) : 0;
- const padding = 20; // Extra padding
-
- const availableHeight = containerHeight - headerHeight - infoHeight - paginateHeight - padding;
- const minHeight = 200; // Minimum table body height
-
- const scrollBodyHeight = Math.max(minHeight, availableHeight);
-
- // Apply the calculated height
- wrapper.find('.dt-scroll-body').css({
- 'max-height': scrollBodyHeight + 'px',
- 'height': scrollBodyHeight + 'px'
- });
+ /**
+ * Builds the [{propertyName, path, columnName}] array from the current
+ * DataTable state. This is what gets exported / shown in the editor.
+ */
+ function buildJsonMapping() {
+ return getCurrentTableData().map(function (row) {
+ return {
+ propertyName: row.propertyName,
+ path: row.path || '',
+ columnName: row.columnName
+ };
+ });
+ }
+
+ /**
+ * Creates a composite lookup key from propertyName and path.
+ * @param {string} propertyName
+ * @param {string} path
+ * @returns {string}
+ */
+ function compositeKey(propertyName, path) {
+ return (propertyName || '') + '|||' + (path || '');
+ }
+
+ function applyJsonMappingToTable(mappingArray) {
+ if (!dataTable) return { appliedCount: 0, unmatchedItems: [] };
+
+ // Build a lookup: (propertyName + path) → columnName
+ const lookup = {};
+ mappingArray.forEach(function (item) {
+ const key = compositeKey(item.propertyName, item.path);
+ lookup[key] = item.columnName;
+ });
+
+ let appliedCount = 0;
+ const matchedKeys = new Set();
+
+ dataTable.rows().every(function () {
+ const rowData = this.data();
+ const node = this.node();
+ const $input = $(node).find('.column-name-input');
+ if (!$input.length) return;
+
+ const key = compositeKey(rowData.key, rowData.path);
+ if (key in lookup) {
+ matchedKeys.add(key);
+ const newValue = sanitizeColumnName(lookup[key] || '');
+ if ($input.val() !== newValue) {
+ $input.val(newValue);
+ validateColumnNameInput($input, newValue, $input.data('path'));
+ appliedCount++;
}
- } catch (error) {
- console.warn('Error adjusting table layout:', error);
}
+ });
+
+ // Identify items from the import that did not match any table row
+ const unmatchedItems = mappingArray.filter(function (item) {
+ const key = compositeKey(item.propertyName, item.path);
+ return !matchedKeys.has(key);
+ });
+
+ if (appliedCount > 0) {
+ markAsChanged();
}
+
+ return { appliedCount: appliedCount, unmatchedItems: unmatchedItems };
}
- // Function to handle tab visibility changes with improved column adjustment
- function handleTabVisibilityChange() {
- // Check if the reporting configuration tab is now visible
- if (isReportingTabVisible()) {
- // Progressive approach with multiple attempts
- const adjustmentSequence = [
- { delay: 50, action: 'initial' },
- { delay: 150, action: 'layout' },
- { delay: 300, action: 'columns' },
- { delay: 500, action: 'final' }
- ];
-
- adjustmentSequence.forEach(step => {
- setTimeout(() => {
- if (dataTable && isReportingTabVisible()) {
- switch (step.action) {
- case 'initial':
- dataTable.columns.adjust();
- break;
- case 'layout':
- adjustTableLayout();
- break;
- case 'columns':
- forceTableColumnAdjustment();
- break;
- case 'final':
- dataTable.columns.adjust();
- dataTable.draw(false);
- break;
- }
+ // Shared validators for the JSON editor and file import
+ const mappingValidators = [
+ {
+ name: 'isArray',
+ message: 'JSON must be an array of objects.',
+ validate: function (data) { return Array.isArray(data); }
+ },
+ {
+ name: 'uniquePropertyNames',
+ message: 'Duplicate propertyName+path combinations detected. Rows sharing the same combination will all receive the last columnName value when applied — use inline table editing for those rows instead.',
+ severity: 'warning',
+ validate: function (data) {
+ if (!Array.isArray(data)) return true;
+ const keys = data.map(function (r) { return compositeKey(r.propertyName, r.path); });
+ return new Set(keys).size === keys.length;
+ }
+ },
+ {
+ name: 'uniqueColumnNames',
+ message: 'Duplicate columnName values found. Each columnName must be unique.',
+ validate: function (data) {
+ if (!Array.isArray(data)) return true;
+ const cols = data.filter(function (r) { return r.columnName; })
+ .map(function (r) { return r.columnName.toLowerCase(); });
+ return new Set(cols).size === cols.length;
+ }
+ },
+ {
+ name: 'columnNameFormat',
+ message: 'One or more column names contain invalid characters. Use only letters, numbers, and underscores.',
+ validate: function (data) {
+ if (!Array.isArray(data)) return true;
+ return data.every(function (r) {
+ if (!r.columnName) return true; // empty is allowed
+ return /^[a-zA-Z_]\w*$/.test(r.columnName);
+ });
+ }
+ },
+ {
+ name: 'columnNameLength',
+ message: 'One or more column names exceed the maximum length of ' + COLUMN_VALIDATION.MAX_LENGTH + ' characters.',
+ validate: function (data) {
+ if (!Array.isArray(data)) return true;
+ return data.every(function (r) {
+ if (!r.columnName) return true;
+ return r.columnName.length <= COLUMN_VALIDATION.MAX_LENGTH;
+ });
+ }
+ },
+ {
+ name: 'reservedWords',
+ message: 'One or more column names use a PostgreSQL reserved word.',
+ validate: function (data) {
+ if (!Array.isArray(data)) return true;
+ return data.every(function (r) {
+ if (!r.columnName) return true;
+ return !COLUMN_VALIDATION.RESERVED_WORDS.includes(r.columnName.toLowerCase());
+ });
+ }
+ }
+ ];
+
+ // Create the editor instance (lazy – modal built on first use)
+ const mappingEditor = new UnityJsonEditor({
+ title: 'Edit Column Mapping',
+ requiredFields: ['propertyName', 'path', 'columnName'],
+ validators: mappingValidators,
+ onSave: function (data, warnings) {
+ const result = applyJsonMappingToTable(data);
+ const msg = result.appliedCount + ' column mapping(s) updated. Remember to Save to persist changes.';
+ const allWarnings = (warnings || []).slice();
+ if (result.unmatchedItems.length > 0) {
+ const unmatchedNames = result.unmatchedItems.map(function (item) {
+ return item.propertyName + (item.path ? ' (' + item.path + ')' : '');
+ });
+ allWarnings.push({ message: result.unmatchedItems.length + ' item(s) could not be matched and were ignored: ' + unmatchedNames.join(', ') });
+ }
+ if (allWarnings.length > 0) {
+ abp.message.warn(msg + '\n\n\u26A0 ' + allWarnings.map(function (w) { return w.message; }).join('\n'));
+ } else {
+ abp.message.success(msg);
+ }
+ }
+ });
+
+ // --- Export ---
+ $('#btn-export-json-mapping').on('click', function (e) {
+ e.preventDefault();
+ if (!dataTable) {
+ abp.message.warn('No mapping data loaded.');
+ return;
+ }
+
+ const mapping = buildJsonMapping();
+ if (mapping.length === 0) {
+ abp.message.warn('No mapping data to export.');
+ return;
+ }
+
+ const provider = getCorrelationProvider();
+ const correlationId = getCurrentCorrelationId();
+ const filename = 'column-mapping-' + provider + '-' + (correlationId || 'new') + '.json';
+ UnityJsonEditor.exportToFile(mapping, filename);
+ });
+
+ // --- Import ---
+ $('#btn-import-json-mapping').on('click', function (e) {
+ e.preventDefault();
+ if (!dataTable) {
+ abp.message.warn('No mapping data loaded. Please load a configuration first.');
+ return;
+ }
+
+ let importWarnings = [];
+ UnityJsonEditor.importFromFile({
+ validators: mappingValidators.concat([
+ {
+ name: 'hasRequiredFields',
+ message: 'Each item must have "propertyName" and "columnName" fields.',
+ validate: function (data) {
+ if (!Array.isArray(data)) return false;
+ return data.every(function (r) {
+ return r !== null && typeof r === 'object' &&
+ 'propertyName' in r && 'columnName' in r;
+ });
}
- }, step.delay);
- });
+ }
+ ]),
+ onWarning: function (warnings) {
+ importWarnings = warnings;
+ }
+ }).then(function (data) {
+ const result = applyJsonMappingToTable(data);
+ const msg = result.appliedCount + ' column mapping(s) imported. Remember to Save to persist changes.';
+ const allWarnings = importWarnings.slice();
+ if (result.unmatchedItems.length > 0) {
+ const unmatchedNames = result.unmatchedItems.map(function (item) {
+ return item.propertyName + (item.path ? ' (' + item.path + ')' : '');
+ });
+ allWarnings.push({ message: result.unmatchedItems.length + ' item(s) could not be matched and were ignored: ' + unmatchedNames.join(', ') });
+ }
+ if (allWarnings.length > 0) {
+ abp.message.warn(msg + '\n\n\u26A0 ' + allWarnings.map(function (w) { return w.message; }).join('\n'));
+ } else {
+ abp.message.success(msg);
+ }
+ }).catch(function (err) {
+ abp.message.error(err.message || 'Failed to import file.');
+ });
+ });
+
+ // --- Edit JSON ---
+ $('#btn-edit-json-mapping').on('click', function (e) {
+ e.preventDefault();
+ if (!dataTable) {
+ abp.message.warn('No mapping data loaded. Please load a configuration first.');
+ return;
+ }
+
+ const mapping = buildJsonMapping();
+ mappingEditor.open(mapping);
+ });
+
+ // Initialize version selector visibility based on provider
+ updateVersionSelectorVisibility();
+
+ // Function to handle tab visibility changes
+ // When the tab becomes visible, recalculate column widths and trigger
+ // ScrollResize to set the correct scroll body height.
+ function handleTabVisibilityChange() {
+ if (isReportingTabVisible() && dataTable) {
+ setTimeout(function () {
+ dataTable.columns.adjust();
+ dataTable.draw(false);
+ }, 50);
}
}
@@ -1918,17 +2076,6 @@ $(function () {
}, 150);
});
- // Also handle window resize events
- $(window).on('resize', function () {
- if (isReportingTabVisible()) {
- adjustTableLayout();
- // Add column adjustment on resize
- setTimeout(function () {
- forceTableColumnAdjustment();
- }, 100);
- }
- });
-
// Add intersection observer for visibility detection (fallback)
if (window.IntersectionObserver) {
const observer = new IntersectionObserver((entries) => {
@@ -1971,4 +2118,11 @@ $(function () {
});
}
}
+
+ // Keep the hidden view-status field in sync when async generation completes
+ PubSub.subscribe('view_generation_completed', function (msg, data) {
+ if (data?.finalStatus) {
+ $('#reportingViewStatus').val(data.finalStatus);
+ }
+ });
});
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/ColumnsMappingServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/ColumnsMappingServiceTests.cs
index 32ca6d8646..0c2490dd55 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/ColumnsMappingServiceTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/ColumnsMappingServiceTests.cs
@@ -748,5 +748,392 @@ await Should.ThrowAsync (async () =>
}
#endregion
+
+ #region Column Name Source by Provider Tests
+
+ // Helper method to call internal GetDefaultColumnNameSource method via reflection
+ private static string CallGetDefaultColumnNameSource(FieldPathTypeDto field, string correlationProvider)
+ {
+ var type = typeof(ReportMappingUtils);
+ var method = type.GetMethod("GetDefaultColumnNameSource", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
+ return (string)method!.Invoke(null, [field, correlationProvider])!;
+ }
+
+ // Helper method to call internal CreateNewMap method via reflection
+ private static ReportColumnsMap CallCreateNewMap(UpsertReportColumnsMapDto dto, FieldPathMetaMapDto fieldsMap)
+ {
+ var type = typeof(ReportMappingUtils);
+ var method = type.GetMethod("CreateNewMap", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
+ return (ReportColumnsMap)method!.Invoke(null, [dto, fieldsMap])!;
+ }
+
+ // Helper method to call internal UpdateExistingMap method via reflection
+ private static ReportColumnsMap CallUpdateExistingMap(UpsertReportColumnsMapDto dto, ReportColumnsMap existing, FieldPathMetaMapDto fieldsMap)
+ {
+ var type = typeof(ReportMappingUtils);
+ var method = type.GetMethod("UpdateExistingMap", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public);
+ return (ReportColumnsMap)method!.Invoke(null, [dto, existing, fieldsMap])!;
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Return_Key_For_FormVersion_Provider()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = "firstName",
+ Label = "First Name"
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "formversion");
+
+ // Assert
+ result.ShouldBe("firstName");
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Return_Label_For_Worksheet_Provider()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = "firstName",
+ Label = "First Name"
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "worksheet");
+
+ // Assert
+ result.ShouldBe("First Name");
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Return_Label_For_Scoresheet_Provider()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = "score1",
+ Label = "Score One"
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "scoresheet");
+
+ // Assert
+ result.ShouldBe("Score One");
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Be_Case_Insensitive_For_FormVersion()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = "myKey",
+ Label = "My Label"
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "FormVersion");
+
+ // Assert
+ result.ShouldBe("myKey");
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Return_Empty_When_Key_Is_Null_For_FormVersion()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = null!,
+ Label = "Some Label"
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "formversion");
+
+ // Assert
+ result.ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public void GetDefaultColumnNameSource_Should_Return_Empty_When_Label_Is_Null_For_Worksheet()
+ {
+ // Arrange
+ var field = new FieldPathTypeDto
+ {
+ Key = "someKey",
+ Label = null
+ };
+
+ // Act
+ var result = CallGetDefaultColumnNameSource(field, "worksheet");
+
+ // Assert
+ result.ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public void CreateNewMap_Should_Use_Key_For_FormVersion_Auto_Generated_ColumnNames()
+ {
+ // Arrange
+ var dto = new UpsertReportColumnsMapDto
+ {
+ CorrelationId = Guid.NewGuid(),
+ CorrelationProvider = "formversion",
+ Mapping = new UpsertColumnMappingDto { Rows = [] }
+ };
+
+ var fieldsMap = new FieldPathMetaMapDto
+ {
+ Fields =
+ [
+ new FieldPathTypeDto
+ {
+ Id = "1",
+ Key = "firstName",
+ Label = "First Name",
+ Type = "textfield",
+ Path = "firstName",
+ DataPath = "firstName",
+ TypePath = "textfield"
+ },
+ new FieldPathTypeDto
+ {
+ Id = "2",
+ Key = "emailAddress",
+ Label = "Email Address",
+ Type = "email",
+ Path = "emailAddress",
+ DataPath = "emailAddress",
+ TypePath = "email"
+ }
+ ]
+ };
+
+ // Act
+ var result = CallCreateNewMap(dto, fieldsMap);
+
+ // Assert
+ result.ShouldNotBeNull();
+ var mapping = System.Text.Json.JsonSerializer.Deserialize(result.Mapping)!;
+
+ // Column names should be derived from Key (not Label)
+ mapping.Rows[0].ColumnName.ShouldBe("firstname");
+ mapping.Rows[1].ColumnName.ShouldBe("emailaddress");
+ }
+
+ [Fact]
+ public void CreateNewMap_Should_Use_Label_For_Worksheet_Auto_Generated_ColumnNames()
+ {
+ // Arrange
+ var dto = new UpsertReportColumnsMapDto
+ {
+ CorrelationId = Guid.NewGuid(),
+ CorrelationProvider = "worksheet",
+ Mapping = new UpsertColumnMappingDto { Rows = [] }
+ };
+
+ var fieldsMap = new FieldPathMetaMapDto
+ {
+ Fields =
+ [
+ new FieldPathTypeDto
+ {
+ Id = "1",
+ Key = "firstName",
+ Label = "First Name",
+ Type = "textfield",
+ Path = "ws1->firstName",
+ DataPath = "ws1->firstName",
+ TypePath = "textfield"
+ }
+ ]
+ };
+
+ // Act
+ var result = CallCreateNewMap(dto, fieldsMap);
+
+ // Assert
+ result.ShouldNotBeNull();
+ var mapping = System.Text.Json.JsonSerializer.Deserialize(result.Mapping)!;
+
+ // Column name should be derived from Label (not Key) for worksheet
+ mapping.Rows[0].ColumnName.ShouldBe("first_name");
+ }
+
+ [Fact]
+ public void UpdateExistingMap_Should_Use_Key_For_FormVersion_New_Field_ColumnNames()
+ {
+ // Arrange
+ var existingMapping = new Mapping
+ {
+ Rows =
+ [
+ new MapRow
+ {
+ PropertyName = "firstName",
+ ColumnName = "existing_col",
+ Path = "firstName",
+ DataPath = "firstName",
+ Label = "First Name",
+ Type = "textfield",
+ TypePath = "textfield",
+ Id = "1"
+ }
+ ]
+ };
+
+ var existing = new ReportColumnsMap
+ {
+ CorrelationId = Guid.NewGuid(),
+ CorrelationProvider = "formversion",
+ Mapping = System.Text.Json.JsonSerializer.Serialize(existingMapping)
+ };
+
+ var dto = new UpsertReportColumnsMapDto
+ {
+ CorrelationId = existing.CorrelationId,
+ CorrelationProvider = "formversion",
+ Mapping = new UpsertColumnMappingDto
+ {
+ Rows =
+ [
+ new UpsertMapRowDto { PropertyName = "firstName", ColumnName = "existing_col", Path = "firstName" }
+ ]
+ }
+ };
+
+ var fieldsMap = new FieldPathMetaMapDto
+ {
+ Fields =
+ [
+ new FieldPathTypeDto
+ {
+ Id = "1",
+ Key = "firstName",
+ Label = "First Name",
+ Type = "textfield",
+ Path = "firstName",
+ DataPath = "firstName",
+ TypePath = "textfield"
+ },
+ // New field discovered
+ new FieldPathTypeDto
+ {
+ Id = "2",
+ Key = "lastName",
+ Label = "Last Name",
+ Type = "textfield",
+ Path = "lastName",
+ DataPath = "lastName",
+ TypePath = "textfield"
+ }
+ ]
+ };
+
+ // Act
+ var result = CallUpdateExistingMap(dto, existing, fieldsMap);
+
+ // Assert
+ result.ShouldNotBeNull();
+ var mapping = System.Text.Json.JsonSerializer.Deserialize(result.Mapping)!;
+
+ // Existing field should keep its column name
+ mapping.Rows[0].ColumnName.ShouldBe("existing_col");
+
+ // New field should be auto-generated from Key (not Label) for formversion
+ mapping.Rows[1].ColumnName.ShouldBe("lastname");
+ }
+
+ [Fact]
+ public void UpdateExistingMap_Should_Use_Label_For_Worksheet_New_Field_ColumnNames()
+ {
+ // Arrange
+ var existingMapping = new Mapping
+ {
+ Rows =
+ [
+ new MapRow
+ {
+ PropertyName = "score1",
+ ColumnName = "existing_score",
+ Path = "ws1->score1",
+ DataPath = "ws1->score1",
+ Label = "Score 1",
+ Type = "number",
+ TypePath = "number",
+ Id = "1"
+ }
+ ]
+ };
+
+ var existing = new ReportColumnsMap
+ {
+ CorrelationId = Guid.NewGuid(),
+ CorrelationProvider = "worksheet",
+ Mapping = System.Text.Json.JsonSerializer.Serialize(existingMapping)
+ };
+
+ var dto = new UpsertReportColumnsMapDto
+ {
+ CorrelationId = existing.CorrelationId,
+ CorrelationProvider = "worksheet",
+ Mapping = new UpsertColumnMappingDto
+ {
+ Rows =
+ [
+ new UpsertMapRowDto { PropertyName = "score1", ColumnName = "existing_score", Path = "ws1->score1" }
+ ]
+ }
+ };
+
+ var fieldsMap = new FieldPathMetaMapDto
+ {
+ Fields =
+ [
+ new FieldPathTypeDto
+ {
+ Id = "1",
+ Key = "score1",
+ Label = "Score 1",
+ Type = "number",
+ Path = "ws1->score1",
+ DataPath = "ws1->score1",
+ TypePath = "number"
+ },
+ // New field discovered
+ new FieldPathTypeDto
+ {
+ Id = "2",
+ Key = "totalScore",
+ Label = "Total Score",
+ Type = "number",
+ Path = "ws1->totalScore",
+ DataPath = "ws1->totalScore",
+ TypePath = "number"
+ }
+ ]
+ };
+
+ // Act
+ var result = CallUpdateExistingMap(dto, existing, fieldsMap);
+
+ // Assert
+ result.ShouldNotBeNull();
+ var mapping = System.Text.Json.JsonSerializer.Deserialize(result.Mapping)!;
+
+ // Existing field should keep its column name
+ mapping.Rows[0].ColumnName.ShouldBe("existing_score");
+
+ // New field should be auto-generated from Label (not Key) for worksheet
+ mapping.Rows[1].ColumnName.ShouldBe("total_score");
+ }
+
+ #endregion
}
}
\ No newline at end of file
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 f2472b27c6..4dea6cfe20 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
@@ -50,6 +50,7 @@ public override void ConfigureBundle(BundleConfigurationContext context)
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("/themes/ux2/json-editor.js");
context.Files.Add("/js/DateUtils.js");
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs
index a4fbbd6a49..a1beefb47f 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs
@@ -12,6 +12,7 @@ public override void ConfigureBundle(BundleConfigurationContext context)
context.Files.Add("/themes/ux2/fluenticons.min.css");
context.Files.Add("/themes/ux2/layout.css");
context.Files.Add("/themes/ux2/unity-styles.css");
+ context.Files.Add("/themes/ux2/json-editor.css");
context.Files.AddIfNotContains("/libs/datatables.net-bs5/css/dataTables.bootstrap5.min.css");
context.Files.AddIfNotContains("/libs/datatables.net-buttons-bs5/css/buttons.bootstrap5.min.css");
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
index d31fa1bcb9..9383cf230e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
@@ -42,6 +42,10 @@
{
Switch Grant Programs
}
+ @if (CurrentTenant.Id != null)
+ {
+ Applicant Portal Configuration
+ }
@if (await FeatureChecker.IsEnabledAsync("Unity.Payments") && isAuthorizedForPaymentConfiguration)
{
Payments Configuration
@@ -51,6 +55,10 @@
Scoresheets Configuration
Custom Fields Configuration
}
+ @if (CurrentUser.IsInRole("ITOperations"))
+ {
+ Unity Admin
+ }
@if (CurrentUser.IsInRole("system_admin") && await FeatureChecker.IsEnabledAsync("SettingManagement.Enable"))
{
Settings
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.css
new file mode 100644
index 0000000000..312279d8b2
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.css
@@ -0,0 +1,95 @@
+/* ==========================================================================
+ UnityJsonEditor - Reusable JSON editor component styles
+ ========================================================================== */
+
+/* Header: title + item count + close */
+.uje-status-text {
+ font-size: 0.8125rem;
+ color: #6c757d;
+ white-space: nowrap;
+}
+
+/* Body: flex column — textarea fills available space, status bar stays pinned below */
+.modal-body:has(.uje-textarea-wrapper) {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.uje-textarea-wrapper {
+ position: relative;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.uje-textarea {
+ font-family: 'Cascadia Code', 'Fira Code', 'SF Mono', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ min-height: 120px;
+ max-height: 350px;
+ height: 100%;
+ tab-size: 2;
+ white-space: pre;
+ overflow-wrap: normal;
+ overflow-x: auto;
+ resize: none;
+}
+
+ .uje-textarea:focus {
+ border-color: #86b7fe;
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
+ }
+
+ .uje-textarea[readonly] {
+ background-color: #f8f9fa;
+ cursor: default;
+ }
+
+/* Footer: buttons left-aligned */
+.uje-footer-actions {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+/* Status bar: reserved space at bottom of body for validation messages */
+.uje-status-bar {
+ flex: 0 0 auto;
+ min-height: 1.5em;
+ max-height: 4.5em;
+ overflow-y: auto;
+ font-size: 0.8125rem;
+ width: 100%;
+ padding-top: 0.375rem;
+}
+
+.uje-validation-msg i {
+ font-size: 0.875rem;
+}
+
+/* Scroll-to-top button */
+.uje-scroll-top {
+ position: absolute;
+ bottom: 0.5rem;
+ right: 0.75rem;
+ width: 1.75rem;
+ height: 1.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+ background-color: rgba(255, 255, 255, 0.9);
+ color: #6c757d;
+ font-size: 0.75rem;
+ cursor: pointer;
+ opacity: 0.7;
+ transition: opacity 0.15s ease-in-out;
+}
+
+ .uje-scroll-top:hover {
+ opacity: 1;
+ color: #495057;
+ border-color: #86b7fe;
+ }
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.js
new file mode 100644
index 0000000000..94e04f4c68
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/json-editor.js
@@ -0,0 +1,637 @@
+/**
+ * UnityJsonEditor - A reusable, extensible JSON editor component.
+ *
+ * Provides a Bootstrap modal with a monospace textarea for editing JSON data,
+ * real-time validation, format/beautify, and file export/import capabilities.
+ * Supports custom validators for domain-specific rules.
+ *
+ * @requires jQuery, Bootstrap 5
+ *
+ * @example
+ * // Basic usage
+ * const editor = new UnityJsonEditor({
+ * title: 'Edit Mapping',
+ * onSave: function(data) {
+ * console.log('Saved:', data);
+ * }
+ * });
+ * editor.open([{ key: 'value' }]);
+ *
+ * @example
+ * // With custom validators
+ * const editor = new UnityJsonEditor({
+ * title: 'Edit Column Mapping',
+ * requiredFields: ['propertyName', 'columnName'],
+ * validators: [
+ * {
+ * name: 'uniqueColumns',
+ * message: 'Column names must be unique',
+ * validate: function(data) {
+ * const cols = data.map(r => r.columnName);
+ * return new Set(cols).size === cols.length;
+ * }
+ * }
+ * ],
+ * onSave: function(data) { applyChanges(data); }
+ * });
+ *
+ * @example
+ * // File operations (standalone, no modal needed)
+ * UnityJsonEditor.exportToFile(myData, 'config.json');
+ * UnityJsonEditor.importFromFile({ accept: '.json' }).then(function(data) {
+ * console.log('Imported:', data);
+ * });
+ */
+const UnityJsonEditor = (function ($) {
+ 'use strict';
+
+ let _instanceCount = 0;
+
+ /**
+ * Default configuration options.
+ * @type {object}
+ */
+ const DEFAULTS = {
+ /** Modal dialog title */
+ title: 'Edit JSON',
+
+ /** Bootstrap modal size class: 'modal-sm', 'modal-lg', 'modal-xl' */
+ size: 'modal-lg',
+
+ /** Number of visible rows in the textarea */
+ rows: 18,
+
+ /** Text for the save/apply button */
+ saveButtonText: 'Apply',
+
+ /** Text for the cancel button */
+ cancelButtonText: 'Cancel',
+
+ /** Text for the format button */
+ formatButtonText: 'Format',
+
+ /** Whether the modal should be read-only (disables editing and save) */
+ readOnly: false,
+
+ /**
+ * Array of field names required on each item when the data is an array of objects.
+ * Set to null or [] to skip required-field validation.
+ * @type {string[]|null}
+ */
+ requiredFields: null,
+
+ /**
+ * Custom validators array. Each validator is an object with:
+ * - name {string}: Identifier for the validator
+ * - message {string}: Message shown on failure
+ * - severity {string}: 'error' (default) blocks save; 'warning' allows save
+ * - validate {function(data): boolean}: Returns true if valid
+ *
+ * Validators run after JSON syntax and required-field checks pass.
+ * @type {Array<{name: string, message: string, severity?: string, validate: function}>}
+ */
+ validators: [],
+
+ /**
+ * Callback invoked when the user clicks Save/Apply and all validation passes.
+ * Receives the parsed JSON data and an array of active warnings.
+ * @type {function(data, warnings): void|null}
+ */
+ onSave: null,
+
+ /**
+ * Callback invoked when the user cancels or closes the modal without saving.
+ * Not called when the modal is closed after a successful save.
+ * @type {function(): void|null}
+ */
+ onCancel: null,
+
+ /**
+ * Callback invoked when validation fails with errors.
+ * Receives an array of error objects: [{ validator: string, message: string }].
+ * @type {function(errors): void|null}
+ */
+ onValidationError: null
+ };
+
+ // ========================================================================
+ // Constructor
+ // ========================================================================
+
+ /**
+ * Creates a new UnityJsonEditor instance.
+ * @param {object} opts - Configuration options (merged with DEFAULTS).
+ */
+ function UnityJsonEditor(opts) {
+ this._id = ++_instanceCount;
+ this._opts = $.extend({}, DEFAULTS, opts);
+ this._modalId = 'unityJsonEditorModal_' + this._id;
+ this._modal = null;
+ this._bsModal = null;
+ this._textarea = null;
+ this._statusBar = null;
+ this._saveBtn = null;
+ this._lastWarnings = [];
+ this._savedFlag = false;
+ this._built = false;
+ }
+
+ // ========================================================================
+ // Static helpers (usable without an instance)
+ // ========================================================================
+
+ /**
+ * Exports data as a downloadable JSON file.
+ * @param {*} data - Data to serialize.
+ * @param {string} [filename='export.json'] - Download filename.
+ */
+ UnityJsonEditor.exportToFile = function (data, filename = 'export.json') {
+ const json = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(function () { URL.revokeObjectURL(url); }, 100);
+ };
+
+ /**
+ * Opens a file picker and reads a JSON file.
+ * @param {object} [opts] - Options.
+ * @param {string} [opts.accept='.json'] - File input accept attribute.
+ * @param {Array<{name:string,message:string,severity?:string,validate:function}>} [opts.validators] - Optional validators to run on imported data.
+ * @param {function} [opts.onWarning] - Callback receiving an array of warning objects when warnings are present but no errors.
+ * @returns {Promise<*>} Resolves with parsed JSON data, rejects on error.
+ */
+ UnityJsonEditor.importFromFile = function (opts) {
+ opts = opts || {};
+ const accept = opts.accept || '.json';
+ const validators = opts.validators || [];
+ const onWarning = opts.onWarning || null;
+
+ return new Promise(function (resolve, reject) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = accept;
+ input.style.display = 'none';
+
+ input.addEventListener('change', function () {
+ // Remove from DOM now that the browser has fired the change event
+ input.remove();
+
+ const file = input.files?.[0];
+ if (!file) {
+ reject(new Error('No file selected.'));
+ return;
+ }
+ _processImportedFile(file, validators, onWarning)
+ .then(resolve)
+ .catch(reject);
+ });
+
+ document.body.appendChild(input);
+ input.click();
+ });
+ };
+
+ // ========================================================================
+ // Prototype (instance methods)
+ // ========================================================================
+
+ UnityJsonEditor.prototype = {
+ constructor: UnityJsonEditor,
+
+ /**
+ * Opens the editor modal with the given data.
+ * @param {*} data - Data to edit (will be serialized to JSON).
+ */
+ open: function (data) {
+ if (!this._built) {
+ this._build();
+ }
+
+ this._savedFlag = false;
+ const json = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
+ this._textarea.val(json);
+ this._clearStatus();
+ this._validate();
+ this._bsModal.show();
+ },
+
+ /**
+ * Closes the editor modal.
+ */
+ close: function () {
+ if (this._bsModal) {
+ this._bsModal.hide();
+ }
+ },
+
+ /**
+ * Returns the current parsed data from the editor, or null if invalid.
+ * @returns {*|null}
+ */
+ getData: function () {
+ try {
+ return JSON.parse(this._textarea.val());
+ } catch (e) {
+ console.debug('getData: invalid JSON in editor', e.message);
+ return null;
+ }
+ },
+
+ /**
+ * Updates configuration options on the fly.
+ * @param {object} opts - Options to merge.
+ */
+ setOptions: function (opts) {
+ $.extend(this._opts, opts);
+ if (this._built) {
+ this._updateUI();
+ }
+ },
+
+ /**
+ * Destroys the editor instance and removes the modal from the DOM.
+ */
+ destroy: function () {
+ if (this._bsModal) {
+ this._bsModal.dispose();
+ }
+ if (this._modal) {
+ this._modal.remove();
+ }
+ this._built = false;
+ },
+
+ // ====================================================================
+ // Private methods
+ // ====================================================================
+
+ /**
+ * Builds the Bootstrap modal and appends it to the document body.
+ * @private
+ */
+ _build: function () {
+ const opts = this._opts;
+ const id = this._modalId;
+
+ const html =
+ '';
+
+ this._modal = $(html);
+ $('body').append(this._modal);
+
+ this._textarea = this._modal.find('.uje-textarea');
+ this._statusBar = this._modal.find('.uje-validation-msg');
+ this._statusText = this._modal.find('.uje-status-text');
+ this._saveBtn = this._modal.find('.uje-save-btn');
+ this._bsModal = new bootstrap.Modal(document.getElementById(id));
+
+ // Event: real-time validation on input
+ this._textarea.on('input', () => {
+ this._validate();
+ });
+
+ // Event: format button
+ this._modal.find('.uje-format-btn').on('click', () => {
+ this._format();
+ });
+
+ // Event: save button
+ if (!opts.readOnly) {
+ this._saveBtn.on('click', () => {
+ this._onSave();
+ });
+ }
+
+ // Event: modal hidden — only fire onCancel when not closed via save
+ this._modal.on('hidden.bs.modal', () => {
+ if (!this._savedFlag && typeof opts.onCancel === 'function') {
+ opts.onCancel();
+ }
+ this._savedFlag = false;
+ });
+
+ // Tab key inserts spaces instead of changing focus
+ this._textarea.on('keydown', (e) => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ const target = e.target;
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+ const value = $(target).val();
+ $(target).val(value.substring(0, start) + ' ' + value.substring(end));
+ target.selectionStart = target.selectionEnd = start + 2;
+ $(this._textarea).trigger('input');
+ }
+ });
+
+ // Scroll-to-top button: show when scrolled, click to jump to top
+ const $scrollBtn = this._modal.find('.uje-scroll-top');
+ this._textarea.on('scroll', function () {
+ $scrollBtn.toggle(this.scrollTop > 100);
+ });
+ $scrollBtn.on('click', () => {
+ this._textarea[0].scrollTop = 0;
+ $scrollBtn.hide();
+ });
+
+ this._built = true;
+ },
+
+ /**
+ * Updates UI elements to reflect current options.
+ * @private
+ */
+ _updateUI: function () {
+ const opts = this._opts;
+ this._modal.find('.modal-title').text(opts.title);
+ this._modal.find('.uje-format-btn').text(_escapeHtml(opts.formatButtonText));
+ if (this._saveBtn.length) {
+ this._saveBtn.text(opts.saveButtonText);
+ }
+ this._textarea.prop('readonly', opts.readOnly);
+ },
+
+ /**
+ * Formats/beautifies the JSON in the textarea.
+ * @private
+ */
+ _format: function () {
+ try {
+ const raw = this._textarea.val();
+ const parsed = JSON.parse(raw);
+ this._textarea.val(JSON.stringify(parsed, null, 2));
+ this._validate();
+ } catch (e) {
+ console.debug('_format: could not parse JSON', e.message);
+ this._validate();
+ }
+ },
+
+ /**
+ * Checks required fields on each item in an array of objects.
+ * @private
+ * @param {*} data - Parsed JSON data.
+ * @returns {string|null} Error message if validation fails, null if valid.
+ */
+ _checkRequiredFields: function (data) {
+ const requiredFields = this._opts.requiredFields;
+ if (!requiredFields || requiredFields.length === 0 || !Array.isArray(data)) {
+ return null;
+ }
+
+ for (const [i, item] of data.entries()) {
+ if (typeof item !== 'object' || item === null) {
+ return 'Item at index ' + i + ' is not an object.';
+ }
+ for (const field of requiredFields) {
+ if (!(field in item) || item[field] === null || item[field] === undefined) {
+ return 'Item at index ' + i + ' is missing required field "' + field + '".';
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Runs all validation checks and updates the UI.
+ * @private
+ * @returns {boolean} True if no errors (warnings are acceptable).
+ */
+ _validate: function () {
+ const raw = this._textarea.val().trim();
+ this._lastWarnings = [];
+
+ // Empty check
+ if (!raw) {
+ this._showStatus('warning', 'Editor is empty');
+ this._setSaveEnabled(false);
+ return false;
+ }
+
+ // JSON syntax check
+ let data;
+ try {
+ data = JSON.parse(raw);
+ } catch (e) {
+ this._showStatus('error', 'Invalid JSON: ' + e.message);
+ this._setSaveEnabled(false);
+ return false;
+ }
+
+ // Required fields check (only for arrays of objects)
+ const requiredFieldsError = this._checkRequiredFields(data);
+ if (requiredFieldsError) {
+ this._showStatus('error', requiredFieldsError);
+ this._setSaveEnabled(false);
+ return false;
+ }
+
+ // Custom validators (errors + warnings)
+ const result = _runValidators(data, this._opts.validators, this._opts.requiredFields);
+
+ // Errors block save
+ if (result.errors.length > 0) {
+ this._showStatus('error', result.errors[0].message);
+ this._setSaveEnabled(false);
+ if (typeof this._opts.onValidationError === 'function') {
+ this._opts.onValidationError(result.errors);
+ }
+ return false;
+ }
+
+ // Warnings allow save but show amber status
+ const itemCount = Array.isArray(data) ? data.length + ' items' : 'Object';
+ if (result.warnings.length > 0) {
+ this._lastWarnings = result.warnings;
+ const warningText = result.warnings[0].message;
+ this._showStatus('warning', 'Valid JSON \u00B7 ' + itemCount, '\u26A0 ' + warningText);
+ this._textarea.removeClass('is-invalid');
+ this._setSaveEnabled(true);
+ return true;
+ }
+
+ // All passed — no errors, no warnings
+ this._showStatus('success', 'Valid JSON \u00B7 ' + itemCount);
+ this._setSaveEnabled(true);
+ return true;
+ },
+
+ /**
+ * Handles the save action.
+ * @private
+ */
+ _onSave: function () {
+ if (!this._validate()) return;
+
+ const data = JSON.parse(this._textarea.val());
+ const warnings = this._lastWarnings || [];
+ if (typeof this._opts.onSave === 'function') {
+ this._opts.onSave(data, warnings);
+ }
+ this._savedFlag = true;
+ this.close();
+ },
+
+ /**
+ * Displays a status message.
+ * @private
+ * @param {'success'|'error'|'warning'} type
+ * @param {string} message
+ */
+ _showStatus: function (type, message, secondLine) {
+ const iconMap = { success: 'fa-check-circle', error: 'fa-times-circle', warning: 'fa-exclamation-circle' };
+ const colorMap = { success: 'text-success', error: 'text-danger', warning: 'text-warning' };
+ const icon = iconMap[type] || iconMap.warning;
+ const colorClass = colorMap[type] || colorMap.warning;
+
+ let html = '' + _escapeHtml(message);
+ if (secondLine) {
+ html += ' ' + _escapeHtml(secondLine);
+ }
+
+ this._statusBar
+ .html(html)
+ .removeClass('text-success text-danger text-warning')
+ .addClass(colorClass);
+
+ this._textarea
+ .toggleClass('is-invalid', type === 'error')
+ .toggleClass('is-valid', type === 'success');
+ },
+
+ /**
+ * Clears the status bar.
+ * @private
+ */
+ _clearStatus: function () {
+ this._statusBar.html('').removeClass('text-success text-danger text-warning');
+ this._textarea.removeClass('is-invalid is-valid');
+ },
+
+ /**
+ * Enables or disables the save button.
+ * @private
+ * @param {boolean} enabled
+ */
+ _setSaveEnabled: function (enabled) {
+ if (this._saveBtn.length) {
+ this._saveBtn.prop('disabled', !enabled);
+ }
+ }
+ };
+
+ // ========================================================================
+ // Private utility functions
+ // ========================================================================
+
+ /**
+ * Runs an array of custom validators against parsed data.
+ * Validators with severity:'warning' produce warnings (non-blocking);
+ * all others produce errors (blocking).
+ * @param {*} data - Parsed JSON data.
+ * @param {Array} validators - Validator definitions.
+ * @param {string[]|null} requiredFields - Required field names (for context).
+ * @returns {{errors: Array<{validator: string, message: string}>, warnings: Array<{validator: string, message: string}>}}
+ */
+ function _runValidators(data, validators, requiredFields) {
+ const errors = [];
+ const warnings = [];
+ if (!validators || validators.length === 0) return { errors: errors, warnings: warnings };
+
+ for (const v of validators) {
+ const isWarning = v.severity === 'warning';
+ const target = isWarning ? warnings : errors;
+ try {
+ const result = v.validate(data, requiredFields);
+ if (result === false) {
+ target.push({ validator: v.name, message: v.message || 'Validation failed: ' + v.name });
+ } else if (typeof result === 'object' && result !== null && result.valid === false) {
+ target.push({ validator: v.name, message: result.message || v.message || 'Validation failed: ' + v.name });
+ }
+ } catch (ex) {
+ errors.push({ validator: v.name, message: 'Validator "' + v.name + '" error: ' + ex.message });
+ }
+ }
+ return { errors: errors, warnings: warnings };
+ }
+
+ /**
+ * Reads a file, parses JSON, runs validators, and resolves with the data.
+ * Extracted from importFromFile to keep function nesting shallow.
+ * @param {File} file - The file to read.
+ * @param {Array} validators - Validators to run on the parsed data.
+ * @param {function|null} onWarning - Callback for non-blocking warnings.
+ * @returns {Promise<*>} Resolves with parsed JSON data, rejects on error.
+ */
+ function _processImportedFile(file, validators, onWarning) {
+ return file.text().then(function (text) {
+ const data = JSON.parse(text);
+
+ // Run validators if provided
+ const result = _runValidators(data, validators, null);
+ if (result.errors.length > 0) {
+ throw new Error(result.errors.map(function (err) { return err.message; }).join('\n'));
+ }
+
+ // Report warnings but allow import
+ if (result.warnings.length > 0 && typeof onWarning === 'function') {
+ onWarning(result.warnings);
+ }
+
+ return data;
+ }).catch(function (ex) {
+ throw new Error('Invalid JSON file: ' + ex.message);
+ });
+ }
+
+ /**
+ * Escapes HTML special characters.
+ * @param {string} str
+ * @returns {string}
+ */
+ function _escapeHtml(str) {
+ const div = document.createElement('div');
+ div.appendChild(document.createTextNode(str));
+ return div.innerHTML;
+ }
+
+ return UnityJsonEditor;
+
+})(jQuery);
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 cfad9c7c22..3217b80343 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
@@ -32,6 +32,7 @@
'use strict';
let DataTable = $.fn.dataTable;
+ let _filterRowInstanceCount = 0;
// Ensure DataTable is loaded
if (!DataTable) {
@@ -55,7 +56,7 @@
this.s = {
dt: new DataTable.Api(settings),
- namespace: '.dtFilterRow',
+ namespace: '.dtFilterRow' + (++_filterRowInstanceCount),
filterData: {},
opts: $.extend({}, DataTable.FilterRow.defaults, opts)
};
@@ -89,8 +90,7 @@
* @private
*/
_constructor: function () {
- let that = this;
- let dt = this.s.dt;
+ const dt = this.s.dt;
// Create the filter row
this._buildFilterRow();
@@ -104,20 +104,24 @@
this._restoreFilterState();
// Listen for column visibility and reorder events
- dt.on('column-reorder' + this.s.namespace, function () {
- that._rebuildFilterRow();
+ dt.on('column-reorder' + this.s.namespace, () => {
+ this._rebuildFilterRow();
+ });
+
+ dt.on('column-visibility' + this.s.namespace, () => {
+ this._rebuildFilterRow();
});
- dt.on('column-visibility' + this.s.namespace, function () {
- that._rebuildFilterRow();
+ // Update button state whenever the global search changes
+ dt.on('search' + this.s.namespace, () => {
+ this._updateButtonState();
});
// Listen for destroy event to cleanup
- dt.on('destroy' + this.s.namespace, function () {
- that._destroy();
+ dt.on('destroy' + this.s.namespace, () => {
+ this._destroy();
});
- // Show filter row if autoShow is enabled
if (this.s.opts.autoShow) {
this.dom.filterRow.show();
}
@@ -131,26 +135,29 @@
* @private
*/
_buildFilterRow: function () {
- let dt = this.s.dt;
- let that = this;
- let filterRow = $('').hide();
+ const dt = this.s.dt;
+ const namespace = this.s.namespace;
+ const opts = this.s.opts;
+ const filterData = this.s.filterData;
+ const updateButtonState = this._updateButtonState.bind(this);
+ const filterRow = $(' ').hide();
dt.columns().every(function () {
- let column = this;
+ const column = this;
// Only create filter cells for visible columns
if (column.visible()) {
- let title = $(column.header()).text();
- let colName = dt.settings()[0].aoColumns[column.index()].name || title;
+ const title = $(column.header()).text();
+ const colName = dt.settings()[0].aoColumns[column.index()].name || title;
if (title && title !== 'Actions' && title !== 'Action' && title !== 'Default') {
- let placeholder = that.s.opts.placeholderPrefix ?
- that.s.opts.placeholderPrefix + ' ' + title :
- title;
- // Get filter value by column name (not title) for persistence across reorders
- let filterValue = that.s.filterData[colName] || column.search() || '';
+ const placeholder = opts.placeholderPrefix
+ ? opts.placeholderPrefix + ' ' + title
+ : title;
- let input = $('', {
+ const filterValue = filterData[colName] || column.search() || '';
+
+ const input = $('', {
type: 'text',
class: 'form-control input-sm custom-filter-input',
placeholder: placeholder,
@@ -158,7 +165,7 @@
'data-column-name': colName
});
- let cell = $('| ').append(input);
+ const cell = $(' | ').append(input);
// Apply search value if it differs from current column search
if (column.search() !== filterValue) {
@@ -166,29 +173,26 @@
}
// Bind keyup event for filtering
- input.on('keyup' + that.s.namespace, function () {
- let val = this.value;
+ input.on('keyup' + namespace, function () {
+ const val = this.value;
+
if (column.search() !== val) {
column.search(val).draw();
// Store by column name for persistence
- that.s.filterData[colName] = val;
- that._updateButtonState();
+ filterData[colName] = val;
+ updateButtonState();
}
});
filterRow.append(cell);
} else {
- // Empty cell for action columns
filterRow.append($(' | '));
}
}
- // Skip hidden columns - don't add any td element
});
- // Remove existing filter row if present
dt.table().header().parentNode.querySelector('.tr-toggle-filter')?.remove();
- // Append to table header
$(dt.table().header()).after(filterRow);
this.dom.filterRow = filterRow;
},
@@ -198,23 +202,23 @@
* @private
*/
_rebuildFilterRow: function () {
- let dt = this.s.dt;
- let that = this;
-
// Preserve current filter values before rebuilding
- this.dom.filterRow?.find('.custom-filter-input').each(function() {
- let colName = $(this).data('column-name');
- let val = $(this).val();
+ this.dom.filterRow?.find('.custom-filter-input').each((_, el) => {
+ const colName = $(el).data('column-name');
+ const val = $(el).val();
if (colName && val) {
- that.s.filterData[colName] = val;
+ this.s.filterData[colName] = val;
}
});
-
- let wasVisible = this.dom.filterRow?.is(':visible');
+
+ const wasVisible = this.dom.filterRow?.is(':visible');
+
this._buildFilterRow();
+
if (wasVisible) {
this.dom.filterRow.show();
}
+
this._updateButtonState();
},
@@ -223,17 +227,14 @@
* @private
*/
_initializePopover: function () {
- let that = this;
- let btnSelector = '#' + this.s.opts.buttonId;
- let $btn = $(btnSelector);
+ const btnSelector = '#' + this.s.opts.buttonId;
+ const $btn = $(btnSelector);
- if (!$btn.length) {
- return; // Button not found, skip popover
- }
+ if (!$btn.length) return;
this.dom.button = $btn;
- $btn.on('click' + this.s.namespace, function () {
+ $btn.on('click' + this.s.namespace, () => {
$btn.popover('toggle');
});
@@ -247,58 +248,65 @@
`,
- content: function () {
- let isChecked = that.dom.filterRow.is(':visible');
+ content: () => {
+ const isChecked = this.dom.filterRow.is(':visible');
+
return `
-
+
- CLEAR FILTER
+
`;
},
placement: this.s.opts.popoverPlacement
});
- // Handle popover shown event
- $btn.on('shown.bs.popover' + this.s.namespace, function () {
- let $popover = $('.popover.custom-popover');
+ $btn.on('shown.bs.popover' + this.s.namespace, () => {
- // 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')]);
- });
+ const popoverId = $btn.attr('aria-describedby');
+ const $popover = popoverId ? $('#' + popoverId) : $('.popover.custom-popover');
- // Clear all filters
- $popover.find('#btnClearFilter').on('click', function () {
- that.clearFilters();
- $btn.popover('hide');
- });
+ $popover.find('#showFilter')
+ .off('click' + this.s.namespace)
+ .on('click' + this.s.namespace, () => {
+ this.dom.filterRow.toggle();
+ this.s.dt.trigger('filterRow-visibility', [
+ this.dom.filterRow.is(':visible')
+ ]);
+ });
- // Close popover on outside click/hover
- $(document).on('click.popover' + that.s.namespace, function (e) {
- if (!$(e.target).closest(btnSelector).length &&
- !$(e.target).closest('.popover').length) {
+ $popover.find('#btnClearFilter')
+ .off('click' + this.s.namespace)
+ .on('click' + this.s.namespace, () => {
+ this.clearFilters();
$btn.popover('hide');
- }
- });
+ });
- $(document).on('mouseenter.popover' + that.s.namespace, function (e) {
- if (!$(e.target).closest(btnSelector).length &&
- !$(e.target).closest('.popover').length) {
- $btn.popover('hide');
- }
- });
+ // ✅ FIXED OUTSIDE CLICK
+ $(document)
+ .off('mousedown' + this.s.namespace)
+ .on('mousedown' + this.s.namespace, (e) => {
+
+ const $popoverEl = $('.popover.custom-popover');
+ if (!$popoverEl.is(':visible')) return;
+
+ const $target = $(e.target);
+
+ const insidePopover = $target.closest('.popover.custom-popover').length > 0;
+ const insideButton = $target.closest(btnSelector).length > 0;
+
+ if (!insidePopover && !insideButton) {
+ $btn.popover('hide');
+ }
+ });
});
// Cleanup popover events on hide
- $btn.on('hide.bs.popover' + this.s.namespace, function () {
- let $popover = $('.popover.custom-popover');
- $popover.find('#showFilter').off('click');
- $popover.find('#btnClearFilter').off('click');
- $(document).off('click.popover' + that.s.namespace);
- $(document).off('mouseenter.popover' + that.s.namespace);
+ $btn.on('hide.bs.popover' + this.s.namespace, () => {
+ $(document).off('mousedown' + this.s.namespace);
});
},
@@ -308,30 +316,20 @@
*/
_updateButtonState: function () {
let dt = this.s.dt;
- let hasFilters = false;
-
- // Check column filters
- dt.columns().every(function () {
- if (this.search()) {
- hasFilters = true;
- return false;
- }
- });
+ let hasFilters = dt.search() !== '';
- // Check global search
- let externalSearchId = dt.init().externalSearchInputId;
- if (externalSearchId) {
- let searchVal = $(externalSearchId).val();
- if (searchVal && searchVal !== '') {
- hasFilters = true;
- }
+ if (!hasFilters) {
+ dt.columns().every(function () {
+ if (this.search()) {
+ hasFilters = true;
+ return false;
+ }
+ });
}
- // Update button text
if (this.dom.button) {
- this.dom.button.text(hasFilters ?
- this.s.opts.buttonTextActive :
- this.s.opts.buttonText
+ this.dom.button.text(
+ hasFilters ? this.s.opts.buttonTextActive : this.s.opts.buttonText
);
}
},
@@ -341,20 +339,20 @@
* @private
*/
_restoreFilterState: function () {
- let dt = this.s.dt;
- let that = this;
+ const dt = this.s.dt;
+ const filterData = this.s.filterData;
let needsRedraw = false;
dt.columns().every(function (i) {
- let column = this;
- let colName = dt.settings()[0].aoColumns[i].name;
- let title = $(column.header()).text();
- let searchVal = column.search();
+ const column = this;
+ const colName = dt.settings()[0].aoColumns[i].name;
+ const title = $(column.header()).text();
+ const searchVal = column.search();
+
+ const key = colName || title;
- // Store by column name (preferred) or fallback to title
- let key = colName || title;
if (searchVal) {
- that.s.filterData[key] = searchVal;
+ filterData[key] = searchVal;
needsRedraw = true;
}
});
@@ -385,32 +383,25 @@
let externalSearchId = dtInit.externalSearchInputId;
let initialSortOrder = (dtInit && dtInit.order) ? dtInit.order : [];
- // Clear external search
if (externalSearchId) {
$(externalSearchId).val('');
}
- // Clear the search input field
- $('#search').val('');
+ this.dom.filterRow.find('.custom-filter-input').val('');
- // Clear custom filter inputs
- $('.custom-filter-input').val('');
-
- // Clear DataTable searches
dt.search('').columns().search('');
+ dt.order(initialSortOrder).draw();
- // Clear order
- dt.order(initialSortOrder);
-
- // If we want to reset quick date range dropdown to default (last 6 months) and trigger change
- // The change event handler will reload the table, so would need to remove ajax.reload() here
- $('#quickDateRange').val($('#quickDateRange option[selected]').val()).trigger('change');
-
- // Update button state
- this._updateButtonState();
+ let $quickDateRange = $('#quickDateRange');
+ if ($quickDateRange.length) {
+ let defaultVal = $quickDateRange.find('option[selected]').val();
+ if (defaultVal) {
+ $quickDateRange.val(defaultVal).trigger('change');
+ }
+ }
- // Clear internal filter data
this.s.filterData = {};
+ this._updateButtonState();
},
/**
@@ -462,12 +453,7 @@
}
}
- // Remove filter row from DOM
- if (this.dom.filterRow) {
- this.dom.filterRow.remove();
- }
-
- // Remove reference from settings
+ this.dom.filterRow?.remove();
dt.settings()[0]._filterRow = null;
}
};
@@ -491,4 +477,22 @@
});
return DataTable.FilterRow;
+
})(jQuery);
+
+
+$(document).on('mousedown', function (e) {
+ const $popover = $('.popover.custom-popover');
+ if (!$popover.is(':visible')) return;
+
+ const $target = $(e.target);
+ const popoverId = $popover.attr('id');
+ const $trigger = $('[aria-describedby="' + popoverId + '"]');
+
+ const insidePopover = $target.closest('.popover.custom-popover').length > 0;
+ const insideButton = $trigger.length > 0 && $target.closest($trigger).length > 0;
+
+ if (!insidePopover && !insideButton) {
+ $trigger.popover('hide');
+ }
+});
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md
new file mode 100644
index 0000000000..f9218514d9
--- /dev/null
+++ b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md
@@ -0,0 +1,64 @@
+# Applicant Electoral District Update
+
+## Overview
+
+This process cross-references application electoral districts against the BC Geocoder API and generates SQL fix scripts for any mismatches.
+
+## Steps
+
+### 1. Extract Data from the Database
+
+Run `GetElectoralDistrictData.sql` against the database and save the results as a CSV file in the `data/` folder:
+
+```
+data/electoral_districts.csv
+```
+
+The query returns all applications joined with their applicant addresses, including `ApplicationId`, `ReferenceNo`, `ApplicantElectoralDistrict`, `Street`, `Street2`, `City`, and `AddressType`.
+
+### 2. Validate Electoral Districts
+
+Run `Validate-ElectoralDistricts.ps1` with the extracted CSV. This geocodes each unique address via the BC Geocoder API, looks up the electoral district from the WFS endpoint, and compares it to the value stored in the database.
+
+```powershell
+.\Validate-ElectoralDistricts.ps1 `
+ -InputCsv ".\data\electoral_districts.csv" `
+ -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" `
+ -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName="
+```
+
+This produces a validated CSV with match results:
+
+```
+data/electoral_districts_validated.csv
+```
+
+Each row includes the expected electoral district, geocoder score, and a `DistrictMatch` column (`MATCH`, `MISMATCH`, `UNKNOWN`, or `SKIPPED`).
+
+### 3. Generate SQL Fix Scripts
+
+Run `Generate-ElectoralDistrictFixes.ps1` against the validated CSV. This produces SQL UPDATE statements for mismatched rows.
+
+```powershell
+.\Generate-ElectoralDistrictFixes.ps1 `
+ -InputCsv ".\data\electoral_districts_validated.csv" `
+ -MinScore 70 `
+ -AddressType 1 `
+ -IncludeLowConfidence
+```
+
+This generates two SQL scripts:
+
+| File | Description |
+|------|-------------|
+| `electoral_districts_validated_update.sql` | High-confidence updates (score >= 70) — sets the district to the expected value |
+| `electoral_districts_validated_nullify.sql` | Low-confidence updates (score < 70) — sets the district to NULL |
+
+Both scripts use conditional updates that only apply when the current database value still matches the value at extract time, preventing overwrites of legitimate changes.
+
+### 4. Apply the Fix Scripts
+
+Review the generated SQL files, then execute them against the database:
+
+1. **Always apply the high-confidence script first** (`_update.sql`).
+2. **Optionally apply the low-confidence script** (`_nullify.sql`) if you want to clear unreliable district values.
diff --git a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1
new file mode 100644
index 0000000000..4dbe27756c
--- /dev/null
+++ b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1
@@ -0,0 +1,198 @@
+<#
+.SYNOPSIS
+ Generates PostgreSQL UPDATE statements to fix mismatched electoral districts.
+
+.DESCRIPTION
+ Reads the validated CSV output from Validate-ElectoralDistricts.ps1 and generates
+ SQL UPDATE statements for rows where:
+ - DistrictMatch is MISMATCH and GeocoderScore >= MinScore: SET to the expected district (main file)
+ - DistrictMatch is MISMATCH and GeocoderScore < MinScore: SET to NULL (separate file, opt-in)
+ - AddressType matches the specified type (1=Physical, 2=Mailing)
+
+.PARAMETER InputCsv
+ Path to the validated CSV file (output of Validate-ElectoralDistricts.ps1).
+
+.PARAMETER OutputSql
+ Path to the high-confidence output .sql file. Defaults to _update.sql.
+
+.PARAMETER MinScore
+ Minimum geocoder score to trust. Rows at or above get the expected district;
+ rows below get NULL (if -IncludeLowConfidence is set). Default: 70.
+
+.PARAMETER AddressType
+ AddressType to filter on. 1 = Physical, 2 = Mailing. Default: 1 (Physical).
+
+.PARAMETER IncludeLowConfidence
+ When set, generates a separate .sql file for low-confidence rows (score < MinScore)
+ that sets ApplicantElectoralDistrict to NULL. File is _nullify.sql.
+
+.EXAMPLE
+ .\Generate-ElectoralDistrictFixes.ps1 `
+ -InputCsv ".\data\electoral_districts_validated.csv" `
+ -MinScore 70 `
+ -AddressType 1 `
+ -IncludeLowConfidence
+#>
+param(
+ [Parameter(Mandatory)]
+ [string]$InputCsv,
+
+ [string]$OutputSql = "",
+
+ [int]$MinScore = 70,
+
+ [ValidateSet("1", "2")]
+ [string]$AddressType = "1",
+
+ [switch]$IncludeLowConfidence
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+# ── Output path defaults ──────────────────────────────────────────────
+$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv))
+$name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv)
+
+if (-not $OutputSql) {
+ $OutputSql = Join-Path $dir "${name}_update.sql"
+}
+$NullifySql = Join-Path $dir "${name}_nullify.sql"
+
+# ── Read CSV ──────────────────────────────────────────────────────────
+$data = Import-Csv -Path $InputCsv
+Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan
+
+# ── Resolve address type label ────────────────────────────────────────
+$addressTypeLabel = switch ($AddressType) {
+ "1" { "Physical" }
+ "2" { "Mailing" }
+}
+Write-Host "Filtering for AddressType: $AddressType ($addressTypeLabel)" -ForegroundColor Cyan
+
+# ── Filter all mismatches for this address type ──────────────────────
+$allMismatches = @($data | Where-Object {
+ $_.DistrictMatch -eq "MISMATCH" -and
+ $_.GeocoderScore -ne "" -and
+ $_.AddressType -eq $AddressType
+})
+
+# Split into high-confidence (update to expected) and low-confidence (set to NULL)
+$highConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -ge $MinScore })
+$lowConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -lt $MinScore })
+
+Write-Host "Found $($allMismatches.Count) total MISMATCH rows for AddressType = $AddressType ($addressTypeLabel)" -ForegroundColor Cyan
+Write-Host " High confidence (score >= $MinScore): $($highConfidence.Count) -> will SET to expected district" -ForegroundColor Green
+$lowConfMsg = if ($IncludeLowConfidence) { " -> will SET to NULL (separate file)" } else { " (skipped, use -IncludeLowConfidence to generate)" }
+Write-Host " Low confidence (score < $MinScore): $($lowConfidence.Count)$lowConfMsg" -ForegroundColor Yellow
+
+if ($highConfidence.Count -eq 0 -and (-not $IncludeLowConfidence -or $lowConfidence.Count -eq 0)) {
+ Write-Host "No rows to update. Exiting." -ForegroundColor Yellow
+ return
+}
+
+# ── Helper ───────────────────────────────────────────────────────────
+function Escape-SqlString {
+ param([string]$value)
+ return $value.Replace("'", "''")
+}
+
+# ── Generate high-confidence SQL ─────────────────────────────────────
+if ($highConfidence.Count -gt 0) {
+ $sb = [System.Text.StringBuilder]::new()
+ [void]$sb.AppendLine("-- Electoral District Fix Script (High Confidence)")
+ [void]$sb.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
+ [void]$sb.AppendLine("-- Source: $InputCsv")
+ [void]$sb.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score >= $MinScore")
+ [void]$sb.AppendLine("-- Total updates: $($highConfidence.Count)")
+ [void]$sb.AppendLine("")
+ [void]$sb.AppendLine("BEGIN;")
+ [void]$sb.AppendLine("")
+
+ foreach ($row in $highConfidence) {
+ $appId = $row.ApplicationId.Trim()
+ $parsedGuid = [System.Guid]::Empty
+ if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) {
+ Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red
+ continue
+ }
+ $appId = $parsedGuid.ToString()
+ $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim()
+ $expectedED = Escape-SqlString $row.ExpectedElectoralDistrict.Trim()
+ $score = $row.GeocoderScore
+ $refNo = $row.ReferenceNo
+
+ [void]$sb.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> '$expectedED'")
+ [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';")
+ [void]$sb.AppendLine("")
+ }
+
+ [void]$sb.AppendLine("COMMIT;")
+ $sb.ToString() | Out-File -FilePath $OutputSql -Encoding UTF8
+ Write-Host "`nHigh-confidence SQL written to: $OutputSql" -ForegroundColor Green
+}
+else {
+ Write-Host "`nNo high-confidence rows to write." -ForegroundColor DarkGray
+}
+
+# ── Generate low-confidence SQL (separate file, opt-in) ──────────────
+if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) {
+ $sbNull = [System.Text.StringBuilder]::new()
+ [void]$sbNull.AppendLine("-- Electoral District Nullify Script (Low Confidence)")
+ [void]$sbNull.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
+ [void]$sbNull.AppendLine("-- Source: $InputCsv")
+ [void]$sbNull.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score < $MinScore")
+ [void]$sbNull.AppendLine("-- Total updates: $($lowConfidence.Count)")
+ [void]$sbNull.AppendLine("-- Action: SET ApplicantElectoralDistrict = NULL (unreliable geocoding)")
+ [void]$sbNull.AppendLine("")
+ [void]$sbNull.AppendLine("BEGIN;")
+ [void]$sbNull.AppendLine("")
+
+ foreach ($row in $lowConfidence) {
+ $appId = $row.ApplicationId.Trim()
+ $parsedGuid = [System.Guid]::Empty
+ if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) {
+ Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red
+ continue
+ }
+ $appId = $parsedGuid.ToString()
+ $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim()
+ $score = $row.GeocoderScore
+ $refNo = $row.ReferenceNo
+
+ [void]$sbNull.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> NULL")
+ [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';")
+ [void]$sbNull.AppendLine("")
+ }
+
+ [void]$sbNull.AppendLine("COMMIT;")
+ $sbNull.ToString() | Out-File -FilePath $NullifySql -Encoding UTF8
+ Write-Host "Low-confidence SQL written to: $NullifySql" -ForegroundColor Yellow
+}
+elseif ($IncludeLowConfidence) {
+ Write-Host "`nNo low-confidence rows to write." -ForegroundColor DarkGray
+}
+
+# ── Summary ───────────────────────────────────────────────────────────
+Write-Host ""
+Write-Host "=============================" -ForegroundColor White
+Write-Host " SQL Generation Summary" -ForegroundColor White
+Write-Host "=============================" -ForegroundColor White
+Write-Host "Address type: $AddressType ($addressTypeLabel)"
+Write-Host "Score threshold: $MinScore"
+Write-Host "-----------------------------"
+Write-Host "High confidence: $($highConfidence.Count) (SET to expected)" -ForegroundColor Green
+Write-Host "Low confidence: $($lowConfidence.Count) (SET to NULL)" -ForegroundColor Yellow
+Write-Host "Total mismatches: $($allMismatches.Count)"
+Write-Host "-----------------------------"
+if ($highConfidence.Count -gt 0) {
+ Write-Host "Update file: $OutputSql" -ForegroundColor White
+}
+if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) {
+ Write-Host "Nullify file: $NullifySql" -ForegroundColor White
+}
+elseif ($lowConfidence.Count -gt 0) {
+ Write-Host "Nullify file: (not generated - use -IncludeLowConfidence)" -ForegroundColor DarkGray
+}
+Write-Host ""
+Write-Host "Review the SQL file(s), then execute against your PostgreSQL database." -ForegroundColor Yellow
diff --git a/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql
new file mode 100644
index 0000000000..1fa5aa3a0b
--- /dev/null
+++ b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql
@@ -0,0 +1,12 @@
+SELECT ap."CreationTime",
+ap."Id" as "ApplicationId",
+ap."ReferenceNo",
+ap."ApplicantElectoralDistrict",
+ad."Id" as "AddressId",
+ad."Street",
+ad."Street2",
+ad."City",
+ad."AddressType"
+FROM public."Applications" ap
+LEFT JOIN "ApplicantAddresses" ad on ad."ApplicationId" = ap."Id"
+ORDER BY ap."CreationTime" DESC
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1
new file mode 100644
index 0000000000..5889bf4c2a
--- /dev/null
+++ b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1
@@ -0,0 +1,310 @@
+<#
+.SYNOPSIS
+ Cross-references electoral districts in a CSV against the BC Geocoder API.
+
+.DESCRIPTION
+ Reads a CSV with address data, looks up each unique address via the BC Geocoder
+ location API to get coordinates, then queries the electoral district WFS endpoint.
+ Deduplicates addresses so identical Street+Street2+City combinations are only
+ looked up once. Handles rate limiting with exponential backoff.
+ Outputs a new CSV with all original columns plus the expected electoral district.
+
+.PARAMETER InputCsv
+ Path to the input CSV file.
+
+.PARAMETER OutputCsv
+ Path to the output CSV file. Defaults to _validated.csv.
+
+.PARAMETER GeocoderLocationBase
+ Base URL for the geocoder location API (GEOCODER_LOCATION_API_BASE).
+ Example: https://geocoder.api.gov.bc.ca
+
+.PARAMETER GeocoderApiBase
+ Base URL for the geocoder WFS API (GEOCODER_API_BASE).
+ Example: https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=
+
+.PARAMETER InitialDelayMs
+ Initial delay between API calls in milliseconds. Default: 250.
+
+.EXAMPLE
+ .\Validate-ElectoralDistricts.ps1 `
+ -InputCsv ".\data\electoral_districts.csv" `
+ -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" `
+ -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName="
+#>
+param(
+ [Parameter(Mandatory)]
+ [string]$InputCsv,
+
+ [string]$OutputCsv = "",
+
+ [Parameter(Mandatory)]
+ [string]$GeocoderLocationBase,
+
+ [Parameter(Mandatory)]
+ [string]$GeocoderApiBase,
+
+ [int]$InitialDelayMs = 100
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+# ── Output path defaults ──────────────────────────────────────────────
+if (-not $OutputCsv) {
+ $dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv))
+ $name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv)
+ $OutputCsv = Join-Path $dir "${name}_validated.csv"
+}
+
+# ── Read CSV ──────────────────────────────────────────────────────────
+$data = Import-Csv -Path $InputCsv
+Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan
+
+# ── Helpers ───────────────────────────────────────────────────────────
+function Test-NullOrEmpty {
+ param([string]$value)
+ return [string]::IsNullOrWhiteSpace($value) -or $value.Trim() -eq 'NULL'
+}
+
+function Get-CleanValue {
+ param([string]$value)
+ if (Test-NullOrEmpty $value) { return "" }
+ return $value.Trim()
+}
+
+function Get-AddressKey {
+ param($row)
+ $street = Get-CleanValue $row.Street
+ $street2 = Get-CleanValue $row.Street2
+ $city = Get-CleanValue $row.City
+ return "$street|$street2|$city".ToLowerInvariant()
+}
+
+function Build-AddressString {
+ param($street, $street2, $city)
+ $parts = @($street, $street2, $city) | ForEach-Object { Get-CleanValue $_ } | Where-Object { $_ -ne "" }
+ return ($parts -join ", ")
+}
+
+function Get-AddressTypeName {
+ param([string]$value)
+ $clean = Get-CleanValue $value
+ switch ($clean) {
+ "1" { return "Physical" }
+ "2" { return "Mailing" }
+ default { return "" }
+ }
+}
+
+# ── Deduplicate addresses ────────────────────────────────────────────
+$addressLookup = [ordered]@{}
+$skippedCount = 0
+
+foreach ($row in $data) {
+ $street = Get-CleanValue $row.Street
+ $street2 = Get-CleanValue $row.Street2
+ $city = Get-CleanValue $row.City
+
+ # Skip rows where both street fields and city are empty/NULL
+ if ($street -eq "" -and $street2 -eq "" -and $city -eq "") {
+ $skippedCount++
+ continue
+ }
+
+ $key = Get-AddressKey $row
+ if (-not $addressLookup.Contains($key)) {
+ $addressLookup[$key] = @{
+ Street = $row.Street
+ Street2 = $row.Street2
+ City = $row.City
+ ExpectedED = $null
+ Score = $null
+ FullAddress = $null
+ Error = $null
+ }
+ }
+}
+
+$uniqueCount = $addressLookup.Count
+Write-Host "Unique addresses to look up: $uniqueCount (skipped $skippedCount rows with empty address)" -ForegroundColor Cyan
+
+# ── Rate-limited HTTP caller with exponential backoff ─────────────────
+$script:currentDelayMs = $InitialDelayMs
+$maxDelayMs = 30000
+$minDelayMs = 100
+
+function Invoke-GeocoderRequest {
+ param(
+ [string]$Uri,
+ [int]$MaxRetries = 6
+ )
+
+ for ($attempt = 1; $attempt -le ($MaxRetries + 1); $attempt++) {
+ try {
+ $response = Invoke-RestMethod -Uri $Uri -Method Get -ErrorAction Stop
+
+ # On success, gently reduce the inter-call delay
+ $script:currentDelayMs = [Math]::Max($minDelayMs, [int]([Math]::Floor($script:currentDelayMs * 0.95)))
+
+ return $response
+ }
+ catch {
+ $statusCode = 0
+ if ($_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ }
+
+ if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -le $MaxRetries) {
+ # Exponential backoff
+ $script:currentDelayMs = [Math]::Min($maxDelayMs, $script:currentDelayMs * 2)
+ Write-Host " Rate limited (HTTP $statusCode). Backing off $($script:currentDelayMs)ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow
+ Start-Sleep -Milliseconds $script:currentDelayMs
+ }
+ else {
+ throw
+ }
+ }
+ }
+}
+
+# ── Process each unique address ───────────────────────────────────────
+$processed = 0
+$errorCount = 0
+
+# Electoral district WFS parameters (from appsettings.json Geocoder:ElectoralDistrict)
+$edFeature = "pub:WHSE_ADMIN_BOUNDARIES.EBC_PROV_ELECTORAL_DIST_SVW"
+$edProperty = "ED_NAME"
+$edQueryType = "SHAPE"
+
+foreach ($entry in $addressLookup.GetEnumerator()) {
+ $processed++
+ $addr = $entry.Value
+ $addressString = Build-AddressString $addr.Street $addr.Street2 $addr.City
+
+ Write-Host "[$processed/$uniqueCount] $addressString" -ForegroundColor Cyan
+
+ try {
+ # Step 1: Geocode address → coordinates
+ $encodedAddress = [System.Uri]::EscapeDataString($addressString)
+ $locationUri = "$GeocoderLocationBase/addresses.json?outputSRS=3005&addressString=$encodedAddress"
+
+ Start-Sleep -Milliseconds $script:currentDelayMs
+ $locationResult = Invoke-GeocoderRequest -Uri $locationUri
+
+ if (-not $locationResult.features -or $locationResult.features.Count -eq 0) {
+ Write-Host " No location results" -ForegroundColor Yellow
+ $addr.Error = "No location results"
+ $errorCount++
+ continue
+ }
+
+ $coords = $locationResult.features[0].geometry.coordinates
+ $coordX = $coords[0] # EPSG:3005 easting (mirrors C# ResultMapper: coordinates[0])
+ $coordY = $coords[1] # EPSG:3005 northing (mirrors C# ResultMapper: coordinates[1])
+ $score = $locationResult.features[0].properties.score
+ $fullAddr = $locationResult.features[0].properties.fullAddress
+
+ $addr.Score = $score
+ $addr.FullAddress = $fullAddr
+ Write-Host " Resolved: $fullAddr (score: $score)" -ForegroundColor DarkGray
+
+ # Step 2: Look up electoral district from coordinates
+ $edUri = "${GeocoderApiBase}${edFeature}" +
+ "&srsname=EPSG:3005" +
+ "&propertyName=${edProperty}" +
+ "&outputFormat=application/json" +
+ "&cql_filter=INTERSECTS(${edQueryType},POINT($coordX $coordY))"
+
+ Start-Sleep -Milliseconds $script:currentDelayMs
+ $edResult = Invoke-GeocoderRequest -Uri $edUri
+
+ if (-not $edResult.features -or $edResult.features.Count -eq 0) {
+ Write-Host " No electoral district found for coordinates" -ForegroundColor Yellow
+ $addr.ExpectedED = ""
+ $addr.Error = "No electoral district for coordinates ($coordX, $coordY)"
+ $errorCount++
+ continue
+ }
+
+ $expectedED = $edResult.features[0].properties.ED_NAME
+ $addr.ExpectedED = $expectedED
+ Write-Host " Electoral District: $expectedED" -ForegroundColor Green
+ }
+ catch {
+ Write-Host " ERROR: $_" -ForegroundColor Red
+ $addr.Error = $_.ToString()
+ $errorCount++
+ }
+}
+
+# ── Build output CSV ──────────────────────────────────────────────────
+Write-Host "`nBuilding output CSV..." -ForegroundColor Cyan
+
+$output = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+foreach ($row in $data) {
+ $key = Get-AddressKey $row
+
+ $expectedED = ""
+ $lookupError = ""
+ $geoScore = ""
+ $geoFullAddr = ""
+ $matchResult = "SKIPPED"
+
+ if ($addressLookup.Contains($key)) {
+ $lookup = $addressLookup[$key]
+ $expectedED = if ($lookup.ExpectedED) { $lookup.ExpectedED } else { "" }
+ $lookupError = if ($lookup.Error) { $lookup.Error } else { "" }
+ $geoScore = if ($lookup.Score -ne $null) { $lookup.Score } else { "" }
+ $geoFullAddr = if ($lookup.FullAddress) { $lookup.FullAddress } else { "" }
+
+ if ($expectedED -and $row.ApplicantElectoralDistrict) {
+ if ($expectedED.Trim() -eq $row.ApplicantElectoralDistrict.Trim()) {
+ $matchResult = "MATCH"
+ }
+ else {
+ $matchResult = "MISMATCH"
+ }
+ }
+ else {
+ $matchResult = "UNKNOWN"
+ }
+ }
+
+ $outRow = [ordered]@{}
+ foreach ($prop in $row.PSObject.Properties) {
+ $outRow[$prop.Name] = $prop.Value
+ }
+ $outRow["AddressTypeName"] = Get-AddressTypeName $row.AddressType
+ $outRow["ExpectedElectoralDistrict"] = $expectedED
+ $outRow["DistrictMatch"] = $matchResult
+ $outRow["GeocoderScore"] = $geoScore
+ $outRow["GeocoderFullAddress"] = $geoFullAddr
+ $outRow["LookupError"] = $lookupError
+
+ $output.Add([PSCustomObject]$outRow)
+}
+
+$output | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
+
+# ── Summary ───────────────────────────────────────────────────────────
+$matchCount = @($output | Where-Object { $_.DistrictMatch -eq "MATCH" }).Count
+$mismatchCount = @($output | Where-Object { $_.DistrictMatch -eq "MISMATCH" }).Count
+$unknownCount = @($output | Where-Object { $_.DistrictMatch -eq "UNKNOWN" }).Count
+$skippedRows = @($output | Where-Object { $_.DistrictMatch -eq "SKIPPED" }).Count
+
+Write-Host ""
+Write-Host "=============================" -ForegroundColor White
+Write-Host " Electoral District Audit" -ForegroundColor White
+Write-Host "=============================" -ForegroundColor White
+Write-Host "Total rows: $($data.Count)"
+Write-Host "Unique addresses: $uniqueCount"
+Write-Host "API errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { "Red" } else { "Green" })
+Write-Host "-----------------------------"
+Write-Host "MATCH: $matchCount" -ForegroundColor Green
+Write-Host "MISMATCH: $mismatchCount" -ForegroundColor $(if ($mismatchCount -gt 0) { "Red" } else { "Green" })
+Write-Host "UNKNOWN: $unknownCount" -ForegroundColor Yellow
+Write-Host "SKIPPED: $skippedRows" -ForegroundColor DarkGray
+Write-Host "-----------------------------"
+Write-Host "Output: $OutputCsv" -ForegroundColor White
diff --git a/applications/Unity.GrantManager/sonar-project.properties b/applications/Unity.GrantManager/sonar-project.properties
new file mode 100644
index 0000000000..358970de64
--- /dev/null
+++ b/applications/Unity.GrantManager/sonar-project.properties
@@ -0,0 +1,30 @@
+# SonarCloud configuration for Unity Grant Manager
+sonar.projectKey=bcgov_Unity
+sonar.organization=bcgov-sonarcloud
+sonar.host.url=https://sonarcloud.io
+
+# Project metadata
+sonar.projectName=Unity
+sonar.projectDescription=Grant management application for the Province of British Columbia
+
+# Source code settings
+sonar.sources=src,modules
+sonar.tests=test
+
+# Quality gate settings (from Azure SonarQube)
+sonar.qualitygate.wait=true
+
+# SonarQube Exclusions (from existing Azure configuration)
+sonar.exclusions=src/Unity.GrantManager.EntityFrameworkCore/Migrations/**,modules/Unity.Payments/src/Unity.Payments.Web/Pages/BatchPayments/Index.js,**/bin/**,**/obj/**,**/wwwroot/lib/**,**/*.Designer.cs,**/node_modules/**
+
+# Test exclusions
+sonar.test.exclusions=**/bin/**,**/obj/**
+
+# Coverage analysis explicitly disabled (excludes all files from coverage)
+sonar.coverage.exclusions=**/*
+
+# Code duplication exclusions (from existing Azure configuration + all files)
+sonar.cpd.exclusions=**/*.aspx,**/*.aspx.designer.cs,**/*.cshtml,**/*.html,**/*.js,**/*
+
+# SCM settings
+sonar.scm.provider=git
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs
deleted file mode 100644
index c84a3d4793..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Unity.GrantManager.AI.Models
-{
- public class ApplicationAnalysisRecommendation
- {
- [JsonPropertyName(AIJsonKeys.Decision)]
- public string? Decision { get; set; }
-
- [JsonPropertyName(AIJsonKeys.Rationale)]
- public string? Rationale { get; set; }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs
index 3db1d7dcd6..779b4f7148 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs
@@ -14,11 +14,14 @@ namespace Unity.GrantManager.ApplicantProfile;
public interface IApplicantProfileContactService
{
///
- /// Retrieves contacts linked to the specified applicant profile.
- ///
- /// The unique identifier of the applicant profile.
- /// A list of with IsEditable set to true.
- Task> GetProfileContactsAsync(Guid profileId);
+ /// Retrieves contacts linked to the applicant profile by resolving applicant IDs from
+ /// form submissions that match the given OIDC subject. When the subject resolves to a
+ /// single applicant ID the returned contacts are editable; when multiple applicant IDs
+ /// are found they are read-only.
+ ///
+ /// The pre-normalized OIDC subject identifier used to resolve applicant IDs from submissions.
+ /// A list of with IsEditable reflecting the applicant-count rule.
+ Task> GetApplicantContactsAsync(string subject);
///
/// Retrieves application contacts associated with submissions matching the given OIDC subject.
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs
index 112eed817b..ac0ec9b771 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs
@@ -18,5 +18,6 @@ public class ContactInfoItemDto
public bool IsEditable { get; set; }
public Guid? ApplicationId { get; set; }
public string? ReferenceNo { get; set; }
+ public DateTime? CreationTime { get; set; }
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs
index f5ef23aac4..4de11bf387 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs
@@ -5,6 +5,8 @@ namespace Unity.GrantManager.ApplicantProfile.ProfileData
public class OrgInfoItemDto
{
public Guid Id { get; set; }
+ public string? ApplicantRefId { get; set; }
+ public string? ApplicantName { get; set; }
public string? OrgName { get; set; }
public string? OrganizationType { get; set; }
public string? OrgNumber { get; set; }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs
index 9794ad422a..e9e5b5c24a 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
namespace Unity.GrantManager.Applicants
{
@@ -6,5 +7,6 @@ public class ApplicantTenantDto
{
public Guid TenantId { get; set; }
public string TenantName { get; set; } = string.Empty;
+ public Dictionary Metadata { get; set; } = [];
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantMetadataKeys.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantMetadataKeys.cs
new file mode 100644
index 0000000000..e8b08e9fdd
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantMetadataKeys.cs
@@ -0,0 +1,11 @@
+namespace Unity.GrantManager.Applicants
+{
+ ///
+ /// Well-known keys for the dictionary.
+ /// Shared with external consumers (e.g. Applicant Portal plugins).
+ ///
+ public static class ApplicantTenantMetadataKeys
+ {
+ public const string DefaultFromAddress = "DefaultFromAddress";
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/AIConfigDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/AIConfigDto.cs
new file mode 100644
index 0000000000..5389b7a5b5
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/AIConfigDto.cs
@@ -0,0 +1,8 @@
+namespace Unity.GrantManager.ApplicationForms
+{
+ public class AIConfigDto
+ {
+ public bool AutomaticallyGenerateAIAnalysis { get; set; }
+ public bool ManuallyInitiateAIAnalysis { get; set; }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs
index 4e70a8880c..0351e6aecb 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs
@@ -24,12 +24,14 @@ public class ApplicationFormDto : EntityDto
public bool PreventPayment { get; set; }
public Guid AccountCodingId { get; set; }
public bool RenderFormIoToHtml { get; set; }
- public Guid? ScoresheetId { get; set; }
- public Guid? TenantId { get; set; }
- public bool IsDirectApproval { get; set; }
- public AddressType? ElectoralDistrictAddressType { get; set; }
- public string? Prefix { get; set; }
- public SuffixConfigType? SuffixType { get; set; }
- public int? DefaultPaymentGroup { get; set; }
- }
-}
+ public Guid? ScoresheetId { get; set; }
+ public Guid? TenantId { get; set; }
+ public bool IsDirectApproval { get; set; }
+ public bool AutomaticallyGenerateAIAnalysis { get; set; }
+ public bool ManuallyInitiateAIAnalysis { get; set; }
+ public AddressType? ElectoralDistrictAddressType { get; set; }
+ public string? Prefix { get; set; }
+ public SuffixConfigType? SuffixType { get; set; }
+ public int? DefaultPaymentGroup { get; set; }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs
index 425ca81bdf..5de7519628 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs
@@ -18,6 +18,7 @@ public interface IApplicationFormAppService : ICrudAppService<
Task> GetVersionsAsync(Guid id);
Task> GetPublishedVersionsAsync(Guid id);
Task PatchOtherConfig(Guid id, OtherConfigDto config);
+ Task PatchAiConfig(Guid id, AIConfigDto config);
Task GetFormPaymentApprovalThresholdByApplicationIdAsync(Guid applicationId);
Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId);
Task GetFormDetailsByApplicationIdAsync(Guid applicationId);
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs
index 1b638f3bc3..f41512c411 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs
@@ -9,6 +9,7 @@ public interface IAttachmentAppService : IApplicationService
{
Task> GetApplicationAsync(Guid applicationId);
Task> GetAssessmentAsync(Guid assessmentId);
+ Task> GetApplicantAsync(Guid applicantId);
Task ResyncSubmissionAttachmentsAsync(Guid applicationId);
Task> GetAttachmentsAsync(AttachmentParametersDto attachmentParametersDto);
Task GetAttachmentMetadataAsync(AttachmentType attachmentType, Guid attachmentId);
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentPreviewAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentPreviewAppService.cs
new file mode 100644
index 0000000000..120b434209
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentPreviewAppService.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Threading.Tasks;
+using Volo.Abp.Application.Services;
+
+namespace Unity.GrantManager.Attachments;
+
+public interface IAttachmentPreviewAppService : IApplicationService
+{
+ Task GetOrCreatePreviewPdfAsync(AttachmentType attachmentType, Guid ownerId, string fileName);
+ Task GetOrCreateChefsPreviewPdfAsync(Guid formSubmissionId, Guid chefsFileId, string fileName, byte[] originalContent);
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs
deleted file mode 100644
index 65589ed840..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using Volo.Abp.Application.Services;
-
-namespace Unity.GrantManager.Attachments;
-
-public interface IAttachmentSummaryAppService : IApplicationService
-{
- Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null);
- Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null);
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ILibreOfficeConversionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ILibreOfficeConversionService.cs
new file mode 100644
index 0000000000..7ecc034769
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ILibreOfficeConversionService.cs
@@ -0,0 +1,9 @@
+using System.Threading.Tasks;
+
+namespace Unity.GrantManager.Attachments;
+
+public interface ILibreOfficeConversionService
+{
+ bool IsInstalled();
+ Task ConvertToPdfAsync(byte[] fileContent, string fileName);
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs
similarity index 73%
rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs
rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs
index 7829f8028b..d1f71301fa 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs
@@ -1,10 +1,8 @@
using System;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
public class GenerateApplicationAnalysisBackgroundJobArgs
{
public Guid ApplicationId { get; set; }
- public string? PromptVersion { get; set; }
public Guid? TenantId { get; set; }
-}
+ public string? PromptVersion { get; set; }
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs
similarity index 73%
rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs
rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs
index 234f8ec706..06b0d0cd97 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs
@@ -1,10 +1,8 @@
using System;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
public class GenerateApplicationScoringBackgroundJobArgs
{
public Guid ApplicationId { get; set; }
- public string? PromptVersion { get; set; }
public Guid? TenantId { get; set; }
-}
+ public string? PromptVersion { get; set; }
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs
similarity index 76%
rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs
rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs
index 7836e5abe3..bf87e59783 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs
@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
public class GenerateAttachmentSummaryBackgroundJobArgs
{
public List AttachmentIds { get; set; } = [];
- public string? PromptVersion { get; set; }
public Guid? TenantId { get; set; }
-}
+ public string? PromptVersion { get; set; }
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs
similarity index 55%
rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs
rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs
index d320bf8316..9cfc51304c 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJobArgs.cs
@@ -1,10 +1,8 @@
using System;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
-public class GenerateContentBackgroundJobArgs
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+public class RunApplicationAIPipelineJobArgs
{
public Guid ApplicationId { get; set; }
- public string? PromptVersion { get; set; }
public Guid? TenantId { get; set; }
+ public string? PromptVersion { get; set; }
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
index 1dda8c6696..e1883fbe35 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
-using Unity.GrantManager.AI.Responses;
+using Unity.AI.Responses;
using Unity.GrantManager.ApplicationForms;
using Volo.Abp.Application.Dtos;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs
index 9a4381db84..0d9267bc8c 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs
@@ -10,4 +10,5 @@ public class GrantApplicationLiteDto : AuditedEntityDto
public string ApplicantName { get; set; } = string.Empty;
public string OrganizationName { get; set; } = string.Empty;
public string UnityApplicantId { get; set; } = string.Empty;
+ public string UnityApplicationId { get; set; } = string.Empty;
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationStatusService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationStatusService.cs
index b28cb95405..fb8dafc378 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationStatusService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationStatusService.cs
@@ -7,4 +7,6 @@ namespace Unity.GrantManager.GrantApplications;
public interface IApplicationStatusService : IApplicationService
{
Task> GetListAsync();
+
+ Task UpdateExternalStatusLabelsAsync(UpdateApplicationStatusExternalLabelsDto input);
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs
index 2491deb1c9..953a10a65d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs
@@ -19,8 +19,8 @@ public interface IGrantApplicationAppService
Task GetAsync(Guid id);
Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction);
Task GetAccountCodingIdFromFormIdAsync(Guid formId);
- Task HideAIAnalysisItemAsync(Guid applicationId, string itemId);
- Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId);
+ Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId);
+ Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId);
Task> GetListAsync(GrantApplicationListInputDto input);
Task IsApplicantRedStopAsync(Guid applicationId);
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelDto.cs
new file mode 100644
index 0000000000..28cb19e244
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelDto.cs
@@ -0,0 +1,14 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace Unity.GrantManager.GrantApplications;
+
+public class UpdateApplicationStatusExternalLabelDto
+{
+ [Required]
+ public Guid Id { get; set; }
+
+ [Required]
+ [StringLength(ApplicationStatusConsts.MaxNameLength, MinimumLength = 1)]
+ public string ExternalStatus { get; set; } = string.Empty;
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelsDto.cs
new file mode 100644
index 0000000000..a622b32d6a
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/UpdateApplicationStatusExternalLabelsDto.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Unity.GrantManager.GrantApplications;
+
+public class UpdateApplicationStatusExternalLabelsDto
+{
+ [Required]
+ public List Statuses { get; set; } = [];
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/IUserTenantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/IUserTenantAppService.cs
index 668421df6b..5ac06a0aa1 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/IUserTenantAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Identity/IUserTenantAppService.cs
@@ -8,5 +8,6 @@ public interface IUserTenantAppService : IApplicationService
{
Task GetUserAdminAccountAsync(string oidcSub);
Task> GetUserTenantsAsync(string oidcSub);
+ Task> GetListAsync();
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/GetSubmissionsListInput.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/GetSubmissionsListInput.cs
new file mode 100644
index 0000000000..723c0cd41e
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/GetSubmissionsListInput.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Unity.GrantManager.Intakes;
+
+[Serializable]
+public class GetSubmissionsListInput
+{
+ public bool ReturnAllSubmissions { get; set; } = true;
+
+ public string? TenantName { get; set; }
+
+ public DateTime? DateFrom { get; set; }
+
+ public DateTime? DateTo { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/ISubmissionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/ISubmissionAppService.cs
index 8962ab097e..618cf756ac 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/ISubmissionAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Intakes/ISubmissionAppService.cs
@@ -14,10 +14,9 @@ public interface ISubmissionAppService : IApplicationService
///
/// List submissions for a form
///
- /// ID of the form
- /// A list of form fields to search on. Refer to the related `versions/{formVersionId}/fields` endpoint for a list of valid values to query for. The list should be comma separated.
+ /// Filter parameters including tenant, date range, and whether to include all submissions.
/// List<FormSubmissionSummary>
- Task> GetSubmissionsList(bool allSubmissions);
+ Task> GetSubmissionsListAsync(GetSubmissionsListInput input);
///
/// Get a form submission
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj
index 4f76fb8d6a..7df55afd60 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj
@@ -1,35 +1,32 @@
-
-
+
-
net9.0
enable
Unity.GrantManager
-
+
-
-
+
+
-
-
-
-
+
+
+
+
-
- **/Assessments/AssessmentListItemDto.cs, **/Assessments/AssessmentScoresDto.cs
-
+
+ **/Assessments/AssessmentListItemDto.cs, **/Assessments/AssessmentScoresDto.cs
+
-
-
+
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs
deleted file mode 100644
index e9bd6ee84b..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using Microsoft.Extensions.Logging;
-using System.Threading.Tasks;
-using Unity.GrantManager.AI.Operations;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.DependencyInjection;
-using Volo.Abp.MultiTenancy;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
-public class GenerateApplicationAnalysisBackgroundJob(
- IApplicationAnalysisService applicationAnalysisService,
- ICurrentTenant currentTenant,
- ILogger logger) : AsyncBackgroundJob, ITransientDependency
-{
- public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args)
- {
- using (currentTenant.Change(args.TenantId))
- {
- logger.LogInformation("Executing AI application analysis background job for application {ApplicationId}.", args.ApplicationId);
- await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion);
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs
deleted file mode 100644
index a445993a68..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using Microsoft.Extensions.Logging;
-using System;
-using System.Threading.Tasks;
-using Unity.GrantManager.AI.Operations;
-using Unity.GrantManager.Intakes.Events;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.DependencyInjection;
-using Volo.Abp.EventBus.Local;
-using Volo.Abp.MultiTenancy;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
-public class GenerateApplicationScoringBackgroundJob(
- IApplicationScoringService applicationScoringService,
- ILocalEventBus localEventBus,
- ICurrentTenant currentTenant,
- ILogger logger) : AsyncBackgroundJob, ITransientDependency
-{
- public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args)
- {
- using (currentTenant.Change(args.TenantId))
- {
- logger.LogInformation("Executing AI application scoring background job for application {ApplicationId}.", args.ApplicationId);
-
- var result = await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion);
- if (!string.Equals(result, "{}", StringComparison.Ordinal))
- {
- await localEventBus.PublishAsync(new AIApplicationScoringGeneratedEvent
- {
- ApplicationId = args.ApplicationId
- });
- }
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs
deleted file mode 100644
index ed6a1ebd91..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Microsoft.Extensions.Logging;
-using System.Threading.Tasks;
-using Unity.GrantManager.AI.Operations;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.DependencyInjection;
-using Volo.Abp.MultiTenancy;
-
-namespace Unity.GrantManager.AI.BackgroundJobs;
-
-public class GenerateAttachmentSummaryBackgroundJob(
- IAttachmentSummaryService attachmentSummaryService,
- ICurrentTenant currentTenant,
- ILogger logger) : AsyncBackgroundJob, ITransientDependency
-{
- public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args)
- {
- using (currentTenant.Change(args.TenantId))
- {
- logger.LogInformation(
- "Executing AI attachment summary background job for {AttachmentCount} attachment(s).",
- args.AttachmentIds.Count);
-
- await attachmentSummaryService.GenerateAndSaveAsync(args.AttachmentIds, args.PromptVersion);
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs
deleted file mode 100644
index 6335c3b765..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs
+++ /dev/null
@@ -1,1488 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Text;
-using System.Text.Json;
-using System.Threading.Tasks;
-using Unity.GrantManager.AI.Extraction;
-using Unity.GrantManager.AI.Models;
-using Unity.GrantManager.AI.Prompts;
-using Unity.GrantManager.AI.Requests;
-using Unity.GrantManager.AI.Responses;
-using Volo.Abp.DependencyInjection;
-using Volo.Abp.MultiTenancy;
-
-namespace Unity.GrantManager.AI.Runtime
-{
- [ExposeServices(typeof(IAIService))]
- public class OpenAIRuntimeService : IAIService, ITransientDependency
- {
- private readonly HttpClient _httpClient;
- private readonly IConfiguration _configuration;
- private readonly ILogger _logger;
- private readonly ITextExtractionService _textExtractionService;
- private readonly ICurrentTenant _currentTenant;
- private readonly IHostEnvironment _hostEnvironment;
- private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis;
- private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary;
- private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring;
- private const string PromptVersionV0 = "v0";
- private const string PromptVersionV1 = "v1";
- private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions");
- private const string ApplicationAnalysisSystemTemplateName = "application-analysis.system";
- private const string ApplicationAnalysisUserTemplateName = "application-analysis.user";
- private const string AttachmentSummarySystemTemplateName = "attachment-summary.system";
- private const string AttachmentSummaryUserTemplateName = "attachment-summary.user";
- private const string ApplicationScoringSystemTemplateName = "application-scoring.system";
- private const string ApplicationScoringUserTemplateName = "application-scoring.user";
- private const string AIServiceNotConfiguredMessage = "AI service not available - service not configured.";
- private const string AIServiceTemporarilyUnavailableMessage = "AI request failed - service temporarily unavailable.";
- private const string AIRequestFailedRetryMessage = "AI request failed - please try again later.";
- private const int MaxAiAttempts = 3;
- private const string DefaultMaxTokensParameterName = "max_completion_tokens";
- private const string LegacyMaxTokensParameterName = "max_tokens";
- private const string DefaultProviderName = "OpenAI";
- private const int DefaultCompletionTokens = 2000;
- private const int DefaultAttachmentSummaryCompletionTokens = 2000;
- private const int DefaultApplicationAnalysisCompletionTokens = 4000;
- private const int DefaultApplicationScoringCompletionTokens = 8000;
-
- private int AttachmentSummaryCompletionTokens => ResolveCompletionTokens(AttachmentSummaryPromptType, DefaultAttachmentSummaryCompletionTokens);
- private int ApplicationAnalysisCompletionTokens => ResolveCompletionTokens(ApplicationAnalysisPromptType, DefaultApplicationAnalysisCompletionTokens);
- private int ApplicationScoringCompletionTokens => ResolveCompletionTokens(ApplicationScoringPromptType, DefaultApplicationScoringCompletionTokens);
- private readonly string MissingApiKeyMessage = "OpenAI API key is not configured";
-
- // Optional local debugging sink for prompt payload logs to a local file.
- // Not intended for deployed/shared environments.
- private bool IsPromptFileLoggingEnabled => _configuration.GetValue("Azure:Logging:EnablePromptFileLog") ?? false;
- private const string PromptLogDirectoryName = "logs";
- private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log";
-
- private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true };
-
- private static readonly Dictionary PromptProfiles =
- new Dictionary(StringComparer.OrdinalIgnoreCase)
- {
- [PromptVersionV0] = PromptVersionV0,
- [PromptVersionV1] = PromptVersionV1
- };
- private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase);
-
- public OpenAIRuntimeService(
- HttpClient httpClient,
- IConfiguration configuration,
- ILogger logger,
- ITextExtractionService textExtractionService,
- ICurrentTenant currentTenant,
- IHostEnvironment hostEnvironment)
- {
- _httpClient = httpClient;
- _configuration = configuration;
- _logger = logger;
- _textExtractionService = textExtractionService;
- _currentTenant = currentTenant;
- _hostEnvironment = hostEnvironment;
- }
-
- public Task IsAvailableAsync()
- {
- if (string.IsNullOrEmpty(ResolveApiKey()))
- {
- _logger.LogWarning("Error: {Message}", MissingApiKeyMessage);
- return Task.FromResult(false);
- }
-
- return Task.FromResult(true);
- }
-
- public async Task GenerateCompletionAsync(AICompletionRequest request)
- {
- var result = await GenerateWithRetryAsync(
- () => GenerateSummaryAsync(
- request?.UserPrompt ?? string.Empty,
- null,
- request?.MaxTokens ?? DefaultCompletionTokens,
- request?.Temperature),
- AIProviderPayloadValidator.IsValidAttachmentSummaryText,
- "completion");
- return new AICompletionResponse { Content = ResolveNarrativeContent(result) };
- }
-
- public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request)
- {
- ArgumentNullException.ThrowIfNull(request);
- var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType));
- var data = JsonSerializer.Serialize(request.Data, JsonLogOptions);
- var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions);
-
- 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
[ExposeServices(typeof(IApplicantProfileDataProvider))]
public class AddressInfoDataProvider(
@@ -56,7 +57,14 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ
from submission in matchingSubmissions
join address in addressesQuery on submission.ApplicationId equals address.ApplicationId
join application in applicationsQuery on address.ApplicationId equals application.Id
- select new { address, address.CreationTime, application.ReferenceNo, IsFromApplicantPath = false, address.ApplicantId };
+ select new
+ {
+ address,
+ address.CreationTime,
+ application.ReferenceNo,
+ IsFromApplicantPath = false,
+ address.ApplicantId
+ };
// Addresses linked via ApplicantId — conditionally editable
var byApplicantId =
@@ -64,8 +72,15 @@ from submission in matchingSubmissions
join address in addressesQuery on submission.ApplicantId equals address.ApplicantId
join application in applicationsQuery on address.ApplicationId equals application.Id into apps
from application in apps.DefaultIfEmpty()
- select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsFromApplicantPath = true, address.ApplicantId };
-
+ select new
+ {
+ address,
+ address.CreationTime,
+ ReferenceNo = application != null ? application.ReferenceNo : null,
+ IsFromApplicantPath = true,
+ address.ApplicantId
+ };
+
var results = await byApplicationId
.Concat(byApplicantId)
.ToListAsync();
@@ -76,13 +91,13 @@ from application in apps.DefaultIfEmpty()
.Select(g => g.OrderBy(r => r.IsFromApplicantPath).First())
.ToList();
- // Addresses from the ApplicantId path are editable only when
- // that path resolves to a single ApplicantId
- var applicantPathEditable = results
- .Where(r => r.IsFromApplicantPath && r.ApplicantId != null)
- .Select(r => r.ApplicantId)
+ // Determine editability from submissions, not addresses
+ var distinctApplicantIds = await matchingSubmissions
+ .Select(s => s.ApplicantId)
.Distinct()
- .Count() <= 1;
+ .ToListAsync();
+
+ var distinctApplicants = distinctApplicantIds.Count > 1;
var addressDtos = deduplicated.Select(r => new AddressInfoItemDto
{
@@ -95,8 +110,8 @@ from application in apps.DefaultIfEmpty()
Province = r.address.Province ?? string.Empty,
PostalCode = r.address.Postal ?? string.Empty,
Country = r.address.Country ?? string.Empty,
- IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"),
- IsEditable = r.IsFromApplicantPath && applicantPathEditable,
+ IsPrimary = r.address.HasProperty(AddressExtraPropertyNames.IsPrimary) && r.address.GetProperty(AddressExtraPropertyNames.IsPrimary),
+ IsEditable = r.IsFromApplicantPath && !distinctApplicants,
ReferenceNo = r.ReferenceNo
}).ToList();
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs
index e7146685ee..bcb856bb16 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs
@@ -1,15 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Logging;
using Unity.GrantManager.Applicants;
using Unity.GrantManager.Applications;
+using Unity.Notifications.Settings;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
+using Volo.Abp.Settings;
using Volo.Abp.TenantManagement;
namespace Unity.GrantManager.ApplicantProfile
@@ -20,7 +22,8 @@ public class ApplicantProfileAppService(
ITenantRepository tenantRepository,
IRepository applicantTenantMapRepository,
IRepository applicationFormSubmissionRepository,
- IEnumerable dataProviders)
+ IEnumerable dataProviders,
+ ISettingProvider settingProvider)
: ApplicationService, IApplicantProfileAppService
{
private readonly Dictionary _providersByKey
@@ -69,12 +72,13 @@ public async Task> GetApplicantTenantsAsync(ApplicantPr
// Extract the username part from the OIDC sub (part before '@')
var subUsername = SubjectNormalizer.Normalize(request.Subject);
if (subUsername is null) return [];
+ List mappings = [];
// Query the ApplicantTenantMaps table in the host database
using (currentTenant.Change(null))
{
var queryable = await applicantTenantMapRepository.GetQueryableAsync();
- var mappings = await queryable
+ mappings = await queryable
.Where(m => m.OidcSubUsername == subUsername)
.Select(m => new ApplicantTenantDto
{
@@ -82,8 +86,27 @@ public async Task> GetApplicantTenantsAsync(ApplicantPr
TenantName = m.TenantName
})
.ToListAsync();
+ }
+
+ // Apply tenant specific metadata
+ foreach (var map in mappings)
+ {
+ await AddTenantMetadataAsync(map);
+ }
+
+ return mappings;
+ }
- return mappings;
+ ///
+ /// Add on any relevant tenant specific metadata
+ ///
+ /// The applicant tenant DTO to enrich with tenant-specific metadata.
+ private async Task AddTenantMetadataAsync(ApplicantTenantDto tenantMap)
+ {
+ using (currentTenant.Change(tenantMap.TenantId))
+ {
+ var defaultEmailAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress);
+ tenantMap.Metadata[ApplicantTenantMetadataKeys.DefaultFromAddress] = defaultEmailAddress ?? "NoReply@gov.bc.ca";
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs
index eba51fa136..06dd26366d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs
@@ -15,8 +15,11 @@ namespace Unity.GrantManager.ApplicantProfile;
///
/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles,
/// application-level contacts matched by OIDC subject, and applicant agent contacts derived from
-/// the submission login token. This service operates independently from the generic
-/// and queries repositories directly.
+/// the submission login token. Profile contacts are resolved by looking up form submissions that
+/// match the OIDC subject to obtain applicant IDs, then querying
+/// records against those IDs. When a single applicant ID is resolved the contacts are editable;
+/// when multiple IDs are found the contacts are read-only. This service operates independently from the
+/// generic and queries repositories directly.
///
public class ApplicantProfileContactService(
IContactRepository contactRepository,
@@ -27,19 +30,28 @@ public class ApplicantProfileContactService(
IRepository applicationRepository)
: IApplicantProfileContactService, ITransientDependency
{
- private const string ApplicantProfileEntityType = "ApplicantProfile";
+ private const string ApplicantEntityType = "Applicant";
///
- public async Task> GetProfileContactsAsync(Guid profileId)
+ public async Task> GetApplicantContactsAsync(string subject)
{
var contactLinksQuery = await contactLinkRepository.GetQueryableAsync();
var contactsQuery = await contactRepository.GetQueryableAsync();
+ var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync();
+
+ var applicantIds = await submissionsQuery
+ .Where(s => s.OidcSub == subject)
+ .Select(s => s.ApplicantId)
+ .Distinct()
+ .ToListAsync();
+
+ var isEditable = applicantIds.Count <= 1;
return await (
from link in contactLinksQuery
join contact in contactsQuery on link.ContactId equals contact.Id
- where link.RelatedEntityType == ApplicantProfileEntityType
- && link.RelatedEntityId == profileId
+ where link.RelatedEntityType == ApplicantEntityType
+ && applicantIds.Contains(link.RelatedEntityId)
&& link.IsActive
select new ContactInfoItemDto
{
@@ -54,9 +66,9 @@ join contact in contactsQuery on link.ContactId equals contact.Id
ContactType = link.RelatedEntityType,
Role = link.Role,
IsPrimary = link.IsPrimary,
- IsEditable = true,
- ApplicationId = null,
- ReferenceNo = null
+ IsEditable = isEditable,
+ ReferenceNo = null,
+ CreationTime = contact.CreationTime
}).ToListAsync();
}
@@ -85,7 +97,8 @@ join application in applicationsQuery on submission.ApplicationId equals applica
IsPrimary = false,
IsEditable = false,
ApplicationId = appContact.ApplicationId,
- ReferenceNo = application.ReferenceNo
+ ReferenceNo = application.ReferenceNo,
+ CreationTime = appContact.CreationTime
}).ToListAsync();
return applicationContacts;
@@ -117,7 +130,8 @@ join application in applicationsQuery on submission.ApplicationId equals applica
IsPrimary = false,
IsEditable = false,
ApplicationId = agent.ApplicationId,
- ReferenceNo = application.ReferenceNo
+ ReferenceNo = application.ReferenceNo,
+ CreationTime = agent.CreationTime
}).ToListAsync();
return agentContacts;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs
index 5062536063..8b7cbe547d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Threading.Tasks;
using Unity.GrantManager.ApplicantProfile.ProfileData;
using Volo.Abp.DependencyInjection;
@@ -7,7 +8,7 @@ namespace Unity.GrantManager.ApplicantProfile
{
///
/// Provides contact information for the applicant profile by aggregating
- /// profile-linked contacts, application-level contacts, and applicant agent contacts.
+ /// applicant linked contacts, application-level contacts, and applicant agent contacts.
///
[ExposeServices(typeof(IApplicantProfileDataProvider))]
public class ContactInfoDataProvider(
@@ -33,8 +34,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ
using (currentTenant.Change(tenantId))
{
- var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId);
- dto.Contacts.AddRange(profileContacts);
+ var applicantContacts = await applicantProfileContactService.GetApplicantContactsAsync(normalizedSubject);
+ dto.Contacts.AddRange(applicantContacts);
var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject);
dto.Contacts.AddRange(applicationContacts);
@@ -43,6 +44,14 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ
dto.Contacts.AddRange(agentContacts);
}
+ if (dto.Contacts.Count > 0 && !dto.Contacts.Any(c => c.IsPrimary))
+ {
+ var latest = dto.Contacts
+ .OrderByDescending(c => c.CreationTime)
+ .First();
+ latest.IsPrimary = true;
+ }
+
return dto;
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs
index 0534e2f0a7..8b02ea0817 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs
@@ -47,6 +47,8 @@ join applicant in applicantsQuery on submission.ApplicantId equals applicant.Id
select new
{
applicant.Id,
+ applicant.UnityApplicantId,
+ applicant.ApplicantName,
applicant.OrgName,
applicant.OrganizationType,
applicant.OrgNumber,
@@ -58,11 +60,14 @@ join applicant in applicantsQuery on submission.ApplicantId equals applicant.Id
applicant.Sector,
applicant.SubSector
})
+ .Distinct()
.ToListAsync();
dto.Organizations.AddRange(results.Select(r => new OrgInfoItemDto
{
Id = r.Id,
+ ApplicantRefId = r.UnityApplicantId,
+ ApplicantName = r.ApplicantName,
OrgName = r.OrgName,
OrganizationType = r.OrganizationType,
OrgNumber = r.OrgNumber,
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs
index fc73e7f825..973cc86a38 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs
@@ -5,6 +5,7 @@
using Unity.GrantManager.ApplicantProfile.ProfileData;
using Unity.GrantManager.Applications;
using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Codes;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
@@ -47,16 +48,22 @@ from submission in submissionsQuery
join application in applicationsQuery on submission.ApplicationId equals application.Id
where submission.OidcSub == normalizedSubject
select new { application.Id, application.ReferenceNo }
- ).Distinct().ToDictionaryAsync(a => a.Id, a => a.ReferenceNo);
+ )
+ .Distinct()
+ .ToDictionaryAsync(a => a.Id, a => a.ReferenceNo);
if (applicationLookup.Count == 0) return dto;
// Payment info is secured via feature flags and permissions, so direct query for this data instead of using module service
var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync();
+#pragma warning disable CA1862 // EF Core does not support StringComparison overloads - https://github.com/dotnet/efcore/issues/1222
var paymentDetails = await paymentsQueryable
- .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId))
+ .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId)
+ && pr.PaymentStatus != null
+ && pr.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper())
.ToListAsync();
+#pragma warning restore CA1862
dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto
{
@@ -65,7 +72,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica
ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty,
Amount = p.Amount,
PaymentDate = p.PaymentDate,
- PaymentStatus = p.Status.ToString()
+ PaymentStatus = CasPaymentRequestStatus.FullyPaid
}));
}
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 9b541dadf9..73c468aa06 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs
@@ -357,19 +357,22 @@ public async Task GetNextUnityApplicantIdAsync()
foreach (var id in orderedIds)
{
- if (id == candidate)
- {
- candidate++;
- }
- else // Gap found: candidate is the first available ID.
- {
- break;
- }
+ if (id < candidate) continue; // Skip duplicates already passed.
+ if (id == candidate) candidate++;
+ else break; // Gap found: candidate is the first available ID.
}
return candidate;
}
+ [RemoteService(true)]
+ public async Task IsUnityApplicantIdAvailableAsync(string unityApplicantId, Guid currentApplicantId)
+ {
+ if (string.IsNullOrEmpty(unityApplicantId)) return true;
+ var existing = await applicantRepository.GetByUnityApplicantIdAsync(unityApplicantId);
+ return existing == null || existing.Id == currentApplicantId;
+ }
+
[RemoteService(true)]
public async Task GetExistingApplicantAsync(string? unityApplicantId)
{
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs
index 0cedccdf1c..9921573dfe 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/IApplicantAppService.cs
@@ -20,6 +20,7 @@ public interface IApplicantAppService : IApplicationService
Task RelateDefaultSupplierAsync(ApplicantAgentDto applicantAgentDto);
Task UpdateApplicantOrgMatchAsync(Applicant applicant);
Task GetNextUnityApplicantIdAsync();
+ Task IsUnityApplicantIdAvailableAsync(string unityApplicantId, Guid currentApplicantId);
Task> GetApplicantsBySiteIdAsync(Guid siteId);
Task GetApplicantLookUpAutocompleteQueryAsync(string? applicantLookUpQuery);
Task> GetListAsync(ApplicantListRequestDto input);
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs
index d95d45758a..1fc0efd1bb 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs
@@ -176,6 +176,15 @@ public async Task PatchOtherConfig(Guid id, OtherConfigDto config)
await Repository.UpdateAsync(form);
}
+ [Authorize(GrantManagerPermissions.ApplicationForms.Default)]
+ public async Task PatchAiConfig(Guid id, AIConfigDto config)
+ {
+ var form = await Repository.GetAsync(id);
+ form.AutomaticallyGenerateAIAnalysis = config.AutomaticallyGenerateAIAnalysis;
+ form.ManuallyInitiateAIAnalysis = config.ManuallyInitiateAIAnalysis;
+ await Repository.UpdateAsync(form);
+ }
+
[Authorize(PaymentsPermissions.Payments.EditFormPaymentConfiguration)]
public async Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId)
{
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 a94fa33b62..09d14f365a 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs
@@ -6,7 +6,6 @@
using System.Text.Json;
using System.Threading.Tasks;
using Unity.AI.Permissions;
-using Unity.AI.Settings;
using Unity.Flex;
using Unity.Flex.Scoresheets;
using Unity.Flex.Scoresheets.Enums;
@@ -94,12 +93,11 @@ public async Task GetDisplayList(Guid applicationId)
var assessments = await _assessmentRepository.GetListWithAssessorsAsync(applicationId);
var assessmentList = ObjectMapper.Map, List>(assessments);
- // If AI Scoring feature is disabled, tenant setting is off, or user lacks permission, filter out AI assessments
+ // If AI Scoring feature is disabled or user lacks permission, filter out AI assessments
var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring");
- var aiScoringSettingEnabled = aiScoringEnabled && await SettingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false);
- var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault);
+ var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.Analysis.ViewScoringResult);
assessmentList = assessmentList
- .Where(a => !a.IsAiAssessment || (aiScoringSettingEnabled && canViewAI))
+ .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI))
.OrderByDescending(a => a.IsAiAssessment)
.ThenByDescending(a => a.StartDate)
.ToList();
@@ -398,17 +396,17 @@ public async Task UpdateAssessmentScore(AssessmentScoresDto dto)
///
/// Thrown when the specified assessment is not an AI assessment.
///
- [Authorize(AIPermissions.ScoringAssistant.ScoringAssistantDefault)]
- public async Task CloneFromAiAsync(Guid aiAssessmentId)
- {
- if (!await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"))
- {
- throw new UserFriendlyException("AI scoring is not enabled.");
- }
-
- var aiAssessment = await _assessmentRepository.GetAsync(aiAssessmentId);
- if (!aiAssessment.IsAiAssessment)
- {
+ [Authorize(AIPermissions.Analysis.ViewScoringResult)]
+ public async Task CloneFromAiAsync(Guid aiAssessmentId)
+ {
+ if (!await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"))
+ {
+ throw new UserFriendlyException("AI scoring is not enabled.");
+ }
+
+ var aiAssessment = await _assessmentRepository.GetAsync(aiAssessmentId);
+ if (!aiAssessment.IsAiAssessment)
+ {
throw new BusinessException(GrantManagerDomainErrorCodes.CannotCloneNonAiAssessment);
}
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 3073e17437..a535776df1 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs
@@ -21,6 +21,7 @@ public class AttachmentAppService(
IApplicationAttachmentRepository applicationAttachmentRepository,
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IAssessmentAttachmentRepository assessmentAttachmentRepository,
+ IApplicantAttachmentRepository applicantAttachmentRepository,
IIntakeFormSubmissionManager intakeFormSubmissionManager,
IPersonRepository personUserRepository) : ApplicationService, IAttachmentAppService
{
@@ -54,6 +55,11 @@ public async Task> GetAssessmentAsync(Guid assess
}).ToList();
}
+ public async Task> GetApplicantAsync(Guid applicantId)
+ {
+ return await GetAttachmentsAsync(new AttachmentParametersDto(AttachmentType.APPLICANT, applicantId));
+ }
+
public async Task> GetApplicationChefsFileAttachmentsAsync(Guid applicationId)
{
return await applicationChefsFileAttachmentRepository.GetListAsync(applicationId);
@@ -79,6 +85,9 @@ public async Task> GetAttachmentsAsync(AttachmentParam
AttachmentType.ASSESSMENT => await GetAttachmentsInternalAsync(
assessmentAttachmentRepository,
attachment => attachment.AssessmentId == attachmentParametersDto.AttachedResourceId),
+ AttachmentType.APPLICANT => await GetAttachmentsInternalAsync(
+ applicantAttachmentRepository,
+ attachment => attachment.ApplicantId == attachmentParametersDto.AttachedResourceId),
_ => throw new ArgumentException("Attachment type is not supported", nameof(attachmentParametersDto)),
};
}
@@ -117,6 +126,8 @@ public async Task GetAttachmentMetadataAsync(AttachmentTy
attachmentId, assessmentAttachmentRepository),
AttachmentType.CHEFS => await GetMetadataInternalAsync(
attachmentId, applicationChefsFileAttachmentRepository),
+ AttachmentType.APPLICANT => await GetMetadataInternalAsync(
+ attachmentId, applicantAttachmentRepository),
_ => throw new ArgumentException("Invalid attachment type", nameof(attachmentType)),
};
}
@@ -152,6 +163,10 @@ public async Task UpdateAttachmentMetadataAsync(UpdateAtt
updateAttachment,
applicationChefsFileAttachmentRepository,
AttachmentType.CHEFS),
+ AttachmentType.APPLICANT => await UpdateMetadataInternalAsync(
+ updateAttachment,
+ applicantAttachmentRepository,
+ AttachmentType.APPLICANT),
_ => throw new ArgumentException("Invalid attachment type", nameof(updateAttachment)),
};
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentPreviewAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentPreviewAppService.cs
new file mode 100644
index 0000000000..df2c80b7ca
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentPreviewAppService.cs
@@ -0,0 +1,181 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Volo.Abp.Application.Services;
+using Volo.Abp.DependencyInjection;
+
+namespace Unity.GrantManager.Attachments;
+
+public class AttachmentPreviewAppService : ApplicationService, IAttachmentPreviewAppService, ITransientDependency, IDisposable
+{
+ private readonly IFileAppService _fileAppService;
+ private readonly ILibreOfficeConversionService _libreOfficeConversionService;
+ private readonly AmazonS3Client _amazonS3Client;
+ private readonly string _bucket;
+ private readonly string _applicationFolder;
+ private readonly string _assessmentFolder;
+ private readonly string _applicantFolder;
+
+ public AttachmentPreviewAppService(
+ IFileAppService fileAppService,
+ ILibreOfficeConversionService libreOfficeConversionService,
+ IConfiguration configuration)
+ {
+ _fileAppService = fileAppService;
+ _libreOfficeConversionService = libreOfficeConversionService;
+
+ var s3Config = new AmazonS3Config
+ {
+ RegionEndpoint = null,
+ ServiceURL = configuration["S3:Endpoint"],
+ AllowAutoRedirect = true,
+ ForcePathStyle = true
+ };
+ _amazonS3Client = new AmazonS3Client(
+ configuration["S3:AccessKeyId"],
+ configuration["S3:SecretAccessKey"],
+ s3Config);
+
+ _bucket = configuration["S3:Bucket"] ?? throw new InvalidOperationException("Missing server configuration: S3:Bucket");
+ _applicationFolder = NormalizeFolder(configuration["S3:ApplicationS3Folder"] ?? throw new InvalidOperationException("Missing server configuration: S3:ApplicationS3Folder"));
+ _assessmentFolder = NormalizeFolder(configuration["S3:AssessmentS3Folder"] ?? throw new InvalidOperationException("Missing server configuration: S3:AssessmentS3Folder"));
+ _applicantFolder = NormalizeFolder(configuration["S3:ApplicantS3Folder"] ?? throw new InvalidOperationException("Missing server configuration: S3:ApplicantS3Folder"));
+ }
+
+ public async Task GetOrCreatePreviewPdfAsync(AttachmentType attachmentType, Guid ownerId, string fileName)
+ {
+ var folder = attachmentType switch
+ {
+ AttachmentType.APPLICATION => _applicationFolder,
+ AttachmentType.ASSESSMENT => _assessmentFolder,
+ AttachmentType.APPLICANT => _applicantFolder,
+ _ => throw new ArgumentException($"Unsupported attachment type for preview: {attachmentType}")
+ };
+
+ var originalKey = $"{folder}/{ownerId}/{fileName}";
+ var previewKey = $"{folder}/{ownerId}/preview/{fileName}.pdf";
+ var previewName = fileName + ".pdf";
+ var safeFileName = SanitizeForLog(fileName);
+ var safePreviewKey = SanitizeForLog(previewKey);
+
+ // Try S3 cache first
+ var cached = await TryGetCachedPreviewAsync(previewKey, previewName);
+ if (cached != null)
+ {
+ Logger.LogInformation("AttachmentPreviewAppService: serving cached preview for {FileName} [{AttachmentType}/{OwnerId}]", safeFileName, attachmentType, ownerId);
+ return cached;
+ }
+
+ // Cache miss — download original, convert, cache, return
+ Logger.LogInformation("AttachmentPreviewAppService: no cached preview found for {FileName} [{AttachmentType}/{OwnerId}] — starting LibreOffice conversion", safeFileName, attachmentType, ownerId);
+ BlobDto original;
+ try
+ {
+ original = await _fileAppService.GetBlobAsync(new GetBlobRequestDto { S3ObjectKey = originalKey, Name = fileName });
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ Logger.LogWarning(ex, "AttachmentPreviewAppService: original file not found in S3 for {FileName} [{AttachmentType}/{OwnerId}]", safeFileName, attachmentType, ownerId);
+ return null;
+ }
+ var pdfBytes = await _libreOfficeConversionService.ConvertToPdfAsync(original.Content, fileName);
+ await UploadPreviewAsync(previewKey, pdfBytes);
+ Logger.LogInformation("AttachmentPreviewAppService: conversion complete for {FileName} — preview cached at {PreviewKey}", safeFileName, safePreviewKey);
+
+ return new BlobDto { Content = pdfBytes, ContentType = "application/pdf", Name = previewName };
+ }
+
+ public async Task GetOrCreateChefsPreviewPdfAsync(Guid formSubmissionId, Guid chefsFileId, string fileName, byte[] originalContent)
+ {
+ var previewKey = $"chefs/{formSubmissionId}/{chefsFileId}/preview/{fileName}.pdf";
+ var previewName = fileName + ".pdf";
+ var safeFileNameForLog = SanitizeForLog(fileName);
+
+ // Try S3 cache first
+ var cached = await TryGetCachedPreviewAsync(previewKey, previewName);
+ if (cached != null)
+ {
+ Logger.LogInformation("AttachmentPreviewAppService: serving cached preview for CHEFS file {FileName} [{FormSubmissionId}/{ChefsFileId}]", safeFileNameForLog, formSubmissionId, chefsFileId);
+ return cached;
+ }
+
+ // Cache miss — convert provided content, cache, return
+ Logger.LogInformation("AttachmentPreviewAppService: no cached preview found for CHEFS file {FileName} [{FormSubmissionId}/{ChefsFileId}] — starting LibreOffice conversion", safeFileNameForLog, formSubmissionId, chefsFileId);
+ var pdfBytes = await _libreOfficeConversionService.ConvertToPdfAsync(originalContent, fileName);
+ await UploadPreviewAsync(previewKey, pdfBytes);
+ Logger.LogInformation("AttachmentPreviewAppService: conversion complete for CHEFS file {FileName} — preview cached at {PreviewKey}", safeFileNameForLog, SanitizeForLog(previewKey));
+
+ return new BlobDto { Content = pdfBytes, ContentType = "application/pdf", Name = previewName };
+ }
+
+ private async Task TryGetCachedPreviewAsync(string previewKey, string previewName)
+ {
+ try
+ {
+ var cached = await _fileAppService.GetBlobAsync(new GetBlobRequestDto { S3ObjectKey = previewKey, Name = previewName });
+ if (cached?.Content?.Length > 0)
+ return cached;
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Cache miss — expected when preview has not been generated yet
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "AttachmentPreviewAppService: unexpected error checking preview cache for key {PreviewKey}", SanitizeForLog(previewKey));
+ }
+ return null;
+ }
+
+ private async Task UploadPreviewAsync(string previewKey, byte[] pdfBytes)
+ {
+ using var stream = new MemoryStream(pdfBytes);
+ var putRequest = new PutObjectRequest
+ {
+ BucketName = _bucket,
+ Key = EscapeKeyFileName(previewKey),
+ ContentType = "application/pdf",
+ InputStream = stream,
+ UseChunkEncoding = false,
+ DisablePayloadSigning = false
+ };
+ await _amazonS3Client.PutObjectAsync(putRequest);
+ }
+
+ private static string SanitizeForLog(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+ }
+
+ private static string NormalizeFolder(string folder)
+ => folder.EndsWith('/') ? folder.TrimEnd('/') : folder;
+
+ private static string EscapeKeyFileName(string s3ObjectKey)
+ {
+ var lastSlash = s3ObjectKey.LastIndexOf('/');
+ if (lastSlash < 0) return Uri.EscapeDataString(s3ObjectKey);
+ return s3ObjectKey[..(lastSlash + 1)] + Uri.EscapeDataString(s3ObjectKey[(lastSlash + 1)..]);
+ }
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _amazonS3Client.Dispose();
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentSummaryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentSummaryAppService.cs
deleted file mode 100644
index 005ce5667f..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentSummaryAppService.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Unity.AI.Permissions;
-using Unity.GrantManager.AI.BackgroundJobs;
-using Volo.Abp;
-using Volo.Abp.Application.Services;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.DependencyInjection;
-using Volo.Abp.Features;
-
-namespace Unity.GrantManager.Attachments;
-
-[Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)]
-[Dependency(ReplaceServices = true)]
-[ExposeServices(typeof(AttachmentSummaryAppService), typeof(IAttachmentSummaryAppService))]
-public class AttachmentSummaryAppService(
- IBackgroundJobManager backgroundJobManager,
- IFeatureChecker featureChecker) : ApplicationService, IAttachmentSummaryAppService
-{
- private const string SummaryGenerationQueuedMessage = "AI summary generation queued.";
-
- public async Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null)
- {
- if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"))
- {
- throw new UserFriendlyException("AI attachment summaries are not enabled.");
- }
-
- await backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs
- {
- AttachmentIds = [attachmentId],
- PromptVersion = promptVersion,
- TenantId = CurrentTenant.Id
- });
-
- return SummaryGenerationQueuedMessage;
- }
-
- public async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null)
- {
- if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"))
- {
- throw new UserFriendlyException("AI attachment summaries are not enabled.");
- }
-
- if (attachmentIds.Count == 0)
- {
- return [];
- }
-
- await backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs
- {
- AttachmentIds = attachmentIds,
- PromptVersion = promptVersion,
- TenantId = CurrentTenant.Id
- });
-
- return attachmentIds.Select(_ => SummaryGenerationQueuedMessage).ToList();
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/LibreOfficeConversionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/LibreOfficeConversionService.cs
new file mode 100644
index 0000000000..2844fb28c5
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/LibreOfficeConversionService.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace Unity.GrantManager.Attachments;
+
+public class LibreOfficeConversionService : ILibreOfficeConversionService, ITransientDependency
+{
+ private const string LibreOfficeBinary = "/usr/bin/libreoffice";
+ private static readonly Lazy IsInstalledCache = new(ProbeIsInstalled, true);
+
+ private static string GetSafeFileName(string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ throw new ArgumentException("File name must be provided.", nameof(fileName));
+ }
+
+ var safeFileName = Path.GetFileName(fileName);
+ if (safeFileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ {
+ throw new ArgumentException("File name contains invalid characters.", nameof(fileName));
+ }
+
+ return safeFileName;
+ }
+
+ private static bool ProbeIsInstalled()
+ {
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = LibreOfficeBinary,
+ Arguments = "--version",
+ UseShellExecute = false
+ }
+ };
+
+ process.Start();
+ var exited = process.WaitForExit(5000);
+
+ return exited && process.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public bool IsInstalled()
+ {
+ return IsInstalledCache.Value;
+ }
+
+ public async Task ConvertToPdfAsync(byte[] fileContent, string fileName)
+ {
+ var safeFileName = GetSafeFileName(fileName);
+ var tempDir = Path.Combine(Path.GetTempPath(), "unity-libreoffice-preview", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var inputPath = Path.Combine(tempDir, safeFileName);
+ await File.WriteAllBytesAsync(inputPath, fileContent);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = LibreOfficeBinary,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ startInfo.ArgumentList.Add($"-env:UserInstallation=file://{tempDir}");
+ startInfo.ArgumentList.Add("--headless");
+ startInfo.ArgumentList.Add("--convert-to");
+ startInfo.ArgumentList.Add("pdf");
+ startInfo.ArgumentList.Add("--outdir");
+ startInfo.ArgumentList.Add(tempDir);
+ startInfo.ArgumentList.Add(inputPath);
+
+ using var process = new Process
+ {
+ StartInfo = startInfo
+ };
+
+ process.Start();
+
+ var standardOutputTask = process.StandardOutput.ReadToEndAsync();
+ var standardErrorTask = process.StandardError.ReadToEndAsync();
+ var exitTask = process.WaitForExitAsync();
+ var completedTask = await Task.WhenAny(exitTask, Task.Delay(60000));
+
+ if (completedTask != exitTask)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync();
+ await Task.WhenAll(standardOutputTask, standardErrorTask);
+ throw new InvalidOperationException($"LibreOffice conversion timed out for file: {safeFileName}");
+ }
+
+ await exitTask;
+ await Task.WhenAll(standardOutputTask, standardErrorTask);
+
+ if (process.ExitCode != 0)
+ {
+ var error = await standardErrorTask;
+ throw new InvalidOperationException($"LibreOffice conversion failed for file: {safeFileName}. Error: {error}");
+ }
+
+ var pdfFileName = Path.GetFileNameWithoutExtension(safeFileName) + ".pdf";
+ var pdfPath = Path.Combine(tempDir, pdfFileName);
+
+ if (!File.Exists(pdfPath))
+ {
+ throw new InvalidOperationException($"LibreOffice did not produce a PDF output for file: {safeFileName}");
+ }
+
+ return await File.ReadAllBytesAsync(pdfPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs
index 065e17d24a..50a92ebbd6 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProvider.cs
@@ -21,14 +21,16 @@ public partial class S3BlobProvider : BlobProviderBase, ITransientDependency
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IApplicationAttachmentRepository _applicationAttachmentRepository;
- private readonly IAssessmentAttachmentRepository _assessmentAttachmentRepository;
+ private readonly IAssessmentAttachmentRepository _assessmentAttachmentRepository;
+ private readonly IApplicantAttachmentRepository _applicantAttachmentRepository;
private readonly AmazonS3Client _amazonS3Client;
- public S3BlobProvider(IHttpContextAccessor httpContextAccessor, IApplicationAttachmentRepository attachmentRepository, IAssessmentAttachmentRepository assessmentAttachmentRepository, IConfiguration configuration)
+ public S3BlobProvider(IHttpContextAccessor httpContextAccessor, IApplicationAttachmentRepository attachmentRepository, IAssessmentAttachmentRepository assessmentAttachmentRepository, IApplicantAttachmentRepository applicantAttachmentRepository, IConfiguration configuration)
{
_httpContextAccessor = httpContextAccessor;
_applicationAttachmentRepository = attachmentRepository;
_assessmentAttachmentRepository = assessmentAttachmentRepository;
+ _applicantAttachmentRepository = applicantAttachmentRepository;
AmazonS3Config s3config = new()
{
@@ -63,6 +65,19 @@ public override async Task DeleteAsync(BlobProviderDeleteArgs args)
};
await _amazonS3Client.DeleteObjectAsync(deleteObjectRequest);
+
+ // Also delete the cached preview PDF if one was generated (S3 DeleteObject is idempotent)
+ var lastSlash = s3ObjectKey.LastIndexOf('/');
+ if (lastSlash >= 0)
+ {
+ var previewKey = s3ObjectKey[..lastSlash] + "/preview/" + s3ObjectKey[(lastSlash + 1)..] + ".pdf";
+ await _amazonS3Client.DeleteObjectAsync(new DeleteObjectRequest
+ {
+ BucketName = config.Bucket,
+ Key = EscapeKeyFileName(previewKey)
+ });
+ }
+
if (attachmentType == "Application")
{
if (attachmentTypeId.IsNullOrEmpty())
@@ -89,6 +104,19 @@ public override async Task DeleteAsync(BlobProviderDeleteArgs args)
await _assessmentAttachmentRepository.DeleteAsync(attachment);
}
}
+ else if (attachmentType == "Applicant")
+ {
+ if (attachmentTypeId.IsNullOrEmpty())
+ {
+ throw new AbpValidationException("Missing ApplicantId");
+ }
+ IQueryable queryableAttachment = _applicantAttachmentRepository.GetQueryableAsync().Result;
+ ApplicantAttachment? attachment = queryableAttachment.FirstOrDefault(a => a.S3ObjectKey.Equals(s3ObjectKey) && a.ApplicantId.Equals(new Guid(attachmentTypeId.ToString())));
+ if (attachment != null)
+ {
+ await _applicantAttachmentRepository.DeleteAsync(attachment);
+ }
+ }
else
{
throw new AbpValidationException("Wrong AttachmentType:"+attachmentType);
@@ -146,30 +174,34 @@ public override async Task SaveAsync(BlobProviderSaveArgs args)
var httpContext = _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HttpContext.");
var queryParams = httpContext.Request?.Query ?? throw new InvalidOperationException("No query parameters in the current request.");
var routeData = _httpContextAccessor.HttpContext.GetRouteData();
- var assessmentId = routeData.Values["assessmentId"];
+ var assessmentId = routeData.Values["assessmentId"];
+ var applicationId = routeData.Values["applicationId"];
+ var applicantId = routeData.Values["applicantId"];
+ queryParams.TryGetValue("userId", out StringValues currentUserId);
if (assessmentId != null)
{
- queryParams.TryGetValue("userId", out StringValues currentUserId);
-#pragma warning disable CS8604 // Possible null reference argument.
+
+ #pragma warning disable CS8604 // Possible null reference argument.
await UploadAssessmentAttachment(args, assessmentId.ToString(), currentUserId.ToString());
-#pragma warning restore CS8604 // Possible null reference argument.
+ #pragma warning restore CS8604 // Possible null reference argument.
+ }
+ else if(applicationId != null)
+ {
+ #pragma warning disable CS8604 // Possible null reference argument.
+ await UploadApplicationAttachment(args, applicationId.ToString(), currentUserId.ToString());
+ #pragma warning restore CS8604 // Possible null reference argument.
+ }
+ else if (applicantId != null)
+ {
+ #pragma warning disable CS8604 // Possible null reference argument.
+ await UploadApplicantAttachment(args, applicantId.ToString(), currentUserId.ToString());
+ #pragma warning restore CS8604 // Possible null reference argument.
}
else
{
- var applicationId = routeData.Values["applicationId"];
- if(applicationId != null)
- {
- queryParams.TryGetValue("userId", out StringValues currentUserId);
-#pragma warning disable CS8604 // Possible null reference argument.
- await UploadApplicationAttachment(args, applicationId.ToString(), currentUserId.ToString());
-#pragma warning restore CS8604 // Possible null reference argument.
- }
- else
- {
- throw new AbpValidationException("Missing parameter: applicationId/assessmentId");
- }
- }
+ throw new AbpValidationException("Missing parameter: applicationId/assessmentId/applicantId");
+ }
}
private async Task UploadAssessmentAttachment(BlobProviderSaveArgs args, string assessmentId, string currentUserId)
@@ -246,6 +278,43 @@ await _applicationAttachmentRepository.InsertAsync(
}
}
+ private async Task UploadApplicantAttachment(BlobProviderSaveArgs args, string applicantId, string currentUserId)
+ {
+ var config = args.Configuration.GetS3BlobProviderConfiguration();
+ var bucket = config.Bucket;
+ var folder = args.Configuration.GetS3BlobProviderConfiguration().ApplicantS3Folder;
+ if (!folder.EndsWith('/'))
+ {
+ folder += "/";
+ }
+ folder += applicantId;
+ var key = folder + "/" + args.BlobName;
+ var escapedKey = folder + "/" + Uri.EscapeDataString(args.BlobName);
+ var mimeType = GetMimeType(args.BlobName);
+ await UploadToS3(args, bucket, escapedKey, mimeType);
+ IQueryable queryableAttachment = _applicantAttachmentRepository.GetQueryableAsync().Result;
+ ApplicantAttachment? attachment = queryableAttachment.FirstOrDefault(a => a.S3ObjectKey.Equals(key) && a.ApplicantId.Equals(new Guid(applicantId)));
+ if (attachment == null)
+ {
+ await _applicantAttachmentRepository.InsertAsync(
+ new ApplicantAttachment
+ {
+ ApplicantId = new Guid(applicantId),
+ S3ObjectKey = key,
+ UserId = new Guid(currentUserId),
+ FileName = args.BlobName,
+ Time = DateTime.UtcNow,
+ });
+ }
+ else
+ {
+ attachment.UserId = new Guid(currentUserId);
+ attachment.FileName = args.BlobName;
+ attachment.Time = DateTime.UtcNow;
+ await _applicantAttachmentRepository.UpdateAsync(attachment);
+ }
+ }
+
public async Task UploadToS3(BlobProviderSaveArgs args, string bucket, string key, string mimeType)
{
byte[] fileBytes;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProviderConfiguration.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProviderConfiguration.cs
index 9c611dcc6f..9430802abd 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProviderConfiguration.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/S3BlobProviderConfiguration.cs
@@ -51,5 +51,12 @@ public string AssessmentS3Folder
.GetConfiguration("S3BlobProvider.AssessmentS3Folder");
set => _containerConfiguration
.SetConfiguration("S3BlobProvider.AssessmentS3Folder", value);
+ }
+ public string ApplicantS3Folder
+ {
+ get => _containerConfiguration
+ .GetConfiguration("S3BlobProvider.ApplicantS3Folder");
+ set => _containerConfiguration
+ .SetConfiguration("S3BlobProvider.ApplicantS3Folder", value);
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAnalysisAppService.cs
deleted file mode 100644
index 39e0f3676b..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAnalysisAppService.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.Extensions.Logging;
-using System;
-using System.Threading.Tasks;
-using Unity.GrantManager.AI.BackgroundJobs;
-using Unity.AI.Permissions;
-using Volo.Abp;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.Features;
-
-namespace Unity.GrantManager.GrantApplications;
-
-[Authorize(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault)]
-public class ApplicationAnalysisAppService(
- IBackgroundJobManager backgroundJobManager,
- IFeatureChecker featureChecker)
- : GrantManagerAppService, IApplicationAnalysisAppService
-{
- public async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null)
- {
- try
- {
- if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"))
- {
- throw new UserFriendlyException("AI application analysis is not enabled.");
- }
-
- await backgroundJobManager.EnqueueAsync(new GenerateApplicationAnalysisBackgroundJobArgs
- {
- ApplicationId = applicationId,
- PromptVersion = promptVersion,
- TenantId = CurrentTenant.Id
- });
-
- return "{}";
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error queueing AI analysis for application {ApplicationId}", applicationId);
- throw new UserFriendlyException("Failed to queue AI analysis. Please try again.");
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationContentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationContentAppService.cs
deleted file mode 100644
index 7e70bad658..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationContentAppService.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.Extensions.Logging;
-using System;
-using System.Threading.Tasks;
-using Unity.AI.Permissions;
-using Unity.GrantManager.AI.BackgroundJobs;
-using Volo.Abp;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.Features;
-
-namespace Unity.GrantManager.GrantApplications;
-
-[Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)]
-[Authorize(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault)]
-[Authorize(AIPermissions.ScoringAssistant.ScoringAssistantDefault)]
-public class ApplicationContentAppService(
- IBackgroundJobManager backgroundJobManager,
- IFeatureChecker featureChecker)
- : GrantManagerAppService, IApplicationContentAppService
-{
- public async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null)
- {
- try
- {
- var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries");
- var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis");
- var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring");
-
- if (!attachmentSummariesEnabled || !applicationAnalysisEnabled || !scoringEnabled)
- {
- throw new UserFriendlyException("AI generate all is not enabled.");
- }
-
- await backgroundJobManager.EnqueueAsync(new GenerateContentBackgroundJobArgs
- {
- ApplicationId = applicationId,
- PromptVersion = promptVersion,
- TenantId = CurrentTenant.Id
- });
-
- return "{}";
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error queueing full AI content pipeline for application {ApplicationId}", applicationId);
- throw new UserFriendlyException("Failed to queue AI generate all. Please try again.");
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationScoringAppService.cs
deleted file mode 100644
index b3a260430c..0000000000
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationScoringAppService.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.Extensions.Logging;
-using System;
-using System.Threading.Tasks;
-using Unity.AI.Permissions;
-using Unity.GrantManager.AI.BackgroundJobs;
-using Volo.Abp;
-using Volo.Abp.BackgroundJobs;
-using Volo.Abp.Features;
-
-namespace Unity.GrantManager.GrantApplications;
-
-[Authorize(AIPermissions.ScoringAssistant.ScoringAssistantDefault)]
-public class ApplicationScoringAppService(
- IBackgroundJobManager backgroundJobManager,
- IFeatureChecker featureChecker)
- : GrantManagerAppService, IApplicationScoringAppService
-{
- public async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null)
- {
- try
- {
- if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring"))
- {
- throw new UserFriendlyException("AI scoring is not enabled.");
- }
-
- await backgroundJobManager.EnqueueAsync(new GenerateApplicationScoringBackgroundJobArgs
- {
- ApplicationId = applicationId,
- PromptVersion = promptVersion,
- TenantId = CurrentTenant.Id
- });
-
- return "{}";
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error queueing AI application scoring generation for application {ApplicationId}", applicationId);
- throw new UserFriendlyException("Failed to queue AI application scoring generation. Please try again.");
- }
- }
-}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs
index 57ea951e99..4427a21044 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs
@@ -19,10 +19,27 @@ public ApplicationStatusAppService(IApplicationStatusRepository repository)
_applicationStatusRepository = repository;
}
- public async Task> GetListAsync()
+ public virtual async Task> GetListAsync()
{
var statuses = await _applicationStatusRepository.GetListAsync();
return ObjectMapper.Map, List>(statuses.OrderBy(s => s.StatusCode).ToList());
}
+
+ public virtual async Task UpdateExternalStatusLabelsAsync(UpdateApplicationStatusExternalLabelsDto input)
+ {
+ // Load all statuses in a single query by IDs
+ var statusIds = input.Statuses.Select(s => s.Id).ToList();
+ var statuses = await _applicationStatusRepository.GetListAsync(s => statusIds.Contains(s.Id));
+ var statusMap = statuses.ToDictionary(s => s.Id);
+
+ foreach (var statusDto in input.Statuses)
+ {
+ if (statusMap.TryGetValue(statusDto.Id, out var status))
+ {
+ status.ExternalStatus = statusDto.ExternalStatus;
+ await _applicationStatusRepository.UpdateAsync(status);
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs
new file mode 100644
index 0000000000..e5f52cf296
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Unity.AI.Automation;
+using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+
+namespace Unity.GrantManager.GrantApplications.Automation;
+
+public class ApplicationAIGenerationQueue(IBackgroundJobManager backgroundJobManager)
+ : IApplicationAIGenerationQueue, ITransientDependency
+{
+ public async Task QueueAttachmentSummariesAsync(IReadOnlyList attachmentIds, Guid? tenantId, string? promptVersion = null)
+ {
+ await backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs
+ {
+ AttachmentIds = [.. attachmentIds],
+ PromptVersion = promptVersion,
+ TenantId = tenantId
+ });
+ }
+
+ public async Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null)
+ {
+ await backgroundJobManager.EnqueueAsync(new GenerateApplicationAnalysisBackgroundJobArgs
+ {
+ ApplicationId = applicationId,
+ PromptVersion = promptVersion,
+ TenantId = tenantId
+ });
+ }
+
+ public async Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null)
+ {
+ await backgroundJobManager.EnqueueAsync(new GenerateApplicationScoringBackgroundJobArgs
+ {
+ ApplicationId = applicationId,
+ PromptVersion = promptVersion,
+ TenantId = tenantId
+ });
+ }
+
+ public async Task QueueApplicationPipelineAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null)
+ {
+ await backgroundJobManager.EnqueueAsync(new RunApplicationAIPipelineJobArgs
+ {
+ ApplicationId = applicationId,
+ PromptVersion = promptVersion,
+ TenantId = tenantId
+ });
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs
new file mode 100644
index 0000000000..c3d3448687
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+using Unity.GrantManager.GrantApplications;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.MultiTenancy;
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+public class GenerateApplicationAnalysisJob(
+ IApplicationAnalysisAppService applicationAnalysisService,
+ ICurrentTenant currentTenant,
+ ILogger logger) : AsyncBackgroundJob, ITransientDependency
+{
+ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args)
+ {
+ using (currentTenant.Change(args.TenantId))
+ {
+ logger.LogInformation("Executing AI application analysis job for application {ApplicationId}.", args.ApplicationId);
+ var result = await applicationAnalysisService.GenerateApplicationAnalysisAsync(args.ApplicationId, args.PromptVersion);
+ if (result.Completed)
+ {
+ logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId);
+ }
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs
new file mode 100644
index 0000000000..61d87d63a7
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+using Unity.GrantManager.GrantApplications;
+using Unity.GrantManager.GrantApplications.Automation.Events;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.EventBus.Local;
+using Volo.Abp.MultiTenancy;
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+public class GenerateApplicationScoringJob(
+ IApplicationScoringAppService applicationScoringService,
+ ILocalEventBus localEventBus,
+ ICurrentTenant currentTenant,
+ ILogger logger) : AsyncBackgroundJob, ITransientDependency
+{
+ public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args)
+ {
+ using (currentTenant.Change(args.TenantId))
+ {
+ logger.LogInformation("Executing AI application scoring job for application {ApplicationId}.", args.ApplicationId);
+ var result = await applicationScoringService.GenerateApplicationScoringAsync(args.ApplicationId, args.PromptVersion);
+ if (result.Completed)
+ {
+ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent
+ {
+ ApplicationId = args.ApplicationId
+ });
+ }
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs
new file mode 100644
index 0000000000..c47c667005
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+using Unity.GrantManager.Attachments;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.MultiTenancy;
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
+public class GenerateAttachmentSummaryJob(
+ IAttachmentSummaryAppService attachmentSummaryService,
+ ICurrentTenant currentTenant,
+ ILogger logger) : AsyncBackgroundJob, ITransientDependency
+{
+ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args)
+ {
+ using (currentTenant.Change(args.TenantId))
+ {
+ logger.LogInformation(
+ "Executing AI attachment summary job for {AttachmentCount} attachment(s).",
+ args.AttachmentIds.Count);
+ var results = await attachmentSummaryService.GenerateAttachmentSummariesAsync(args.AttachmentIds, args.PromptVersion);
+ logger.LogInformation("Completed AI attachment summaries for {CompletedCount} attachment(s).", results.Count);
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs
similarity index 53%
rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs
rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs
index 2e55de9895..9c06a650f5 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs
@@ -1,30 +1,31 @@
using Microsoft.Extensions.Logging;
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
-using Unity.AI.Settings;
-using Unity.GrantManager.AI.Operations;
-using Unity.GrantManager.Intakes.Events;
+using Unity.GrantManager.Applications;
+using Unity.GrantManager.Attachments;
+using Unity.GrantManager.GrantApplications;
+using Unity.GrantManager.GrantApplications.Automation.Events;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.DependencyInjection;
using Volo.Abp.EventBus.Local;
using Volo.Abp.Features;
using Volo.Abp.MultiTenancy;
-using Volo.Abp.Settings;
-namespace Unity.GrantManager.AI.BackgroundJobs;
+namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
-public class GenerateContentBackgroundJob(
- IAttachmentSummaryService attachmentSummaryService,
- IApplicationAnalysisService applicationAnalysisService,
- IApplicationScoringService applicationScoringService,
- IAIService aiService,
+public class RunApplicationAIPipelineJob(
+ IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
+ IAttachmentSummaryAppService attachmentSummaryAppService,
+ IApplicationAnalysisAppService applicationAnalysisAppService,
+ IApplicationScoringAppService applicationScoringAppService,
IFeatureChecker featureChecker,
- ISettingProvider settingProvider,
ILocalEventBus localEventBus,
ICurrentTenant currentTenant,
- ILogger | |