diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js
index b956d5bfe6..9427a6fad3 100644
--- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js
@@ -7,12 +7,35 @@ $(function () {
let initialFormState = uiElements.settingForm.serialize();
+ let lastSavedValues = {
+ automaticGenerationEnabled: $('#AutomaticGenerationEnabled').is(':checked'),
+ manualGenerationEnabled: $('#ManualGenerationEnabled').is(':checked'),
+ reportingEnabled: $('#ReportingEnabled').is(':checked')
+ };
+
function checkFormChanges() {
let isFormChanged = uiElements.settingForm.serialize() !== initialFormState;
uiElements.saveButton.prop('disabled', !isFormChanged);
uiElements.discardButton.prop('disabled', !isFormChanged);
}
+ function saveSettings(automaticEnabled, manualEnabled, reportingEnabled) {
+ unity.aI.settings.aIConfiguration.updateTenantConfiguration({
+ automaticGenerationEnabled: automaticEnabled,
+ manualGenerationEnabled: manualEnabled,
+ reportingEnabled: reportingEnabled
+ }).then(function () {
+ lastSavedValues = {
+ automaticGenerationEnabled: automaticEnabled,
+ manualGenerationEnabled: manualEnabled,
+ reportingEnabled: reportingEnabled
+ };
+ $(document).trigger('AbpSettingSaved');
+ initialFormState = uiElements.settingForm.serialize();
+ checkFormChanges();
+ });
+ }
+
uiElements.settingForm.on('change', function () {
checkFormChanges();
});
@@ -22,14 +45,13 @@ $(function () {
const automaticEnabled = $('#AutomaticGenerationEnabled').is(':checked');
const manualEnabled = $('#ManualGenerationEnabled').is(':checked');
+ const reportingEnabled = $('#ReportingEnabled').is(':checked');
+ const turningOn = (automaticEnabled && !lastSavedValues.automaticGenerationEnabled) ||
+ (manualEnabled && !lastSavedValues.manualGenerationEnabled) ||
+ (reportingEnabled && !lastSavedValues.reportingEnabled);
- unity.aI.settings.aIConfiguration.updateTenantConfiguration({
- automaticGenerationEnabled: automaticEnabled,
- manualGenerationEnabled: manualEnabled
- }).then(function () {
- $(document).trigger('AbpSettingSaved');
- initialFormState = uiElements.settingForm.serialize();
- checkFormChanges();
+ unity.aI.legalDisclaimer.confirmIfNeeded(turningOn, function () {
+ saveSettings(automaticEnabled, manualEnabled, reportingEnabled);
});
});
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/AIConfigurationViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/AIConfigurationViewComponent.cs
index a2de10dd7a..87e6120012 100644
--- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/AIConfigurationViewComponent.cs
+++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/AIConfigurationViewComponent.cs
@@ -37,6 +37,8 @@ public class AIConfigurationScriptBundleContributor : BundleContributor
{
public override void ConfigureBundle(BundleConfigurationContext context)
{
+ context.Files
+ .Add("/Views/Shared/Scripts/AiLegalDisclaimer.js");
context.Files
.Add("/Views/Shared/Components/AIConfiguration/Default.js");
}
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/Default.js
index 8527f3422b..fd9fbde05b 100644
--- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIConfiguration/Default.js
@@ -28,21 +28,32 @@ $(function () {
}
function handleSave() {
+ const automaticEnabled = UIElements.automaticCheckbox.is(':checked');
+ const manualEnabled = UIElements.manualCheckbox.is(':checked');
+ const turningOn = (automaticEnabled && !lastSavedAIValues.automaticallyGenerateAIAnalysis) ||
+ (manualEnabled && !lastSavedAIValues.manuallyInitiateAIAnalysis);
+
+ unity.aI.legalDisclaimer.confirmIfNeeded(turningOn, function () {
+ saveAiConfig(automaticEnabled, manualEnabled);
+ });
+ }
+
+ function saveAiConfig(automaticEnabled, manualEnabled) {
UIElements.btnSave.prop('disabled', true);
abp.ajax({
url: `/api/app/application-form/${UIElements.formId.val()}/ai-config`,
type: 'PATCH',
data: JSON.stringify({
- automaticallyGenerateAIAnalysis: UIElements.automaticCheckbox.is(':checked'),
- manuallyInitiateAIAnalysis: UIElements.manualCheckbox.is(':checked')
+ automaticallyGenerateAIAnalysis: automaticEnabled,
+ manuallyInitiateAIAnalysis: manualEnabled
}),
contentType: 'application/json'
})
.done(function () {
lastSavedAIValues = {
- automaticallyGenerateAIAnalysis: UIElements.automaticCheckbox.is(':checked'),
- manuallyInitiateAIAnalysis: UIElements.manualCheckbox.is(':checked')
+ automaticallyGenerateAIAnalysis: automaticEnabled,
+ manuallyInitiateAIAnalysis: manualEnabled
};
abp.notify.success('AI configuration saved successfully.');
})
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Scripts/AiLegalDisclaimer.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Scripts/AiLegalDisclaimer.js
new file mode 100644
index 0000000000..6bdbe283ce
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Scripts/AiLegalDisclaimer.js
@@ -0,0 +1,23 @@
+(function () {
+ window.unity = window.unity || {};
+ unity.aI = unity.aI || {};
+
+ unity.aI.legalDisclaimer = {
+ confirmIfNeeded: function (turningOn, onConfirmed) {
+ if (!turningOn) {
+ onConfirmed();
+ return;
+ }
+
+ const modal = new abp.ModalManager({
+ viewUrl: abp.appPath + 'Settings/LegalDisclaimerModal'
+ });
+
+ modal.onResult(function () {
+ onConfirmed();
+ });
+
+ modal.open();
+ }
+ };
+})();
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/IScoresheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/IScoresheetInstanceRepository.cs
index 6e7a0f5e67..f0132c7eba 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/IScoresheetInstanceRepository.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/ScoresheetInstances/IScoresheetInstanceRepository.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;
@@ -8,5 +8,6 @@ public interface IScoresheetInstanceRepository : IBasicRepository
GetByCorrelationAsync(Guid correlationId);
Task GetWithAnswersAsync(Guid scoresheetInstanceId);
+ Task AnyByScoresheetAsync(Guid scoresheetId);
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/ScoresheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/ScoresheetInstanceRepository.cs
index 92145196d3..1a38264d08 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/ScoresheetInstanceRepository.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/ScoresheetInstanceRepository.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
@@ -17,6 +17,12 @@ public class ScoresheetInstanceRepository(IDbContextProvider dbCo
.FirstOrDefaultAsync(s => s.CorrelationId == correlationId);
}
+ public async Task AnyByScoresheetAsync(Guid scoresheetId)
+ {
+ var dbContext = await GetDbContextAsync();
+ return await dbContext.ScoresheetInstances.AnyAsync(instance => instance.ScoresheetId == scoresheetId);
+ }
+
public async Task GetWithAnswersAsync(Guid scoresheetInstanceId)
{
var dbSet = await GetDbSetAsync();
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs
index f350797571..f5f7b3224d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs
@@ -110,9 +110,17 @@ public async Task SendCommentNotification(EmailCommentDto i
string commentLink = input.CommentType switch
{
Comments.CommentType.ApplicationComment or Comments.CommentType.AssessmentComment =>
- QueryHelpers.AddQueryString($"{baseUrl}/GrantApplications/Details", "ApplicationId", input.OwnerId),
+ QueryHelpers.AddQueryString($"{baseUrl}/GrantApplications/Details", new Dictionary
+ {
+ ["ApplicationId"] = input.OwnerId,
+ ["TenantId"] = CurrentTenant.Id?.ToString()
+ }),
Comments.CommentType.ApplicantComment =>
- QueryHelpers.AddQueryString($"{baseUrl}/GrantApplicants/Details", "ApplicantId", input.OwnerId),
+ QueryHelpers.AddQueryString($"{baseUrl}/GrantApplicants/Details", new Dictionary
+ {
+ ["ApplicantId"] = input.OwnerId,
+ ["TenantId"] = CurrentTenant.Id?.ToString()
+ }),
_ => throw new InvalidOperationException("Invalid comment type.")
};
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css
index 1567fe83c5..870d90080c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/unity-styles.css
@@ -367,11 +367,6 @@ thead input {
background-color: var(--bc-colors-blue-background, #38598A);
}
-.abp-widget-wrapper {
- top: 0;
- z-index: 2;
-}
-
td.dt-editable {
cursor: pointer;
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs
index ee59592b06..2c9d1bc64a 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs
@@ -1,31 +1,40 @@
-using Newtonsoft.Json.Linq;
-using System;
-using System.Threading.Tasks;
-using Unity.GrantManager.ApplicationForms.Mapping;
-using Unity.GrantManager.Forms;
-using Volo.Abp.Application.Dtos;
-using Volo.Abp.Application.Services;
-
-namespace Unity.GrantManager.ApplicationForms
-{
- public interface IApplicationFormVersionAppService : ICrudAppService<
- ApplicationFormVersionDto,
- Guid,
- PagedAndSortedResultRequestDto,
- CreateUpdateApplicationFormVersionDto>
- {
- Task FormVersionExists(string chefsFormVersionId);
- Task InitializePublishedFormVersion(dynamic chefsForm, Guid applicationFormId, bool initializePublishedOnly);
- Task GetFormVersionSubmissionMapping(string chefsFormVersionId);
- Task UpdateOrCreateApplicationFormVersion(string chefsFormId, string chefsFormVersionId, Guid applicationFormId, dynamic chefsFormVersion);
- Task TryInitializeApplicationFormVersionWithToken(JToken token, Guid applicationFormId, string formVersionId, bool published);
- Task TryInitializeApplicationFormVersion(string? formId, int version, Guid applicationFormId, string formVersionId, bool published);
- Task GetByChefsFormVersionId(Guid chefsFormVersionId);
- Task GetFormVersionByApplicationIdAsync(Guid applicationId);
+using Newtonsoft.Json.Linq;
+using System;
+using System.Threading.Tasks;
+using Unity.GrantManager.ApplicationForms.Mapping;
+using Unity.GrantManager.Forms;
+using Volo.Abp.Application.Dtos;
+using Volo.Abp.Application.Services;
+
+namespace Unity.GrantManager.ApplicationForms
+{
+ public interface IApplicationFormVersionAppService : ICrudAppService<
+ ApplicationFormVersionDto,
+ Guid,
+ PagedAndSortedResultRequestDto,
+ CreateUpdateApplicationFormVersionDto>
+ {
+ Task FormVersionExists(string chefsFormVersionId);
+ Task InitializePublishedFormVersion(dynamic chefsForm, Guid applicationFormId, bool initializePublishedOnly);
+ Task GetFormVersionSubmissionMapping(string chefsFormVersionId);
+ Task UpdateOrCreateApplicationFormVersion(string chefsFormId, string chefsFormVersionId, Guid applicationFormId, dynamic chefsFormVersion);
+ Task TryInitializeApplicationFormVersionWithToken(JToken token, Guid applicationFormId, string formVersionId, bool published);
+ Task TryInitializeApplicationFormVersion(string? formId, int version, Guid applicationFormId, string formVersionId, bool published);
+ Task GetByChefsFormVersionId(Guid chefsFormVersionId);
+ Task GetFormVersionByApplicationIdAsync(Guid applicationId);
Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId);
Task GenerateMappingAsync(Guid id);
Task GetPendingAiWorksheetAsync(Guid formVersionId);
Task CreateAiWorksheetDraftAsync(Guid formVersionId, CreateAiWorksheetDraftDto input);
Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId);
+ Task GetPendingAiScoresheetAsync(Guid formVersionId);
+ Task CreateAiScoresheetDraftAsync(Guid formVersionId, CreateAiScoresheetDraftDto input);
+ Task DiscardAiScoresheetSuggestionsAsync(Guid formVersionId);
+ Task GetMappingReviewAsync(Guid formVersionId);
+ Task AcceptMappingSuggestionsAsync(Guid formVersionId, AcceptMappingSuggestionsDto input);
+ Task DiscardMappingSuggestionsAsync(Guid formVersionId);
+ Task SetMappingReviewPhaseAsync(Guid formVersionId, FormMappingReviewPhase phase);
+ Task FinalizeMappingReviewAsync(Guid formVersionId);
+ Task ResetAiFlowAsync(Guid formVersionId);
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsDto.cs
new file mode 100644
index 0000000000..0593bd4312
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsDto.cs
@@ -0,0 +1,9 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class AcceptMappingSuggestionsDto
+{
+ public List SuggestionIds { get; set; } = [];
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsResultDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsResultDto.cs
new file mode 100644
index 0000000000..618a711982
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AcceptMappingSuggestionsResultDto.cs
@@ -0,0 +1,6 @@
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class AcceptMappingSuggestionsResultDto
+{
+ public string SubmissionHeaderMapping { get; set; } = "{}";
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetReviewDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetReviewDto.cs
new file mode 100644
index 0000000000..5b4e5cf92c
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetReviewDto.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class AiScoresheetReviewDto
+{
+ public Guid SessionId { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public List Sections { get; set; } = [];
+}
+
+public sealed class AiScoresheetReviewSectionDto
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public uint Order { get; set; }
+ public List Questions { get; set; } = [];
+}
+
+public sealed class AiScoresheetReviewQuestionDto
+{
+ public Guid Id { get; set; }
+ public Guid SectionId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string Label { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string Type { get; set; } = string.Empty;
+ public bool Selected { get; set; } = true;
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiScoresheetDraftDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiScoresheetDraftDto.cs
new file mode 100644
index 0000000000..435f4d9d72
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/CreateAiScoresheetDraftDto.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class CreateAiScoresheetDraftDto
+{
+ public Guid SessionId { get; set; }
+
+ [Required]
+ public string Title { get; set; } = string.Empty;
+
+ [MinLength(1)]
+ public List SelectedQuestionIds { get; set; } = [];
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewDto.cs
new file mode 100644
index 0000000000..f9ad45cf80
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewDto.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public class FormMappingReviewDto
+{
+ public Guid FormVersionId { get; set; }
+ public int Sequence { get; set; }
+ public GenerationReviewStatus Status { get; set; }
+ public FormMappingReviewPhase Phase { get; set; }
+ public FormGenerationWorkflowState WorkflowState { get; set; }
+ public FormGenerationWorkflowAction WorkflowAction { get; set; }
+ public string State { get; set; } = string.Empty;
+ public string Action { get; set; } = string.Empty;
+ public List AvailableActions { get; set; } = [];
+ public bool ActionEnabled { get; set; }
+ public string StateLabel { get; set; } = string.Empty;
+ public string ActionLabel { get; set; } = string.Empty;
+ public List PendingSuggestions { get; set; } = [];
+ public int UnchangedSuggestionCount { get; set; }
+ public bool NoSuggestionsGenerated { get; set; }
+ public bool NoWorksheetSuggestionsGenerated { get; set; }
+ public List DraftWorksheetIds { get; set; } = [];
+ public bool CanGenerateFinalMapping { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewPayload.cs
new file mode 100644
index 0000000000..cddb445803
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingReviewPayload.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class FormMappingReviewPayload
+{
+ public List PendingSuggestions { get; set; } = [];
+ public int UnchangedSuggestionCount { get; set; }
+ public bool NoSuggestionsGenerated { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingSuggestionDto.cs
new file mode 100644
index 0000000000..1d52982e6f
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingSuggestionDto.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public class FormMappingSuggestionDto
+{
+ public Guid Id { get; set; }
+ public string SourceField { get; set; } = string.Empty;
+ public string TargetField { get; set; } = string.Empty;
+ public string Reason { get; set; } = string.Empty;
+ public decimal Confidence { get; set; }
+ public string ChangeType { get; set; } = "New";
+ public string? PreviousTargetField { get; set; }
+ public string? ConflictSourceField { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetReviewPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetReviewPayload.cs
new file mode 100644
index 0000000000..9d5b2254b8
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetReviewPayload.cs
@@ -0,0 +1,10 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public sealed class FormWorksheetReviewPayload
+{
+ public List DraftWorksheetIds { get; set; } = [];
+ public bool NoSuggestionsGenerated { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJobArgs.cs
index 19d8c996c4..5320c270bb 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJobArgs.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJobArgs.cs
@@ -11,6 +11,8 @@ public sealed class AIGenerationBackgroundJobArgs
public Guid OperationId { get; set; }
+ public Guid? GenerationRequestId { get; set; }
+
public Guid? TenantId { get; set; }
public Guid? RequestedByUserId { get; set; }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs
index 2211f051f5..1a8192eb69 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs
@@ -1,5 +1,7 @@
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Localization;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
@@ -8,12 +10,15 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Unity.AI.Features;
+using Unity.AI.Localization;
using Unity.AI.Generation;
using Unity.AI.Operations;
using Unity.AI.Permissions;
using Unity.AI.Requests;
using Unity.AI.Runtime.Execution;
using Unity.Flex.Domain.Worksheets;
+using Unity.Flex.Domain.Scoresheets;
+using Unity.Flex.Scoresheets.Enums;
using Unity.GrantManager.ApplicationForms.Mapping;
using Unity.GrantManager.Applications;
using Unity.GrantManager.Forms;
@@ -29,6 +34,11 @@
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Features;
using Volo.Abp.Uow;
+using Unity.Flex.Domain.WorksheetLinks;
+using Unity.Flex.Domain.WorksheetInstances;
+using Unity.Flex.Domain.ScoresheetInstances;
+using Unity.Flex.Permissions;
+using Unity.Modules.Shared.Correlation;
namespace Unity.GrantManager.ApplicationForms
{
@@ -41,9 +51,15 @@ public class ApplicationFormVersionAppService(
IApplicationFormSubmissionRepository formSubmissionRepository,
IReportingFieldsGeneratorService reportingFieldsGeneratorService,
IFeatureChecker featureChecker,
+ IStringLocalizer localizer,
IAIGenerationAppService aiGenerationAppService,
IWorksheetRepository worksheetRepository,
- IRepository customFieldRepository) :
+ IRepository customFieldRepository,
+ IGenerationReviewRepository generationReviewRepository,
+ IWorksheetLinkRepository worksheetLinkRepository,
+ IScoresheetRepository scoresheetRepository,
+ IWorksheetInstanceRepository worksheetInstanceRepository,
+ IScoresheetInstanceRepository scoresheetInstanceRepository) :
CrudAppService<
ApplicationFormVersion,
ApplicationFormVersionDto,
@@ -58,6 +74,7 @@ public override async Task CreateAsync(CreateUpdateAp
await base.CreateAsync(input);
[RemoteService(false)]
+ [Authorize]
public override async Task UpdateAsync(Guid id, CreateUpdateApplicationFormVersionDto input) =>
await base.UpdateAsync(id, input);
@@ -338,6 +355,13 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer
public virtual async Task GenerateMappingAsync(Guid id)
{
var applicationFormVersion = await Repository.GetAsync(id);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ id);
+ if (review?.Status == GenerationReviewStatus.Active)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormGenerationReviewActive]);
+ }
await _aiGenerationAppService.SubmitAsync(
AIGenerationOperations.FormMapping,
new AIGenerationSubmissionDto
@@ -352,6 +376,204 @@ await _aiGenerationAppService.SubmitAsync(
};
}
+ [HttpGet("api/app/application-form-version/mapping-review")]
+ public virtual async Task GetMappingReviewAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.ViewFormMapping);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ formVersionId);
+ return await MapMappingReviewAsync(formVersionId, review);
+ }
+
+ [HttpPost("api/app/application-form-version/accept-mapping-suggestions")]
+ public virtual async Task AcceptMappingSuggestionsAsync(
+ Guid formVersionId,
+ AcceptMappingSuggestionsDto input)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ formVersionId)
+ ?? throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewPending]);
+ if (review.Status != GenerationReviewStatus.Active)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewInactive]);
+ }
+
+ var suggestionIds = input?.SuggestionIds?.Distinct().ToHashSet() ?? [];
+ if (suggestionIds.Count == 0)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingSelectionRequired]);
+ }
+
+ var payload = GetMappingReviewPayload(review);
+ var selectedSuggestions = payload.PendingSuggestions
+ .Where(suggestion => suggestionIds.Contains(suggestion.Id))
+ .ToList();
+ if (selectedSuggestions.Count != suggestionIds.Count)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingSelectionInvalid]);
+ }
+
+ var formVersion = await Repository.GetAsync(formVersionId);
+ formVersion.SubmissionHeaderMapping = FormMappingResponseMapper.MergeSubmissionHeaderMapping(
+ formVersion.SubmissionHeaderMapping,
+ selectedSuggestions.Select(suggestion => new FormMappingDto
+ {
+ SourceField = suggestion.SourceField,
+ TargetField = suggestion.TargetField
+ }),
+ replaceExisting: review.Sequence > 1 && review.Sequence % 2 == 0);
+ await Repository.UpdateAsync(formVersion, true);
+ payload.PendingSuggestions.RemoveAll(suggestion => suggestionIds.Contains(suggestion.Id));
+ SetMappingReviewPayload(review, payload);
+ await generationReviewRepository.UpdateAsync(review, true);
+
+ return new AcceptMappingSuggestionsResultDto
+ {
+ SubmissionHeaderMapping = formVersion.SubmissionHeaderMapping
+ };
+ }
+
+ [HttpPost("api/app/application-form-version/discard-mapping-suggestions")]
+ public virtual async Task DiscardMappingSuggestionsAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ formVersionId);
+ if (review == null)
+ {
+ return;
+ }
+
+ var payload = GetMappingReviewPayload(review);
+ payload.PendingSuggestions = [];
+ review.Discard();
+ SetMappingReviewPayload(review, payload);
+ await generationReviewRepository.UpdateAsync(review, true);
+ }
+
+ [HttpPost("api/app/application-form-version/mapping-review-phase")]
+ public virtual async Task SetMappingReviewPhaseAsync(Guid formVersionId, FormMappingReviewPhase phase)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ formVersionId);
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ if (phase == FormMappingReviewPhase.WorksheetReview &&
+ review?.Sequence == 1)
+ {
+ review.Complete();
+ await generationReviewRepository.UpdateAsync(review, true);
+ return;
+ }
+
+ if (phase == FormMappingReviewPhase.Completed && review != null)
+ {
+ return;
+ }
+
+ if (phase != FormMappingReviewPhase.WorksheetReview)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewPending]);
+ }
+
+ return;
+ }
+
+ var payload = GetMappingReviewPayload(review);
+ if (phase == FormMappingReviewPhase.WorksheetReview)
+ {
+ if (payload.PendingSuggestions.Count > 0)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewPendingSuggestions]);
+ }
+
+ review.Complete();
+ }
+ else if (phase == FormMappingReviewPhase.Completed)
+ {
+ review.Complete();
+ }
+ else
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewTransitionInvalid]);
+ }
+
+ SetMappingReviewPayload(review, payload);
+ await generationReviewRepository.UpdateAsync(review, true);
+ }
+
+ [HttpPost("api/app/application-form-version/reset-ai-flow")]
+ public virtual async Task ResetAiFlowAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping);
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet);
+ var formVersion = await Repository.GetAsync(formVersionId);
+ var mappingReviews = await generationReviewRepository.GetListByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId);
+ var worksheetReviews = await generationReviewRepository.GetListByOperationAndFormVersionAsync(AIGenerationOperations.FormWorksheet, formVersionId);
+ var worksheetIds = worksheetReviews
+ .SelectMany(review => GetWorksheetReviewPayload(review).DraftWorksheetIds)
+ .Distinct()
+ .ToList();
+ var suggestionWorksheet = await worksheetRepository.GetByNameAsync(
+ AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true);
+ if (suggestionWorksheet != null && !worksheetIds.Contains(suggestionWorksheet.Id))
+ {
+ worksheetIds.Add(suggestionWorksheet.Id);
+ }
+
+ foreach (var worksheetId in worksheetIds)
+ {
+ var worksheet = await worksheetRepository.FindAsync(worksheetId);
+ if (worksheet == null)
+ {
+ continue;
+ }
+
+ await DeleteAiWorksheetSuggestionAsync(worksheet, formVersionId);
+ }
+ await generationReviewRepository.DeleteManyAsync(mappingReviews.Concat(worksheetReviews), true);
+ formVersion.SubmissionHeaderMapping = "{}";
+ await Repository.UpdateAsync(formVersion, true);
+ }
+
+ [HttpPost("api/app/application-form-version/finalize-mapping-review")]
+ public virtual async Task FinalizeMappingReviewAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping);
+ var formVersion = await Repository.GetAsync(formVersionId);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ formVersionId)
+ ?? throw new UserFriendlyException(localizer[AILocalizationKeys.MappingReviewPending]);
+ var worksheetReview = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ formVersionId);
+ if (review.Sequence % 2 == 0 ||
+ review.Status == GenerationReviewStatus.Active ||
+ worksheetReview == null ||
+ worksheetReview.Status == GenerationReviewStatus.Active ||
+ GetWorksheetReviewPayload(worksheetReview).NoSuggestionsGenerated ||
+ !await HasNoRemainingDraftsOrAssignedDraftAsync(worksheetReview))
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.WorksheetDraftsMustBePublished]);
+ }
+ review.Complete();
+ await generationReviewRepository.UpdateAsync(review, true);
+ await _aiGenerationAppService.SubmitAsync(
+ AIGenerationOperations.FormMapping,
+ new AIGenerationSubmissionDto
+ {
+ ApplicationId = formVersion.ApplicationFormId,
+ ApplicationFormVersionId = formVersionId
+ });
+ }
+
[HttpGet("api/app/application-form-version/pending-ai-worksheet")]
public virtual async Task GetPendingAiWorksheetAsync(Guid formVersionId)
{
@@ -365,36 +587,35 @@ await _aiGenerationAppService.SubmitAsync(
public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, CreateAiWorksheetDraftDto input)
{
await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet);
-
var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId);
if (worksheet == null || worksheet.Id != input.SessionId)
{
- throw new UserFriendlyException("The AI worksheet is no longer available for review.");
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetUnavailable]);
}
var title = input.Title?.Trim();
if (string.IsNullOrWhiteSpace(title))
{
- throw new UserFriendlyException("A worksheet title is required.");
+ throw new UserFriendlyException(localizer[AILocalizationKeys.WorksheetTitleRequired]);
}
var selectedFieldIds = input.SelectedFieldIds?.ToHashSet() ?? [];
if (selectedFieldIds.Count == 0)
{
- throw new UserFriendlyException("Select at least one suggested field.");
+ throw new UserFriendlyException(localizer[AILocalizationKeys.WorksheetSelectionRequired]);
}
var fields = worksheet.Sections.SelectMany(section => section.Fields).ToList();
var unknownFieldIds = selectedFieldIds.Except(fields.Select(field => field.Id)).ToList();
if (unknownFieldIds.Count > 0)
{
- throw new UserFriendlyException("The AI worksheet selection is invalid.");
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetSelectionInvalid]);
}
var draftName = await GetNextAiWorksheetDraftNameAsync(title);
- var draft = new Worksheet(Guid.NewGuid(), draftName, title);
+ var draft = new Worksheet(GuidGenerator.Create(), draftName, title);
- var draftSection = new WorksheetSection(Guid.NewGuid(), "Suggested Fields")
+ var draftSection = new WorksheetSection(GuidGenerator.Create(), "Suggested Fields")
{
Worksheet = draft
}.SetOrder(1);
@@ -407,7 +628,7 @@ public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, Create
.Select((field, index) => (field, index)))
{
var draftField = new CustomField(
- Guid.NewGuid(),
+ GuidGenerator.Create(),
field.Key,
draft.Name,
field.Label,
@@ -420,6 +641,17 @@ public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, Create
await worksheetRepository.InsertAsync(draft, true);
+ var worksheetReview = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ formVersionId);
+ if (worksheetReview != null)
+ {
+ var worksheetPayload = GetWorksheetReviewPayload(worksheetReview);
+ worksheetPayload.DraftWorksheetIds.Add(draft.Id);
+ SetWorksheetReviewPayload(worksheetReview, worksheetPayload);
+ await generationReviewRepository.UpdateAsync(worksheetReview);
+ }
+
foreach (var field in fields.Where(field => selectedFieldIds.Contains(field.Id)))
{
field.Section.RemoveField(field);
@@ -428,7 +660,12 @@ public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, Create
if (worksheet.Sections.All(section => section.Fields.Count == 0))
{
- await worksheetRepository.DeleteAsync(worksheet, true);
+ await DeleteAiWorksheetSuggestionAsync(worksheet, formVersionId);
+ if (worksheetReview != null)
+ {
+ worksheetReview.Complete();
+ await generationReviewRepository.UpdateAsync(worksheetReview, true);
+ }
return;
}
@@ -439,16 +676,233 @@ public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, Create
public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId)
{
await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet);
-
var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId);
if (worksheet != null)
{
- await worksheetRepository.DeleteAsync(worksheet, true);
+ await DeleteAiWorksheetSuggestionAsync(worksheet, formVersionId);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ formVersionId);
+ if (review != null)
+ {
+ review.Discard();
+ await generationReviewRepository.UpdateAsync(review, true);
+ }
+ }
+ }
+
+ [HttpGet("api/app/application-form-version/pending-ai-scoresheet")]
+ public virtual async Task GetPendingAiScoresheetAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.ViewFormScoresheet);
+
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormScoresheet,
+ formVersionId);
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ return null;
}
+
+ var formVersion = await formVersionRepository.GetAsync(formVersionId);
+ var scoresheet = await scoresheetRepository.GetByNameAsync(
+ BuildAiScoresheetSuggestionName(formVersion.ApplicationFormId, formVersion.Id), true);
+ return scoresheet?.Published == false ? MapAiScoresheetReview(scoresheet) : null;
}
+ [HttpPost("api/app/application-form-version/create-ai-scoresheet-draft")]
+ public virtual async Task CreateAiScoresheetDraftAsync(Guid formVersionId, CreateAiScoresheetDraftDto input)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormScoresheet);
+
+ var suggestion = await GetPendingAiScoresheetEntityAsync(formVersionId);
+ if (suggestion == null || suggestion.Id != input.SessionId)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetUnavailable]);
+ }
+
+ var title = input.Title?.Trim();
+ if (string.IsNullOrWhiteSpace(title))
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetTitleRequired]);
+ }
+
+ var selectedIds = input.SelectedQuestionIds?.ToHashSet() ?? [];
+ if (selectedIds.Count == 0)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetSelectionRequired]);
+ }
+
+ var questions = suggestion.Sections.SelectMany(section => section.Fields).ToList();
+ if (selectedIds.Except(questions.Select(question => question.Id)).Any())
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetSelectionInvalid]);
+ }
+
+ var draftName = await GetNextAiScoresheetDraftNameAsync(title);
+ var draft = new Scoresheet(GuidGenerator.Create(), title, draftName);
+ foreach (var sourceSection in suggestion.Sections.OrderBy(section => section.Order))
+ {
+ var selectedQuestions = sourceSection.Fields
+ .Where(question => selectedIds.Contains(question.Id))
+ .OrderBy(question => question.Order)
+ .ToList();
+ if (selectedQuestions.Count == 0)
+ {
+ continue;
+ }
+
+ var section = new ScoresheetSection(GuidGenerator.Create(), sourceSection.Name, sourceSection.Order);
+ draft.AddSection(section);
+ foreach (var sourceQuestion in selectedQuestions)
+ {
+ var draftQuestion = new Question(
+ GuidGenerator.Create(),
+ sourceQuestion.Name,
+ sourceQuestion.Label,
+ sourceQuestion.Type,
+ sourceQuestion.Order,
+ sourceQuestion.Description,
+ sourceQuestion.Definition)
+ {
+ SectionId = section.Id
+ };
+ section.Fields.Add(draftQuestion);
+ }
+ }
+
+ await scoresheetRepository.InsertAsync(draft, true);
+
+ foreach (var question in questions.Where(question => selectedIds.Contains(question.Id)).ToList())
+ {
+ var sourceSection = suggestion.Sections.First(section => section.Fields.Contains(question));
+ sourceSection.Fields.Remove(question);
+ }
+
+ if (suggestion.Sections.All(section => section.Fields.Count == 0))
+ {
+ await DeleteAiScoresheetSuggestionAsync(suggestion);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormScoresheet,
+ formVersionId);
+ review?.Complete();
+ if (review != null)
+ {
+ await generationReviewRepository.UpdateAsync(review, true);
+ }
+ }
+ else
+ {
+ await scoresheetRepository.UpdateAsync(suggestion, true);
+ }
+ }
+
+ [HttpPost("api/app/application-form-version/discard-ai-scoresheet-suggestions")]
+ public virtual async Task DiscardAiScoresheetSuggestionsAsync(Guid formVersionId)
+ {
+ await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormScoresheet);
+ var suggestion = await GetPendingAiScoresheetEntityAsync(formVersionId);
+ if (suggestion == null)
+ {
+ return;
+ }
+
+ await DeleteAiScoresheetSuggestionAsync(suggestion);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormScoresheet,
+ formVersionId);
+ if (review != null)
+ {
+ review.Discard();
+ await generationReviewRepository.UpdateAsync(review, true);
+ }
+ }
+
+ private async Task GetPendingAiScoresheetEntityAsync(Guid formVersionId)
+ {
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormScoresheet,
+ formVersionId);
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ return null;
+ }
+
+ var formVersion = await formVersionRepository.GetAsync(formVersionId);
+ var scoresheet = await scoresheetRepository.GetByNameAsync(
+ BuildAiScoresheetSuggestionName(formVersion.ApplicationFormId, formVersion.Id), true);
+ return scoresheet?.Published == false ? scoresheet : null;
+ }
+
+ private static string BuildAiScoresheetSuggestionName(Guid formId, Guid formVersionId) =>
+ $"ai-form-{formId}-version-{formVersionId}-scoresheet";
+
+ private static AiScoresheetReviewDto MapAiScoresheetReview(Scoresheet scoresheet) => new()
+ {
+ SessionId = scoresheet.Id,
+ Title = scoresheet.Title,
+ Sections = scoresheet.Sections
+ .OrderBy(section => section.Order)
+ .Select(section => new AiScoresheetReviewSectionDto
+ {
+ Id = section.Id,
+ Name = section.Name,
+ Order = section.Order,
+ Questions = section.Fields.OrderBy(question => question.Order)
+ .Select(question => new AiScoresheetReviewQuestionDto
+ {
+ Id = question.Id,
+ SectionId = section.Id,
+ Name = question.Name,
+ Label = question.Label,
+ Description = question.Description,
+ Type = question.Type.ToString(),
+ Selected = true
+ }).ToList()
+ }).ToList()
+ };
+
+ private async Task DeleteAiWorksheetSuggestionAsync(Worksheet worksheet, Guid formVersionId)
+ {
+ var links = await worksheetLinkRepository.GetListByWorksheetAsync(worksheet.Id, CorrelationConsts.FormVersion) ?? [];
+ if (worksheet.Published ||
+ links.Any(link => link.CorrelationId != formVersionId) ||
+ await worksheetInstanceRepository.AnyByWorksheetAndFormVersionAsync(worksheet.Id, formVersionId))
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetDeleteProtected]);
+ }
+
+ foreach (var link in links.Where(link => link.CorrelationId == formVersionId))
+ {
+ await worksheetLinkRepository.DeleteAsync(link, true);
+ }
+
+ await worksheetRepository.DeleteAsync(worksheet, true);
+ }
+
+ private async Task DeleteAiScoresheetSuggestionAsync(Scoresheet scoresheet)
+ {
+ if (scoresheet.Published || scoresheet.IsArchived)
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetDeleteProtected]);
+ }
+ if (await scoresheetInstanceRepository.AnyByScoresheetAsync(scoresheet.Id))
+ {
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetHasInstances]);
+ }
+
+ await scoresheetRepository.DeleteAsync(scoresheet, true);
+ }
private async Task GetPendingAiWorksheetEntityAsync(Guid formVersionId)
{
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ formVersionId);
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ return null;
+ }
+
var formVersion = await formVersionRepository.GetAsync(formVersionId);
var worksheet = await worksheetRepository.GetByNameAsync(
AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true);
@@ -478,6 +932,242 @@ public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId)
.ToList()
};
+ private async Task MapMappingReviewAsync(
+ Guid formVersionId,
+ GenerationReview? mappingReview)
+ {
+ var worksheetReview = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ formVersionId);
+ var workflow = await DeriveWorkflowAsync(mappingReview, worksheetReview);
+ var mappingPayload = mappingReview == null
+ ? new FormMappingReviewPayload()
+ : GetMappingReviewPayload(mappingReview);
+ var worksheetPayload = worksheetReview == null
+ ? new FormWorksheetReviewPayload()
+ : GetWorksheetReviewPayload(worksheetReview);
+
+ return new FormMappingReviewDto
+ {
+ FormVersionId = formVersionId,
+ Sequence = mappingReview?.Sequence ?? 0,
+ Status = mappingReview?.Status ?? GenerationReviewStatus.Completed,
+ Phase = GetLegacyPhase(workflow.State),
+ WorkflowState = workflow.State,
+ WorkflowAction = workflow.Action,
+ State = workflow.State.ToString(),
+ Action = workflow.Action.ToString(),
+ AvailableActions = workflow.AvailableActions,
+ ActionEnabled = workflow.ActionEnabled,
+ StateLabel = GetWorkflowLabel(workflow.State),
+ ActionLabel = GetWorkflowLabel(workflow.Action),
+ PendingSuggestions = mappingPayload.PendingSuggestions,
+ UnchangedSuggestionCount = mappingPayload.UnchangedSuggestionCount,
+ NoSuggestionsGenerated = mappingPayload.NoSuggestionsGenerated,
+ NoWorksheetSuggestionsGenerated = worksheetPayload.NoSuggestionsGenerated,
+ DraftWorksheetIds = worksheetPayload.DraftWorksheetIds,
+ CanGenerateFinalMapping = workflow.State == FormGenerationWorkflowState.GenerateFinalMapping
+ };
+ }
+
+ private async Task DeriveWorkflowAsync(
+ GenerationReview? mappingReview,
+ GenerationReview? worksheetReview)
+ {
+ if (mappingReview == null)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.GenerateInitialMapping,
+ FormGenerationWorkflowAction.GenerateInitialMapping,
+ true);
+ }
+
+ if (mappingReview.Status == GenerationReviewStatus.Active)
+ {
+ var isFinalMapping = mappingReview.Sequence > 1 && mappingReview.Sequence % 2 == 0;
+ var state = !isFinalMapping
+ ? FormGenerationWorkflowState.ReviewInitialMapping
+ : FormGenerationWorkflowState.ReviewFinalMapping;
+ var action = !isFinalMapping
+ ? FormGenerationWorkflowAction.ReviewInitialMapping
+ : FormGenerationWorkflowAction.ReviewFinalMapping;
+ return FormWorkflowResult.Single(state, action, true);
+ }
+
+ if (mappingReview.Sequence > 1 &&
+ mappingReview.Sequence % 2 == 0 &&
+ worksheetReview?.Status == GenerationReviewStatus.Active)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.ReviewWorksheets,
+ FormGenerationWorkflowAction.ReviewWorksheets,
+ true);
+ }
+
+ if (mappingReview.Sequence > 1 && mappingReview.Sequence % 2 == 0)
+ {
+ return new FormWorkflowResult(
+ FormGenerationWorkflowState.Completed,
+ FormGenerationWorkflowAction.GenerateMapping,
+ true,
+ [
+ FormGenerationWorkflowAction.GenerateMapping,
+ FormGenerationWorkflowAction.GenerateWorksheetsNextCycle
+ ]);
+ }
+
+ if (worksheetReview == null)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.GenerateWorksheets,
+ FormGenerationWorkflowAction.GenerateWorksheets,
+ true);
+ }
+
+ if (worksheetReview.Status == GenerationReviewStatus.Active)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.ReviewWorksheets,
+ FormGenerationWorkflowAction.ReviewWorksheets,
+ true);
+ }
+
+ if (GetWorksheetReviewPayload(worksheetReview).NoSuggestionsGenerated)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.Completed,
+ FormGenerationWorkflowAction.GenerateMapping,
+ true);
+ }
+
+ if (worksheetReview.Status == GenerationReviewStatus.Discarded)
+ {
+ return FormWorkflowResult.Single(
+ FormGenerationWorkflowState.Completed,
+ FormGenerationWorkflowAction.GenerateMapping,
+ true);
+ }
+
+ return await HasNoRemainingDraftsOrAssignedDraftAsync(worksheetReview)
+ ? FormWorkflowResult.Single(
+ FormGenerationWorkflowState.GenerateFinalMapping,
+ FormGenerationWorkflowAction.GenerateFinalMapping,
+ true)
+ : FormWorkflowResult.Single(
+ FormGenerationWorkflowState.PublishAndAssignWorksheets,
+ FormGenerationWorkflowAction.PublishAndAssignWorksheets,
+ false);
+ }
+
+ private async Task HasNoRemainingDraftsOrAssignedDraftAsync(GenerationReview review)
+ {
+ var draftWorksheetIds = GetWorksheetReviewPayload(review).DraftWorksheetIds;
+ if (draftWorksheetIds.Count == 0)
+ {
+ return true;
+ }
+
+ var linkedWorksheetIds = (await worksheetLinkRepository.GetListByCorrelationAsync(
+ review.ContextId,
+ CorrelationConsts.FormVersion))
+ .Select(link => link.WorksheetId)
+ .ToHashSet();
+
+ var hasRemainingDraft = false;
+
+ foreach (var worksheetId in draftWorksheetIds)
+ {
+ var worksheet = await worksheetRepository.FindAsync(worksheetId);
+ if (worksheet == null)
+ {
+ continue;
+ }
+
+ hasRemainingDraft = true;
+ if (worksheet.Published && linkedWorksheetIds.Contains(worksheetId))
+ {
+ return true;
+ }
+ }
+
+ return !hasRemainingDraft;
+ }
+
+ private static FormMappingReviewPhase GetLegacyPhase(FormGenerationWorkflowState state) =>
+ state switch
+ {
+ FormGenerationWorkflowState.ReviewInitialMapping => FormMappingReviewPhase.MappingReview,
+ FormGenerationWorkflowState.GenerateWorksheets or
+ FormGenerationWorkflowState.ReviewWorksheets => FormMappingReviewPhase.WorksheetReview,
+ FormGenerationWorkflowState.PublishAndAssignWorksheets or
+ FormGenerationWorkflowState.GenerateFinalMapping => FormMappingReviewPhase.PublishAndAssignWorksheets,
+ FormGenerationWorkflowState.ReviewFinalMapping => FormMappingReviewPhase.FinalMappingReview,
+ _ => FormMappingReviewPhase.Completed
+ };
+
+ private string GetWorkflowLabel(FormGenerationWorkflowState state) =>
+ state switch
+ {
+ FormGenerationWorkflowState.GenerateInitialMapping => localizer[AILocalizationKeys.WorkflowGenerateInitialMapping],
+ FormGenerationWorkflowState.ReviewInitialMapping => localizer[AILocalizationKeys.WorkflowReviewInitialMapping],
+ FormGenerationWorkflowState.GenerateWorksheets => localizer[AILocalizationKeys.WorkflowGenerateWorksheets],
+ FormGenerationWorkflowState.ReviewWorksheets => localizer[AILocalizationKeys.WorkflowReviewWorksheets],
+ FormGenerationWorkflowState.PublishAndAssignWorksheets => localizer[AILocalizationKeys.WorkflowPublishAssignWorksheets],
+ FormGenerationWorkflowState.GenerateFinalMapping => localizer[AILocalizationKeys.WorkflowGenerateFinalMapping],
+ FormGenerationWorkflowState.ReviewFinalMapping => localizer[AILocalizationKeys.WorkflowReviewFinalMapping],
+ _ => localizer[AILocalizationKeys.WorkflowCompleted]
+ };
+
+ private string GetWorkflowLabel(FormGenerationWorkflowAction action) =>
+ action switch
+ {
+ FormGenerationWorkflowAction.GenerateInitialMapping => localizer[AILocalizationKeys.WorkflowGenerateInitialMapping],
+ FormGenerationWorkflowAction.ReviewInitialMapping => localizer[AILocalizationKeys.WorkflowReviewInitialMapping],
+ FormGenerationWorkflowAction.GenerateWorksheets or
+ FormGenerationWorkflowAction.GenerateWorksheetsNextCycle => localizer[AILocalizationKeys.WorkflowGenerateWorksheets],
+ FormGenerationWorkflowAction.ReviewWorksheets => localizer[AILocalizationKeys.WorkflowReviewWorksheets],
+ FormGenerationWorkflowAction.PublishAndAssignWorksheets => localizer[AILocalizationKeys.WorkflowPublishAssignWorksheets],
+ FormGenerationWorkflowAction.GenerateFinalMapping => localizer[AILocalizationKeys.WorkflowGenerateFinalMapping],
+ FormGenerationWorkflowAction.GenerateMapping => localizer[AILocalizationKeys.WorkflowGenerateMapping],
+ FormGenerationWorkflowAction.ReviewFinalMapping => localizer[AILocalizationKeys.WorkflowReviewFinalMapping],
+ _ => localizer[AILocalizationKeys.WorkflowCompleted]
+ };
+
+ private sealed record FormWorkflowResult(
+ FormGenerationWorkflowState State,
+ FormGenerationWorkflowAction Action,
+ bool ActionEnabled,
+ List AvailableActions)
+ {
+ public static FormWorkflowResult Single(
+ FormGenerationWorkflowState state,
+ FormGenerationWorkflowAction action,
+ bool enabled) =>
+ new(state, action, enabled, [action]);
+ }
+
+ private static FormMappingReviewPayload GetMappingReviewPayload(GenerationReview review) =>
+ string.IsNullOrWhiteSpace(review.ReviewData)
+ ? new FormMappingReviewPayload()
+ : JsonSerializer.Deserialize(review.ReviewData)
+ ?? new FormMappingReviewPayload();
+
+ private static void SetMappingReviewPayload(
+ GenerationReview review,
+ FormMappingReviewPayload payload) =>
+ review.SetReviewData(JsonSerializer.Serialize(payload));
+
+ private static FormWorksheetReviewPayload GetWorksheetReviewPayload(GenerationReview review) =>
+ string.IsNullOrWhiteSpace(review.ReviewData)
+ ? new FormWorksheetReviewPayload()
+ : JsonSerializer.Deserialize(review.ReviewData)
+ ?? new FormWorksheetReviewPayload();
+
+ private static void SetWorksheetReviewPayload(
+ GenerationReview review,
+ FormWorksheetReviewPayload payload) =>
+ review.SetReviewData(JsonSerializer.Serialize(payload));
+
private async Task GetNextAiWorksheetDraftNameAsync(string title)
{
var titlePart = Regex.Replace(title.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
@@ -493,6 +1183,21 @@ private async Task GetNextAiWorksheetDraftNameAsync(string title)
return candidate;
}
+ private async Task GetNextAiScoresheetDraftNameAsync(string title)
+ {
+ var titlePart = Regex.Replace(title.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
+ var baseName = $"ai-{(string.IsNullOrEmpty(titlePart) ? "scoresheet" : titlePart)}";
+ var candidate = baseName;
+ var suffix = 2;
+
+ while (await scoresheetRepository.GetByNameAsync(candidate, false) != null)
+ {
+ candidate = $"{baseName}-{suffix++}";
+ }
+
+ return candidate;
+ }
+
private static string NormalizeCustomFieldDefinition(string definition)
{
try
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs
index 06860cd8a6..d8a3ca2a8e 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Text.Json;
using Unity.AI.Responses;
@@ -22,13 +25,9 @@ internal static string BuildSubmissionHeaderMapping(FormMappingResponse response
foreach (var property in document.RootElement.EnumerateObject())
{
- if (property.Value.ValueKind != JsonValueKind.String)
- {
- return "{}";
- }
-
- var chefsField = property.Value.GetString();
- if (string.IsNullOrWhiteSpace(chefsField) || string.IsNullOrWhiteSpace(property.Name))
+ if (property.Value.ValueKind != JsonValueKind.String
+ || string.IsNullOrWhiteSpace(property.Value.GetString())
+ || string.IsNullOrWhiteSpace(property.Name))
{
return "{}";
}
@@ -41,4 +40,89 @@ internal static string BuildSubmissionHeaderMapping(FormMappingResponse response
return "{}";
}
}
+
+ internal static List ParseSuggestions(string mapping)
+ {
+ if (string.IsNullOrWhiteSpace(mapping))
+ {
+ return [];
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(mapping);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return [];
+ }
+
+ return document.RootElement.EnumerateObject()
+ .Where(property => property.Value.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(property.Name)
+ && !string.IsNullOrWhiteSpace(property.Value.GetString()))
+ .Select(property => new FormMappingDto
+ {
+ TargetField = property.Name,
+ SourceField = property.Value.GetString() ?? string.Empty
+ })
+ .ToList();
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+
+ internal static string MergeSubmissionHeaderMapping(
+ string? existingMapping,
+ IEnumerable suggestions,
+ bool replaceExisting = false)
+ {
+ var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrWhiteSpace(existingMapping))
+ {
+ try
+ {
+ var existing = JsonSerializer.Deserialize>(existingMapping);
+ if (existing != null)
+ {
+ foreach (var pair in existing.Where(pair =>
+ !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value)))
+ {
+ mapping[pair.Key] = pair.Value;
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ }
+ }
+
+ foreach (var suggestion in suggestions)
+ {
+ if (!string.IsNullOrWhiteSpace(suggestion.TargetField)
+ && !string.IsNullOrWhiteSpace(suggestion.SourceField))
+ {
+ if (replaceExisting)
+ {
+ foreach (var existing in mapping
+ .Where(pair => pair.Key.Equals(suggestion.TargetField, StringComparison.OrdinalIgnoreCase)
+ || pair.Value.Equals(suggestion.SourceField, StringComparison.OrdinalIgnoreCase))
+ .Select(pair => pair.Key)
+ .ToList())
+ {
+ mapping.Remove(existing);
+ }
+
+ mapping[suggestion.TargetField] = suggestion.SourceField;
+ }
+ else if (!mapping.ContainsKey(suggestion.TargetField))
+ {
+ mapping[suggestion.TargetField] = suggestion.SourceField;
+ }
+ }
+ }
+
+ return JsonSerializer.Serialize(mapping);
+ }
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationPrerequisiteValidator.cs
index 36ec117a87..3241e7fb9e 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationPrerequisiteValidator.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationPrerequisiteValidator.cs
@@ -7,6 +7,7 @@
using Unity.AI.Operations;
using Unity.Flex.Domain.Scoresheets;
using Unity.GrantManager.Applications;
+using Unity.GrantManager.ApplicationForms;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Linq;
@@ -21,39 +22,32 @@ public class AIGenerationPrerequisiteValidator(
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IScoresheetRepository scoresheetRepository,
IAsyncQueryableExecuter asyncExecuter,
- IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency
+ IStringLocalizer localizer,
+ IGenerationReviewRepository generationReviewRepository) : IAIGenerationPrerequisiteValidator, ITransientDependency
{
- public Task EnsureAvailableAsync(string operationType, AIGenerationSubmissionDto request)
+ public Task EnsureAvailableAsync(string operationType, AIGenerationSubmissionDto request) => operationType switch
{
- return operationType switch
- {
- AIGenerationOperations.AttachmentSummary => EnsureAttachmentSummaryAvailableAsync(request.ApplicationId),
- AIGenerationOperations.ApplicationAnalysis => EnsureApplicationAnalysisAvailableAsync(request.ApplicationId),
- AIGenerationOperations.ApplicationScoring => EnsureApplicationScoringAvailableAsync(request.ApplicationId),
- AIGenerationOperations.FormMapping => EnsureFormMappingAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
- AIGenerationOperations.FormWorksheet => EnsureFormWorksheetAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
- AIGenerationOperations.FormScoresheet => EnsureFormScoresheetAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
- _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}")
- };
- }
+ AIGenerationOperations.AttachmentSummary => EnsureAttachmentSummaryAvailableAsync(request.ApplicationId),
+ AIGenerationOperations.ApplicationAnalysis => EnsureApplicationAnalysisAvailableAsync(request.ApplicationId),
+ AIGenerationOperations.ApplicationScoring => EnsureApplicationScoringAvailableAsync(request.ApplicationId),
+ AIGenerationOperations.FormMapping => EnsureFormMappingAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
+ AIGenerationOperations.FormWorksheet => EnsureFormWorksheetAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
+ AIGenerationOperations.FormScoresheet => EnsureFormScoresheetAvailableAsync(request.ApplicationFormVersionId.GetValueOrDefault()),
+ _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}")
+ };
public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId)
{
var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync();
var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId));
- if (!hasAttachments)
- {
- throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]);
- }
+ if (!hasAttachments) throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]);
}
public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId)
{
var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId);
if (submission == null || string.IsNullOrWhiteSpace(submission.Submission))
- {
throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]);
- }
}
public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId)
@@ -61,41 +55,37 @@ public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId)
var application = await applicationRepository.GetAsync(applicationId);
var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId);
if (applicationForm.ScoresheetId == null)
- {
throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]);
- }
-
var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value);
if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any())
- {
throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]);
- }
}
public async Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId)
{
var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId);
- if (formVersion == null)
- {
- throw new UserFriendlyException(localizer[AILocalizationKeys.FormMappingRequiresFormVersion]);
- }
+ if (formVersion == null) throw new UserFriendlyException(localizer[AILocalizationKeys.FormMappingRequiresFormVersion]);
+ await EnsureNoActiveReviewAsync(AIGenerationOperations.FormMapping, applicationFormVersionId);
}
public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId)
{
var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId);
- if (formVersion == null)
- {
- throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]);
- }
+ if (formVersion == null) throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]);
+ await EnsureNoActiveReviewAsync(AIGenerationOperations.FormWorksheet, applicationFormVersionId);
}
public async Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId)
{
var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId);
- if (formVersion == null)
- {
- throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]);
- }
+ if (formVersion == null) throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]);
+ await EnsureNoActiveReviewAsync(AIGenerationOperations.FormScoresheet, applicationFormVersionId);
+ }
+
+ private async Task EnsureNoActiveReviewAsync(string operation, Guid formVersionId)
+ {
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(operation, formVersionId);
+ if (review?.Status == GenerationReviewStatus.Active)
+ throw new UserFriendlyException(localizer[AILocalizationKeys.FormGenerationReviewActive]);
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs
index c29d5df821..0a530b6dca 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/ApplicationAIGenerationQueue.cs
@@ -180,6 +180,7 @@ await backgroundJobManager.EnqueueAsync(new AIGenerationBackgroundJobArgs
ApplicationFormVersionId = request.ApplicationFormVersionId,
AttachmentIds = request.AttachmentIds,
OperationId = persistedOperation.Id,
+ GenerationRequestId = generationRequest.Id,
PromptVersion = request.PromptVersion,
RequestedByUserId = currentUser.Id,
TenantId = tenantId
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs
index 04d0768fb8..f05621bc90 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationBackgroundJob.cs
@@ -36,7 +36,8 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync(
generationRequestRepository,
args.TenantId,
args.ApplicationId,
- args.OperationId);
+ args.OperationId,
+ args.GenerationRequestId);
try
{
@@ -56,7 +57,8 @@ await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync(
generationRequestRepository,
args.TenantId,
args.ApplicationId,
- args.OperationId);
+ args.OperationId,
+ args.GenerationRequestId);
}
catch (Exception ex)
{
@@ -66,7 +68,8 @@ await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync(
args.TenantId,
args.ApplicationId,
args.OperationId,
- ex.Message);
+ ex.Message,
+ args.GenerationRequestId);
throw;
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationRequestJobHelper.cs
index 2ab606a459..e51dea4f78 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationRequestJobHelper.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Generation/BackgroundJobs/AIGenerationRequestJobHelper.cs
@@ -78,14 +78,11 @@ public static async Task MarkRunningInNewUowAsync(
IRepository generationRequestRepository,
Guid? tenantId,
Guid applicationId,
- Guid operationId)
+ Guid operationId,
+ Guid? generationRequestId = null)
{
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
- var request = await GetLatestRequestAsync(
- generationRequestRepository,
- x => x.TenantId == tenantId
- && x.ApplicationId == applicationId
- && x.OperationId == operationId);
+ var request = await GetRequestAsync(generationRequestRepository, generationRequestId, tenantId, applicationId, operationId);
await MarkRunningAsync(generationRequestRepository, request);
await uow.CompleteAsync();
}
@@ -95,14 +92,11 @@ public static async Task MarkCompletedInNewUowAsync(
IRepository generationRequestRepository,
Guid? tenantId,
Guid applicationId,
- Guid operationId)
+ Guid operationId,
+ Guid? generationRequestId = null)
{
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
- var request = await GetLatestRequestAsync(
- generationRequestRepository,
- x => x.TenantId == tenantId
- && x.ApplicationId == applicationId
- && x.OperationId == operationId);
+ var request = await GetRequestAsync(generationRequestRepository, generationRequestId, tenantId, applicationId, operationId);
await MarkCompletedAsync(generationRequestRepository, request);
await uow.CompleteAsync();
}
@@ -113,16 +107,31 @@ public static async Task MarkFailedInNewUowAsync(
Guid? tenantId,
Guid applicationId,
Guid operationId,
- string? failureReason)
+ string? failureReason,
+ Guid? generationRequestId = null)
{
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
- var request = await GetLatestRequestAsync(
+ var request = await GetRequestAsync(generationRequestRepository, generationRequestId, tenantId, applicationId, operationId);
+ await MarkFailedAsync(generationRequestRepository, request, failureReason);
+ await uow.CompleteAsync();
+ }
+ private static async Task GetRequestAsync(
+ IRepository generationRequestRepository,
+ Guid? generationRequestId,
+ Guid? tenantId,
+ Guid applicationId,
+ Guid operationId)
+ {
+ if (generationRequestId.HasValue)
+ {
+ return await generationRequestRepository.FindAsync(generationRequestId.Value);
+ }
+
+ return await GetLatestRequestAsync(
generationRequestRepository,
x => x.TenantId == tenantId
&& x.ApplicationId == applicationId
&& x.OperationId == operationId);
- await MarkFailedAsync(generationRequestRepository, request, failureReason);
- await uow.CompleteAsync();
}
public static async Task StampCooldownBestEffortAsync(
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormMapping/FormMappingOperationExecutor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormMapping/FormMappingOperationExecutor.cs
index 6ab7e487ca..53d2f83634 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormMapping/FormMappingOperationExecutor.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormMapping/FormMappingOperationExecutor.cs
@@ -1,6 +1,9 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
+using System.Linq;
+using System.Text.Json;
using Unity.AI.Domain;
using Unity.AI.Generation;
using Unity.AI.Operations;
@@ -9,11 +12,11 @@
using Unity.GrantManager.ApplicationForms;
using Unity.GrantManager.ApplicationForms.Mapping;
using Unity.GrantManager.Applications;
-using Volo.Abp.Domain.Repositories;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
+using Volo.Abp.Guids;
using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
@@ -22,7 +25,8 @@ namespace Unity.GrantManager.GrantApplications.Automation.Operations.FormMapping
public sealed class FormMappingOperationExecutor(
IApplicationFormVersionMappingReadService mappingReadService,
IFormMappingService aiService,
- IRepository applicationFormVersionRepository) : AIGenerationOperationExecutor, ITransientDependency
+ IGenerationReviewRepository generationReviewRepository,
+ IGuidGenerator guidGenerator) : AIGenerationOperationExecutor, ITransientDependency
{
public override string OperationType => AIGenerationOperations.FormMapping;
@@ -31,17 +35,118 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a
var applicationFormVersionId = args.ApplicationFormVersionId
?? throw new InvalidOperationException("Form mapping generation requires an application form version.");
var readModel = await mappingReadService.GetAsync(applicationFormVersionId);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormMapping,
+ applicationFormVersionId);
+ if (review?.Status == GenerationReviewStatus.Active)
+ {
+ return false;
+ }
+
var response = await aiService.GenerateFormMappingAsync(new FormMappingRequest
{
Data = FormMappingPromptDataBuilder.Build(readModel),
PromptVersion = args.PromptVersion
});
- var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response);
- var applicationFormVersion = await applicationFormVersionRepository.GetAsync(applicationFormVersionId);
- applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping;
- await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true);
+ if (!string.IsNullOrWhiteSpace(response.FailureReason))
+ {
+ throw new InvalidOperationException(response.FailureReason);
+ }
+
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ var sequence = review?.Sequence + 1 ?? 1;
+ review = new GenerationReview(
+ guidGenerator.Create(),
+ AIGenerationOperations.FormMapping,
+ applicationFormVersionId,
+ sequence);
+ await generationReviewRepository.InsertAsync(review);
+ }
+
+ var rawSuggestions = FormMappingResponseMapper.ParseSuggestions(response.Mapping)
+ .Select(suggestion => new FormMappingSuggestionDto
+ {
+ Id = guidGenerator.Create(),
+ SourceField = suggestion.SourceField,
+ TargetField = suggestion.TargetField,
+ Reason = suggestion.Reason,
+ Confidence = suggestion.Confidence
+ })
+ .ToList();
+ var isFinalMapping = review.Sequence > 1 && review.Sequence % 2 == 0;
+ var unchangedCount = 0;
+ var suggestions = isFinalMapping
+ ? ClassifyFinalSuggestions(readModel.ExistingMapping, rawSuggestions, out unchangedCount)
+ : rawSuggestions;
+ var payload = JsonSerializer.Deserialize(review.ReviewData)
+ ?? new FormMappingReviewPayload();
+ payload.PendingSuggestions = suggestions;
+ payload.UnchangedSuggestionCount = isFinalMapping ? unchangedCount : 0;
+ payload.NoSuggestionsGenerated = suggestions.Count == 0;
+ if (suggestions.Count == 0)
+ {
+ review.Complete();
+ }
+ review.SetReviewData(JsonSerializer.Serialize(payload));
+ if (suggestions.Count > 0)
+ {
+ review.SetStatus(GenerationReviewStatus.Active);
+ }
+ await generationReviewRepository.UpdateAsync(review, true);
return true;
}
+
+ internal static List ClassifyFinalSuggestions(
+ string? existingMapping,
+ List suggestions,
+ out int unchangedCount)
+ {
+ var existing = ParseMapping(existingMapping);
+ var bySource = existing.GroupBy(pair => pair.Value, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.First().Key, StringComparer.OrdinalIgnoreCase);
+ var byTarget = existing.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+ unchangedCount = 0;
+ var actionable = new List();
+
+ foreach (var suggestion in suggestions)
+ {
+ if (bySource.TryGetValue(suggestion.SourceField, out var previousTarget) &&
+ previousTarget.Equals(suggestion.TargetField, StringComparison.OrdinalIgnoreCase))
+ {
+ unchangedCount++;
+ continue;
+ }
+
+ suggestion.ChangeType = bySource.ContainsKey(suggestion.SourceField) ? "Changed" : "New";
+ suggestion.PreviousTargetField = bySource.GetValueOrDefault(suggestion.SourceField);
+ suggestion.ConflictSourceField = byTarget.TryGetValue(suggestion.TargetField, out var conflictSource) &&
+ !conflictSource.Equals(suggestion.SourceField, StringComparison.OrdinalIgnoreCase)
+ ? conflictSource
+ : null;
+ actionable.Add(suggestion);
+ }
+
+ return actionable;
+ }
+
+ private static Dictionary ParseMapping(string? mapping)
+ {
+ if (string.IsNullOrWhiteSpace(mapping))
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(mapping)
+ ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ catch (JsonException)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ }
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs
index 158ccd47ff..7f64eb9a4a 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Localization;
using System;
using System.Linq;
using System.Text.Json;
@@ -6,16 +7,19 @@
using Unity.AI.Domain;
using Unity.AI.Generation;
using Unity.AI.Operations;
+using Unity.AI.Localization;
using Unity.AI.Requests;
using Unity.GrantManager.ApplicationForms;
using Unity.GrantManager.Applications;
using Unity.Flex.Domain.Scoresheets;
+using Unity.Flex.Domain.ScoresheetInstances;
using Unity.Flex.Scoresheets;
using Volo.Abp.BackgroundJobs;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
+using Volo.Abp.Guids;
using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
@@ -25,7 +29,11 @@ public sealed class FormScoresheetOperationExecutor(
IApplicationFormVersionRepository applicationFormVersionRepository,
IApplicationFormRepository applicationFormRepository,
IScoresheetRepository scoresheetRepository,
- IFormScoresheetService aiService) : AIGenerationOperationExecutor, ITransientDependency
+ IScoresheetInstanceRepository scoresheetInstanceRepository,
+ IFormScoresheetService aiService,
+ IGenerationReviewRepository generationReviewRepository,
+ IGuidGenerator guidGenerator,
+ IStringLocalizer localizer) : AIGenerationOperationExecutor, ITransientDependency
{
private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new()
{
@@ -37,14 +45,25 @@ public sealed class FormScoresheetOperationExecutor(
protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs args)
{
var applicationFormVersionId = args.ApplicationFormVersionId
- ?? throw new InvalidOperationException("Form scoresheet generation requires an application form version.");
+ ?? throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationRequiresFormVersion]);
var formVersion = await applicationFormVersionRepository.GetAsync(applicationFormVersionId);
var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId);
var scoresheetName = BuildScoresheetName(formVersion.Id, applicationForm.Id);
- var existingScoresheet = await scoresheetRepository.GetByNameAsync(scoresheetName, true)
- ?? (applicationForm.ScoresheetId.HasValue
- ? await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value)
- : null);
+ var existingScoresheet = await scoresheetRepository.GetByNameAsync(scoresheetName, true);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormScoresheet,
+ applicationFormVersionId);
+
+ if (review?.Status == GenerationReviewStatus.Active)
+ {
+ return false;
+ }
+
+ if (existingScoresheet is { Published: true } || existingScoresheet?.IsArchived == true
+ || existingScoresheet != null && await scoresheetInstanceRepository.AnyByScoresheetAsync(existingScoresheet.Id))
+ {
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationProtected]);
+ }
var promptData = new
{
@@ -91,11 +110,35 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a
});
var scoresheetJson = scoresheetResponse.Scoresheet;
+ if (!string.IsNullOrWhiteSpace(scoresheetResponse.FailureReason))
+ {
+ throw new InvalidOperationException(
+ localizer[AILocalizationKeys.ScoresheetGenerationInvalidOutput, scoresheetResponse.FailureReason]);
+ }
+
var importDto = ParseScoresheetDefinition(scoresheetJson);
+ var parsed = ParseScoresheetElement(scoresheetJson);
+ if (!HasGeneratedQuestions(parsed))
+ {
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ review = new GenerationReview(
+ guidGenerator.Create(),
+ AIGenerationOperations.FormScoresheet,
+ applicationFormVersionId,
+ review?.Sequence + 1 ?? 1);
+ await generationReviewRepository.InsertAsync(review);
+ }
+
+ review.Complete();
+ await generationReviewRepository.UpdateAsync(review, true);
+ return false;
+ }
+
var scoresheet = existingScoresheet == null
? BuildScoresheet(importDto, scoresheetJson, scoresheetName)
: RebuildScoresheet(existingScoresheet, importDto, scoresheetJson, scoresheetName);
- scoresheet.Published = true;
+ scoresheet.Published = false;
if (existingScoresheet == null)
{
await scoresheetRepository.InsertAsync(scoresheet);
@@ -105,24 +148,33 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a
await scoresheetRepository.UpdateAsync(scoresheet);
}
- applicationForm.ScoresheetId = scoresheet.Id;
- await applicationFormRepository.UpdateAsync(applicationForm);
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ review = new GenerationReview(
+ guidGenerator.Create(),
+ AIGenerationOperations.FormScoresheet,
+ applicationFormVersionId,
+ review?.Sequence + 1 ?? 1);
+ await generationReviewRepository.InsertAsync(review);
+ }
+
+ await generationReviewRepository.UpdateAsync(review, true);
return true;
}
- private static CreateScoresheetDto ParseScoresheetDefinition(string json)
+ private CreateScoresheetDto ParseScoresheetDefinition(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
- throw new InvalidOperationException("Scoresheet generation returned empty content.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationEmpty]);
}
var dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions);
if (dto == null || string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Name))
{
- throw new InvalidOperationException("Scoresheet generation returned an unusable scoresheet definition.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationUnusable]);
}
return dto;
@@ -133,20 +185,20 @@ private static string BuildScoresheetName(Guid formVersionId, Guid formId)
return $"ai-form-{formId}-version-{formVersionId}-scoresheet";
}
- private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName)
+ private Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName)
{
var scoresheet = new Scoresheet(Guid.NewGuid(), dto.Title, scoresheetName);
var parsed = ParseScoresheetElement(json);
if (!TryGetNumberProperty(parsed, "Version", out var version))
{
- throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationNoVersion]);
}
scoresheet.Version = version;
- if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array)
+ if (!TryGetProperty(parsed, "Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array)
{
- throw new InvalidOperationException("Scoresheet generation returned a definition without Sections.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationNoSections]);
}
foreach (var section in sectionsElement.EnumerateArray())
@@ -156,9 +208,9 @@ private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json,
var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder);
scoresheet.AddSection(scoresheetSection);
- if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array)
+ if (!TryGetProperty(section, "Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array)
{
- throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationSectionNoFields, sectionName]);
}
foreach (var field in fieldsElement.EnumerateArray())
@@ -169,10 +221,10 @@ private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json,
GetRequiredStringProperty(field, "Label", "field"),
(Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"),
GetRequiredNumberProperty(field, "Order", "field"),
- field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null
+ TryGetProperty(field, "Description", out var description) && description.ValueKind != JsonValueKind.Null
? description.GetString()
: null,
- field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null
+ TryGetProperty(field, "Definition", out var definition) && definition.ValueKind != JsonValueKind.Null
? definition.GetString()
: null);
question.SectionId = scoresheetSection.Id;
@@ -188,12 +240,12 @@ private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json,
return scoresheet;
}
- private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName)
+ private Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName)
{
var parsed = ParseScoresheetElement(json);
if (!TryGetNumberProperty(parsed, "Version", out var version))
{
- throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationNoVersion]);
}
scoresheet.SetName(scoresheetName);
@@ -206,9 +258,9 @@ private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresh
scoresheet.Sections.Clear();
- if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array)
+ if (!TryGetProperty(parsed, "Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array)
{
- throw new InvalidOperationException("Scoresheet generation returned a definition without Sections.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationNoSections]);
}
foreach (var section in sectionsElement.EnumerateArray())
@@ -218,9 +270,9 @@ private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresh
var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder);
scoresheet.AddSection(scoresheetSection);
- if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array)
+ if (!TryGetProperty(section, "Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array)
{
- throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationSectionNoFields, sectionName]);
}
foreach (var field in fieldsElement.EnumerateArray())
@@ -231,10 +283,10 @@ private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresh
GetRequiredStringProperty(field, "Label", "field"),
(Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"),
GetRequiredNumberProperty(field, "Order", "field"),
- field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null
+ TryGetProperty(field, "Description", out var description) && description.ValueKind != JsonValueKind.Null
? description.GetString()
: null,
- field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null
+ TryGetProperty(field, "Definition", out var definition) && definition.ValueKind != JsonValueKind.Null
? definition.GetString()
: null);
question.SectionId = scoresheetSection.Id;
@@ -245,6 +297,16 @@ private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresh
return scoresheet;
}
+ private static bool HasGeneratedQuestions(JsonElement parsed)
+ {
+ return TryGetProperty(parsed, "Sections", out var sections)
+ && sections.ValueKind == JsonValueKind.Array
+ && sections.EnumerateArray().Any(section =>
+ TryGetProperty(section, "Fields", out var fields)
+ && fields.ValueKind == JsonValueKind.Array
+ && fields.GetArrayLength() > 0);
+ }
+
private static JsonElement ParseScoresheetElement(string json)
{
return JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions);
@@ -252,7 +314,7 @@ private static JsonElement ParseScoresheetElement(string json)
private static bool TryGetNumberProperty(JsonElement element, string propertyName, out uint value)
{
- if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number)
+ if (TryGetProperty(element, propertyName, out var property) && property.ValueKind == JsonValueKind.Number)
{
value = property.GetUInt32();
return true;
@@ -262,25 +324,48 @@ private static bool TryGetNumberProperty(JsonElement element, string propertyNam
return false;
}
- private static string GetRequiredStringProperty(JsonElement element, string propertyName, string sourceName, bool allowEmpty = false)
+ private string GetRequiredStringProperty(JsonElement element, string propertyName, string sourceName, bool allowEmpty = false)
{
- if (element.TryGetProperty(propertyName, out var property)
+ if (TryGetProperty(element, propertyName, out var property)
&& property.ValueKind == JsonValueKind.String
&& (allowEmpty || !string.IsNullOrWhiteSpace(property.GetString())))
{
return property.GetString()!;
}
- throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without a valid {propertyName}.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationPropertyInvalid, sourceName, propertyName]);
+ }
+
+ private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property)
+ {
+ if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
+ {
+ return true;
+ }
+
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var candidate in element.EnumerateObject())
+ {
+ if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ property = candidate.Value;
+ return true;
+ }
+ }
+ }
+
+ property = default;
+ return false;
}
- private static uint GetRequiredNumberProperty(JsonElement element, string propertyName, string sourceName)
+ private uint GetRequiredNumberProperty(JsonElement element, string propertyName, string sourceName)
{
if (TryGetNumberProperty(element, propertyName, out var value))
{
return value;
}
- throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without a valid {propertyName}.");
+ throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationPropertyInvalid, sourceName, propertyName]);
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs
index a4c8987cc7..e5b3153c58 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs
@@ -1,4 +1,4 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -20,6 +20,7 @@
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
+using Volo.Abp.Guids;
using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs;
@@ -31,6 +32,8 @@ public sealed class FormWorksheetOperationExecutor(
IWorksheetRepository worksheetRepository,
IApplicationFormVersionMappingReadService mappingReadService,
IFormWorksheetService aiService,
+ IGenerationReviewRepository generationReviewRepository,
+ IGuidGenerator guidGenerator,
ILogger logger) : AIGenerationOperationExecutor, ITransientDependency
{
private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new()
@@ -46,22 +49,25 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a
?? throw new InvalidOperationException("Form worksheet generation requires an application form version.");
var formVersion = await applicationFormVersionRepository.GetAsync(applicationFormVersionId);
var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId);
- var worksheetName = AiWorksheetSuggestionName.Build(applicationForm.Id, formVersion.Id);
+ var baseWorksheetName = AiWorksheetSuggestionName.Build(applicationForm.Id, formVersion.Id);
+ var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync(
+ AIGenerationOperations.FormWorksheet,
+ applicationFormVersionId);
+ if (review?.Status == GenerationReviewStatus.Active)
+ {
+ return false;
+ }
+
+ var worksheetName = baseWorksheetName;
var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true);
+ EnsureCanonicalSuggestionWorksheetState(existingWorksheet);
+ var noSuggestionsGenerated = existingWorksheet != null &&
+ existingWorksheet.Sections.SelectMany(section => section.Fields).Any() == false;
if (existingWorksheet != null)
{
- if (existingWorksheet.Published)
- {
- logger.LogWarning(
- "A published worksheet already uses AI suggestion name {WorksheetName}; leaving it unchanged.",
- worksheetName);
- }
- else
- {
- logger.LogInformation(
- "An AI suggestion worksheet is pending review for form version {FormVersionId}; leaving it unchanged.",
- formVersion.Id);
- }
+ logger.LogInformation(
+ "An AI suggestion worksheet is pending review for form version {FormVersionId}; leaving it unchanged.",
+ formVersion.Id);
}
else
{
@@ -95,15 +101,49 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a
});
var suggestions = ParseWorksheetDefinition(worksheetResponse.Worksheet);
- var worksheet = BuildWorksheet(suggestions, worksheetName);
- worksheet.SetPublished(false);
- await worksheetRepository.InsertAsync(worksheet);
+ noSuggestionsGenerated = suggestions.Count == 0;
+ if (!noSuggestionsGenerated)
+ {
+ var worksheet = BuildWorksheet(suggestions, worksheetName);
+ worksheet.SetPublished(false);
+ await worksheetRepository.InsertAsync(worksheet);
+ }
+
+ }
+ if (review == null || review.Status != GenerationReviewStatus.Active)
+ {
+ review = new GenerationReview(
+ guidGenerator.Create(),
+ AIGenerationOperations.FormWorksheet,
+ applicationFormVersionId,
+ review?.Sequence + 1 ?? 1);
+ await generationReviewRepository.InsertAsync(review);
+ }
+
+ if (noSuggestionsGenerated)
+ {
+ review.SetReviewData(JsonSerializer.Serialize(new FormWorksheetReviewPayload
+ {
+ NoSuggestionsGenerated = true
+ }));
+ review.Complete();
}
+ await generationReviewRepository.UpdateAsync(review, true);
+
return existingWorksheet == null;
}
+ internal static void EnsureCanonicalSuggestionWorksheetState(Worksheet? worksheet)
+ {
+ if (worksheet?.Published == true)
+ {
+ throw new InvalidOperationException(
+ "The canonical AI suggestion worksheet is published and cannot be regenerated.");
+ }
+ }
+
internal static List ParseWorksheetDefinition(string json)
{
if (string.IsNullOrWhiteSpace(json))
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs
index 0fdbe22eaf..e2aa151325 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Identity/UserImportAppService.cs
@@ -20,6 +20,7 @@ public class UserImportAppService : GrantManagerAppService, IUserImportAppServic
private readonly ICurrentTenant _currentTenant;
private readonly IdentityUserManager _userManager;
private readonly IPersonRepository _personRepository;
+ private readonly IUserAccountsRepository _userAccountsRepository;
private readonly IIdentityUserRepository _identityUserRepository;
private readonly IDataFilter _dataFilter;
@@ -27,6 +28,7 @@ public UserImportAppService(ICssUsersApiService cssUsersApiService,
ICurrentTenant currentTenant,
IdentityUserManager userManager,
IPersonRepository personRepository,
+ IUserAccountsRepository userAccountsRepository,
IIdentityUserRepository identityUserRepository,
IDataFilter dataFilter)
{
@@ -34,6 +36,7 @@ public UserImportAppService(ICssUsersApiService cssUsersApiService,
_currentTenant = currentTenant;
_userManager = userManager;
_personRepository = personRepository;
+ _userAccountsRepository = userAccountsRepository;
_identityUserRepository = identityUserRepository;
_dataFilter = dataFilter;
}
@@ -47,15 +50,17 @@ public UserImportAppService(ICssUsersApiService cssUsersApiService,
///
public async Task ImportUserAsync(ImportUserDto importUserDto)
{
- var newUserId = Guid.NewGuid();
-
var result = await _cssUsersApiService.FindUserAsync(importUserDto.Directory, importUserDto.Guid);
if (result.Data == null || result.Data.Length == 0) throw new AbpValidationException();
var cssUser = result.Data[0];
+ var oidcSub = (cssUser.Attributes?.IdirUserGuid?[0] ?? Guid.NewGuid().ToString()).ToSubjectWithoutIdp();
+ var existingPerson = await _personRepository.FindByOidcSub(oidcSub);
+ var newUserId = existingPerson?.Id ?? Guid.NewGuid();
- IdentityUser? identityUser = await ReactivateAndGetDeletedUserAsync(cssUser.Attributes?.IdirUsername?[0] ?? throw new AbpValidationException());
+ IdentityUser? identityUser = await ReactivateAndGetDeletedUserAsync(
+ cssUser.Attributes?.IdirUsername?[0] ?? throw new AbpValidationException(), oidcSub, cssUser.FirstName, cssUser.LastName);
identityUser ??= await CreateNewIdentityUserAsync(newUserId, cssUser.Attributes?.IdirUsername?[0], cssUser.FirstName, cssUser.LastName, cssUser.Email);
if (identityUser == null) throw new UserFriendlyException("Error creating user account");
@@ -67,7 +72,6 @@ public async Task ImportUserAsync(ImportUserDto importUserDto)
await _userManager.AddToRolesAsync(identityUser, importUserDto.Roles);
}
- var oidcSub = (cssUser.Attributes?.IdirUserGuid?[0] ?? newUserId.ToString()).ToSubjectWithoutIdp();
var displayName = cssUser.Attributes?.DisplayName?[0] ?? identityUser.NormalizedUserName.ToString();
await UpdateAdditionalUserPropertiesAsync(identityUser, oidcSub, displayName);
@@ -89,9 +93,11 @@ public async Task AutoImportUserInternalAsync(ImportUserDto importUserDto,
string oidcSub,
string displayName)
{
- var newUserId = Guid.NewGuid();
+ var normalizedOidcSub = oidcSub.ToSubjectWithoutIdp();
+ var existingPerson = await _personRepository.FindByOidcSub(normalizedOidcSub);
+ var newUserId = existingPerson?.Id ?? Guid.NewGuid();
- IdentityUser? identityUser = await ReactivateAndGetDeletedUserAsync(username);
+ IdentityUser? identityUser = await ReactivateAndGetDeletedUserAsync(username, normalizedOidcSub, firstName, lastName);
identityUser ??= await CreateNewIdentityUserAsync(newUserId, username, firstName, lastName, emailAddress);
if (identityUser == null) throw new UserFriendlyException("Error creating user account");
@@ -103,8 +109,8 @@ public async Task AutoImportUserInternalAsync(ImportUserDto importUserDto,
await _userManager.AddToRolesAsync(identityUser, importUserDto.Roles);
}
- await UpdateAdditionalUserPropertiesAsync(identityUser, oidcSub, displayName);
- await SyncUserToCurrentTenantAsync(identityUser, oidcSub, displayName);
+ await UpdateAdditionalUserPropertiesAsync(identityUser, normalizedOidcSub, displayName);
+ await SyncUserToCurrentTenantAsync(identityUser, normalizedOidcSub, displayName);
}
///
@@ -217,7 +223,7 @@ public async Task SetUserRolesAsync(Guid userId, string[] roleNames)
return identityUser;
}
- private async Task ReactivateAndGetDeletedUserAsync(string username)
+ private async Task ReactivateAndGetDeletedUserAsync(string username, string oidcSub, string? firstName, string? lastName)
{
//Temporary disable the ISoftDelete filter - find delete user account and reactivate for import
using (_dataFilter.Disable())
@@ -225,8 +231,21 @@ public async Task SetUserRolesAsync(Guid userId, string[] roleNames)
var identityUser = await _identityUserRepository
.FindByTenantIdAndUserNameAsync(username, _currentTenant.Id);
+ identityUser ??= (await _userAccountsRepository.GetListByOidcSub(oidcSub))
+ .FirstOrDefault();
+
if (identityUser != null)
{
+ // Directory details (e.g. surname) can change between imports - keep the
+ // reactivated account's name/username current rather than leaving it stale.
+ if (!string.IsNullOrWhiteSpace(username) &&
+ !string.Equals(identityUser.UserName, username, StringComparison.OrdinalIgnoreCase))
+ {
+ await _userManager.SetUserNameAsync(identityUser, username);
+ }
+
+ identityUser.Name = firstName ?? identityUser.Name;
+ identityUser.Surname = lastName ?? identityUser.Surname;
identityUser.IsDeleted = false;
identityUser.DeleterId = null;
identityUser.DeletionTime = null;
@@ -236,12 +255,12 @@ public async Task SetUserRolesAsync(Guid userId, string[] roleNames)
}
return null;
- }
+ }
private async Task SyncUserToCurrentTenantAsync(IdentityUser user, string oidcSub, string displayName)
{
- var existingUser = await _personRepository.FindByOidcSub(oidcSub);
- if (existingUser == null)
+ var existingPerson = await _personRepository.FindByOidcSub(oidcSub);
+ if (existingPerson == null)
{
await _personRepository.InsertAsync(new Person()
{
@@ -251,7 +270,13 @@ await _personRepository.InsertAsync(new Person()
FullName = $"{user.Name} {user.Surname}",
Badge = Utils.CreateUserBadge(user)
});
+ return;
}
+
+ existingPerson.OidcDisplayName = displayName;
+ existingPerson.FullName = $"{user.Name} {user.Surname}";
+ existingPerson.Badge = Utils.CreateUserBadge(user);
+ await _personRepository.UpdateAsync(existingPerson);
}
private async Task UpdateAdditionalUserPropertiesAsync(IdentityUser user, string oidcSub, string displayName)
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/GenerationReviewStatus.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/GenerationReviewStatus.cs
new file mode 100644
index 0000000000..50ec9803c5
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/GenerationReviewStatus.cs
@@ -0,0 +1,8 @@
+namespace Unity.GrantManager.ApplicationForms;
+
+public enum GenerationReviewStatus
+{
+ Active = 0,
+ Completed = 1,
+ Discarded = 2
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/Mapping/FormMappingReviewPhase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/Mapping/FormMappingReviewPhase.cs
new file mode 100644
index 0000000000..4b0fb7edd0
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicationForms/Mapping/FormMappingReviewPhase.cs
@@ -0,0 +1,35 @@
+namespace Unity.GrantManager.ApplicationForms.Mapping;
+
+public enum FormMappingReviewPhase
+{
+ MappingReview = 0,
+ WorksheetReview = 1,
+ PublishAndAssignWorksheets = 2,
+ FinalMappingReview = 3,
+ Completed = 4
+}
+
+public enum FormGenerationWorkflowState
+{
+ GenerateInitialMapping = 10,
+ ReviewInitialMapping = 20,
+ GenerateWorksheets = 30,
+ ReviewWorksheets = 40,
+ PublishAndAssignWorksheets = 50,
+ GenerateFinalMapping = 60,
+ ReviewFinalMapping = 70,
+ Completed = 80
+}
+
+public enum FormGenerationWorkflowAction
+{
+ GenerateInitialMapping = 10,
+ ReviewInitialMapping = 20,
+ GenerateWorksheets = 30,
+ ReviewWorksheets = 40,
+ PublishAndAssignWorksheets = 50,
+ GenerateFinalMapping = 60,
+ ReviewFinalMapping = 70,
+ GenerateMapping = 80,
+ GenerateWorksheetsNextCycle = 90
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json
index 10945ccd1a..843cb70ef7 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json
@@ -616,6 +616,11 @@
"DataTable:ContextMenu:Copy": "Copy",
"DataTable:ContextMenu:CopiedToClipboard": "Copied to clipboard",
"DataTable:ContextMenu:Filter": "Filter",
- "DataTable:ContextMenu:ClearFilter": "Clear Filters"
+ "DataTable:ContextMenu:ClearFilter": "Clear Filters",
+
+ "WrongTenantError:Title": "An Error Occurred",
+ "WrongTenantError:ApplicationTenant": "This application is in Tenant: {0}",
+ "WrongTenantError:CurrentTenant": "You are currently in Tenant: {0}",
+ "WrongTenantError:Instructions": "Please click on the Profile menu at the top right of the corner, click on Switch Grant Programs, and select the correct Tenant before proceeding to view the link."
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/GenerationReview.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/GenerationReview.cs
new file mode 100644
index 0000000000..1bc320a738
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/GenerationReview.cs
@@ -0,0 +1,57 @@
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+using Volo.Abp.Domain.Entities.Auditing;
+using Volo.Abp.MultiTenancy;
+
+namespace Unity.GrantManager.ApplicationForms;
+
+public class GenerationReview : AuditedAggregateRoot, IMultiTenant
+{
+ protected GenerationReview()
+ {
+ ReviewData = "{}";
+ }
+
+ public GenerationReview(
+ Guid id,
+ string operation,
+ Guid contextId,
+ int sequence = 1)
+ : base(id)
+ {
+ Operation = operation;
+ ContextId = contextId;
+ Sequence = sequence;
+ Status = GenerationReviewStatus.Active;
+ ReviewData = "{}";
+ }
+
+ public string Operation { get; private set; } = null!;
+ public Guid ContextId { get; private set; }
+ public int Sequence { get; private set; }
+ public GenerationReviewStatus Status { get; private set; }
+ [Column(TypeName = "jsonb")]
+ public string ReviewData { get; private set; }
+
+ public Guid? TenantId { get; set; }
+
+ public void SetReviewData(string reviewData)
+ {
+ ReviewData = reviewData;
+ }
+
+ public void SetStatus(GenerationReviewStatus status)
+ {
+ Status = status;
+ }
+
+ public void Complete()
+ {
+ Status = GenerationReviewStatus.Completed;
+ }
+
+ public void Discard()
+ {
+ Status = GenerationReviewStatus.Discarded;
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/IGenerationReviewRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/IGenerationReviewRepository.cs
new file mode 100644
index 0000000000..1857462a0b
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/ApplicationForms/IGenerationReviewRepository.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Volo.Abp.Domain.Repositories;
+
+namespace Unity.GrantManager.ApplicationForms;
+
+public interface IGenerationReviewRepository : IRepository
+{
+ Task FindLatestByOperationAndFormVersionAsync(
+ string operation,
+ Guid formVersionId);
+
+ Task> GetListByOperationAndFormVersionAsync(
+ string operation,
+ Guid formVersionId);
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
index f26b4a7abb..6cd378639d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
@@ -18,6 +18,7 @@
using Unity.Reporting.EntityFrameworkCore;
using Unity.GrantManager.GlobalTag;
using Unity.GrantManager.Contacts;
+using Unity.GrantManager.ApplicationForms;
namespace Unity.GrantManager.EntityFrameworkCore
{
@@ -28,6 +29,7 @@ public class GrantTenantDbContext : AbpDbContext
public DbSet Intakes { get; set; }
public DbSet ApplicationForms { get; set; }
public DbSet ApplicationFormVersions { get; set; }
+ public DbSet GenerationReviews { get; set; }
public DbSet Applicants { get; set; }
public DbSet Applications { get; set; }
public DbSet ApplicationStatuses { get; set; }
@@ -143,6 +145,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(x => x.FormSchema).HasColumnType("jsonb");
});
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable(GrantManagerConsts.TenantTablePrefix + "GenerationReviews", "AI");
+ b.ConfigureByConvention();
+ b.Property(x => x.Operation).IsRequired();
+ b.Property(x => x.Status).HasConversion().IsRequired();
+ b.Property(x => x.ReviewData).HasColumnType("jsonb").IsRequired();
+ b.HasIndex(x => new { x.Operation, x.ContextId, x.Sequence }).IsUnique();
+ });
+
modelBuilder.Entity(b =>
{
b.ToTable(GrantManagerConsts.TenantTablePrefix + "ApplicationStatuses",
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260805212847_AddGenerationReviews.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260805212847_AddGenerationReviews.Designer.cs
new file mode 100644
index 0000000000..830984d400
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260805212847_AddGenerationReviews.Designer.cs
@@ -0,0 +1,5340 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Unity.GrantManager.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore;
+
+#nullable disable
+
+namespace Unity.GrantManager.Migrations.TenantMigrations
+{
+ [DbContext(typeof(GrantTenantDbContext))]
+ [Migration("20260805212847_AddGenerationReviews")]
+ partial class AddGenerationReviews
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql)
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("ReportData")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("ScoresheetId")
+ .HasColumnType("uuid");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScoresheetId");
+
+ b.ToTable("ScoresheetInstances", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("CurrentValue")
+ .HasColumnType("jsonb");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid");
+
+ b.Property("ScoresheetInstanceId")
+ .HasColumnType("uuid");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("QuestionId");
+
+ b.HasIndex("ScoresheetInstanceId");
+
+ b.ToTable("Answers", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("Definition")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Order")
+ .HasColumnType("bigint");
+
+ b.Property("SectionId")
+ .HasColumnType("uuid");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SectionId");
+
+ b.ToTable("Questions", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("IsArchived")
+ .HasColumnType("boolean");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Order")
+ .HasColumnType("bigint");
+
+ b.Property("Published")
+ .HasColumnType("boolean");
+
+ b.Property("ReportColumns")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReportKeys")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReportViewName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.ToTable("Scoresheets", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Order")
+ .HasColumnType("bigint");
+
+ b.Property("ScoresheetId")
+ .HasColumnType("uuid");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScoresheetId");
+
+ b.ToTable("ScoresheetSections", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("CurrentValue")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CustomFieldId")
+ .HasColumnType("uuid");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("WorksheetInstanceId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WorksheetInstanceId");
+
+ b.ToTable("CustomFieldValues", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("CurrentValue")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("ReportData")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("UiAnchor")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("WorksheetCorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("WorksheetCorrelationProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("WorksheetId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("WorksheetInstances", "Flex");
+ });
+
+ modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Order")
+ .HasColumnType("bigint");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property