diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs index 7e3f594e16..3d9aaf789f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs @@ -14,5 +14,14 @@ public class ApplicationAnalysisRequest [JsonPropertyName("attachments")] public List Attachments { get; set; } = new(); + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs index c0e1bfd1ee..d3eb7fe217 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs @@ -12,5 +12,14 @@ public class AttachmentSummaryRequest [JsonPropertyName("contentType")] public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs index 870412d079..7f904ea77a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs @@ -17,5 +17,14 @@ public class ScoresheetSectionRequest [JsonPropertyName("sectionSchema")] public JsonElement SectionSchema { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs new file mode 100644 index 0000000000..fc1fac75f3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.AI +{ + public class AIPromptCaptureResponse + { + [JsonPropertyName("contextId")] + public string ContextId { get; set; } = string.Empty; + + [JsonPropertyName("promptType")] + public string PromptType { get; set; } = string.Empty; + + [JsonPropertyName("promptVersion")] + public string PromptVersion { get; set; } = string.Empty; + + [JsonPropertyName("captureLabel")] + public string CaptureLabel { get; set; } = string.Empty; + + [JsonPropertyName("systemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; + + [JsonPropertyName("userPrompt")] + public string UserPrompt { get; set; } = string.Empty; + + [JsonPropertyName("rawOutput")] + public string RawOutput { get; set; } = string.Empty; + + [JsonPropertyName("formattedOutput")] + public string FormattedOutput { get; set; } = string.Empty; + + [JsonPropertyName("capturedAt")] + public DateTime CapturedAt { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs index 4a99135f3d..4ca718a427 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantOrgInfoDto : ApplicantProfileDataDto { public override string DataType => "ORGINFO"; + + public List Organizations { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs index c17c89eebe..3f4b70a7fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantPaymentInfoDto : ApplicantProfileDataDto { public override string DataType => "PAYMENTINFO"; + + public List Payments { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs new file mode 100644 index 0000000000..f5ef23aac4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class OrgInfoItemDto + { + public Guid Id { get; set; } + public string? OrgName { get; set; } + public string? OrganizationType { get; set; } + public string? OrgNumber { get; set; } + public string? OrgStatus { get; set; } + public string? NonRegOrgName { get; set; } + public string? FiscalMonth { get; set; } + public int? FiscalDay { get; set; } + public string? OrganizationSize { get; set; } + public string? Sector { get; set; } + public string? SubSector { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs new file mode 100644 index 0000000000..da82267916 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class PaymentInfoItemDto + { + public Guid Id { get; set; } + public string PaymentNumber { get; set; } = string.Empty; + public string ReferenceNo { get; set; } = string.Empty; + public decimal Amount { get; set; } + public string? PaymentDate { get; set; } + public string PaymentStatus { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs index 3bf233769b..b6bb290746 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs @@ -13,6 +13,6 @@ public interface IAttachmentAppService : IApplicationService Task> GetAttachmentsAsync(AttachmentParametersDto attachmentParametersDto); Task GetAttachmentMetadataAsync(AttachmentType attachmentType, Guid attachmentId); Task UpdateAttachmentMetadataAsync(UpdateAttachmentMetadataDto updateAttachment); - Task GenerateAISummaryAttachmentAsync(Guid attachmentId); - Task> GenerateAISummariesAttachmentsAsync(List attachmentIds); + Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false); + Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs index c14c38d1bd..cfb21ea57c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAIAnalysisAppService : IApplicationService { - Task GenerateAIAnalysisAsync(Guid applicationId); + Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs new file mode 100644 index 0000000000..c25d04ee9b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.AI; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationAIPromptCaptureAppService : IApplicationService + { + Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs index 9f18a4f4dd..e3f54c8f2e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAIScoringAppService : IApplicationService { - Task GenerateAIScoresheetAnswersAsync(Guid applicationId); + Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs new file mode 100644 index 0000000000..ec69a9de20 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI +{ + public class AIPromptCaptureStore : IAIPromptCaptureStore, ISingletonDependency + { + private const int MaxCapturesPerKey = 50; + private readonly ConcurrentDictionary> _captures = new(StringComparer.OrdinalIgnoreCase); + + public void Save(AIPromptCaptureResponse capture) + { + var key = BuildKey(capture.ContextId, capture.PromptType, capture.PromptVersion); + var queue = _captures.GetOrAdd(key, _ => new ConcurrentQueue()); + queue.Enqueue(capture); + + while (queue.Count > MaxCapturesPerKey) + { + queue.TryDequeue(out _); + } + } + + public IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20) + { + if (!string.IsNullOrWhiteSpace(promptVersion)) + { + var key = BuildKey(contextId, promptType, promptVersion); + return _captures.TryGetValue(key, out var captures) + ? captures.OrderByDescending(item => item.CapturedAt).Take(maxResults).ToList() + : Array.Empty(); + } + + return _captures.Values + .SelectMany(queue => queue) + .Where(item => string.Equals(item.ContextId, contextId, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.PromptType, promptType, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.CapturedAt) + .Take(maxResults) + .ToList(); + } + + private static string BuildKey(string contextId, string promptType, string promptVersion) + { + return $"{contextId.Trim()}::{promptType.Trim()}::{promptVersion.Trim()}"; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs index 2234f9ef8d..4b633cfd82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -24,12 +24,17 @@ public class ApplicationAnalysisService( }; private const string ComponentsKey = "components"; + private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + { + "applicantAgent" + }; - public async Task RegenerateAndSaveAsync(Guid applicationId) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { var application = await applicationRepository.GetAsync(applicationId); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); var attachmentSummaries = attachments .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) @@ -40,24 +45,6 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) }) .ToList(); - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; - object formFieldConfiguration = new { message = "Form configuration not available." }; if (formSubmission?.ApplicationFormVersionId != null) { @@ -67,8 +54,11 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest { Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), - Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), - Attachments = attachmentSummaries + Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger), + Attachments = attachmentSummaries, + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = applicationId.ToString() }); var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); @@ -77,6 +67,25 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) return analysisJson; } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) { try @@ -120,7 +129,7 @@ private static void ExtractFieldRequirements(JArray components, List req var type = component["type"]?.ToString(); var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type)) + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) { ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); continue; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs index dcef966ea1..82b7c12ae4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -14,6 +14,7 @@ public class ApplicationScoresheetAnalysisService( IApplicationRepository applicationRepository, IApplicationFormRepository applicationFormRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, IAIService aiService, @@ -30,7 +31,7 @@ public class ApplicationScoresheetAnalysisService( WriteIndented = true }; - public async Task RegenerateAndSaveAsync(Guid applicationId) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { var application = await applicationRepository.GetAsync(applicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); @@ -56,23 +57,8 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) .ToList(); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger); var allSectionResults = new Dictionary(); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -82,23 +68,27 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) var sectionQuestionsData = new List(); foreach (var field in section.Fields.OrderBy(f => f.Order)) { + var options = ExtractSelectListOptions(field); sectionQuestionsData.Add(new { id = field.Id.ToString(), question = field.Label, description = field.Description, type = field.Type.ToString(), - definition = field.Definition, - availableOptions = ExtractSelectListOptions(field) + options, + allowed_answers = ExtractSelectListOptionNumbers(options) }); } var sectionRequest = new ScoresheetSectionRequest { - Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), + Data = promptData, Attachments = attachmentSummaries, SectionName = section.Name, - SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions) + SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions), + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = applicationId.ToString() }; var sectionAnswers = await aiService.GenerateScoresheetSectionAsync(sectionRequest); @@ -122,10 +112,28 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) var validatedJson = ValidateScoresheetJson(combinedResults); application.AIScoresheetAnswers = validatedJson; await applicationRepository.UpdateAsync(application); - return validatedJson; } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for scoresheet prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private static string ValidateScoresheetJson(string scoresheetAnswers) { try @@ -144,7 +152,7 @@ private static string ValidateScoresheetJson(string scoresheetAnswers) return "{}"; } - private static (int number, string value, long numericValue)[]? ExtractSelectListOptions(Question field) + private static object[]? ExtractSelectListOptions(Question field) { if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) return null; @@ -155,7 +163,12 @@ private static (int number, string value, long numericValue)[]? ExtractSelectLis if (definition?.Options != null && definition.Options.Count > 0) { return definition.Options - .Select((option, index) => (number: index, value: option.Value, numericValue: option.NumericValue)) + .Select((option, index) => + (object)new + { + number = index + 1, + value = option.Value + }) .ToArray(); } } @@ -166,5 +179,17 @@ private static (int number, string value, long numericValue)[]? ExtractSelectLis return null; } + + private static string[]? ExtractSelectListOptionNumbers(object[]? options) + { + if (options == null || options.Length == 0) + { + return null; + } + + return options + .Select((_, index) => (index + 1).ToString()) + .ToArray(); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs new file mode 100644 index 0000000000..7c2e5d301a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.AI +{ + public interface IAIPromptCaptureStore + { + void Save(AIPromptCaptureResponse capture); + IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs index cdb31bf8b2..172a3b9c5a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs @@ -5,6 +5,6 @@ namespace Unity.GrantManager.AI { public interface IApplicationAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs index 73a272fd3f..1cc4ef1ffa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs @@ -5,6 +5,6 @@ namespace Unity.GrantManager.AI { public interface IApplicationScoresheetAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 063968a272..bdc4d248e5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -19,6 +19,7 @@ public class OpenAIService : IAIService, ITransientDependency private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; + private readonly IAIPromptCaptureStore _promptIoCaptureStore; private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; private const string AttachmentSummaryPromptType = "AttachmentSummary"; private const string ScoresheetSectionPromptType = "ScoresheetSection"; @@ -62,12 +63,14 @@ public OpenAIService( HttpClient httpClient, IConfiguration configuration, ILogger logger, - ITextExtractionService textExtractionService) + ITextExtractionService textExtractionService, + IAIPromptCaptureStore promptIoCaptureStore) { _httpClient = httpClient; _configuration = configuration; _logger = logger; _textExtractionService = textExtractionService; + _promptIoCaptureStore = promptIoCaptureStore; } public Task IsAvailableAsync() @@ -93,6 +96,9 @@ public async Task GenerateCompletionAsync(AICompletionRequ public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { + ArgumentNullException.ThrowIfNull(request); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -105,15 +111,16 @@ public async Task GenerateApplicationAnalysisAsync( .Cast(); var attachments = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); - var systemPrompt = BuildAnalysisSystemPrompt(SelectedPromptVersion); + var systemPrompt = BuildAnalysisSystemPrompt(promptVersion); var analysisContent = BuildAnalysisUserPrompt( - SelectedPromptVersion, + promptVersion, schema, data, attachments); - await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); + await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, analysisContent); var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, raw); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, raw); return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); } @@ -193,14 +200,17 @@ private async Task GenerateSummaryAsync( public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) { - var fileName = request?.FileName ?? string.Empty; - var fileContent = request?.FileContent ?? Array.Empty(); - var contentType = request?.ContentType ?? "application/octet-stream"; + ArgumentNullException.ThrowIfNull(request); + var fileName = request.FileName ?? string.Empty; + var fileContent = request.FileContent ?? Array.Empty(); + var contentType = request.ContentType ?? "application/octet-stream"; + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; try { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - var prompt = BuildAttachmentSystemPrompt(SelectedPromptVersion); + var prompt = BuildAttachmentSystemPrompt(promptVersion); var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; if (attachmentText != null) @@ -220,11 +230,12 @@ public async Task GenerateAttachmentSummaryAsync(Atta text = attachmentText }; var attachment = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); - var contentToAnalyze = BuildAttachmentUserPrompt(SelectedPromptVersion, attachment); + var contentToAnalyze = BuildAttachmentUserPrompt(promptVersion, attachment); - await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); + await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, modelOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, modelOutput); return new AttachmentSummaryResponse { @@ -314,6 +325,9 @@ private string AddIdsToAnalysisItems(string analysisJson) public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { + ArgumentNullException.ThrowIfNull(request); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); @@ -363,16 +377,17 @@ public async Task GenerateScoresheetSectionAsync(Scor } var analysisContent = BuildScoresheetSectionUserPrompt( - SelectedPromptVersion, + promptVersion, dataJson, attachments, section, response); - var systemPrompt = BuildScoresheetSectionSystemPrompt(SelectedPromptVersion); + var systemPrompt = BuildScoresheetSectionSystemPrompt(promptVersion); - await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); + await LogPromptInputAsync(ScoresheetSectionPromptType, promptVersion, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetSectionPromptType, modelOutput); + await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, modelOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, modelOutput); return ParseScoresheetSectionResponse(modelOutput); } @@ -596,21 +611,21 @@ private static string BuildScoresheetSectionResponseTemplate(string sectionPaylo } } - private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) + private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, SelectedPromptVersion, formattedInput); - await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, promptVersion, formattedInput); + await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput); } - private async Task LogPromptOutputAsync(string promptType, string output) + private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output) { var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, SelectedPromptVersion, formattedOutput); - await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, promptVersion, formattedOutput); + await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput); } - private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) + private async Task WritePromptLogFileAsync(string promptType, string promptVersion, string payloadType, string payload) { if (!CanWritePromptFileLog()) { @@ -624,7 +639,7 @@ private async Task WritePromptLogFileAsync(string promptType, string payloadType Directory.CreateDirectory(logDirectory); var logPath = Path.Combine(logDirectory, PromptLogFileName); - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + var entry = $"{now} [{promptType}] [{promptVersion}] {payloadType}\n{payload}\n\n"; await File.AppendAllTextAsync(logPath, entry); } catch (Exception ex) @@ -638,6 +653,27 @@ private bool CanWritePromptFileLog() return IsPromptFileLoggingEnabled; } + private void SavePromptCapture(bool capturePromptIo, string? contextId, string promptType, string promptVersion, string captureLabel, string? systemPrompt, string userPrompt, string rawOutput) + { + if (!capturePromptIo || string.IsNullOrWhiteSpace(contextId)) + { + return; + } + + _promptIoCaptureStore.Save(new AIPromptCaptureResponse + { + ContextId = contextId, + PromptType = promptType, + PromptVersion = promptVersion, + CaptureLabel = captureLabel?.Trim() ?? string.Empty, + SystemPrompt = systemPrompt?.Trim() ?? string.Empty, + UserPrompt = userPrompt?.Trim() ?? string.Empty, + RawOutput = rawOutput?.Trim() ?? string.Empty, + FormattedOutput = FormatPromptOutputForLog(rawOutput ?? string.Empty), + CapturedAt = DateTime.UtcNow + }); + } + private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) { var normalizedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? string.Empty : systemPrompt.Trim(); @@ -891,7 +927,7 @@ private static string RenderPromptTemplateInternal( version, fragmentTemplateName, new Dictionary(StringComparer.Ordinal), - resolutionStack); + resolutionStack).TrimEnd(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs new file mode 100644 index 0000000000..8bea35cab0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs @@ -0,0 +1,226 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Unity.GrantManager.Applications; + +namespace Unity.GrantManager.AI +{ + internal static class PromptDataPayloadBuilder + { + private static readonly string[] ExcludedPromptDataKeys = + { + "simplefile", + "applicantAgent", + "submit", + "lateEntry", + "metadata", + "full_application_form_submission", + "files", + "file", + "attachments" + }; + + private static readonly HashSet NonDataComponentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "button", + "simplebuttonadvanced", + "html", + "htmlelement", + "content", + "simpleseparator" + }; + + public static JsonElement BuildPromptDataPayload( + Application application, + ApplicationFormSubmission? formSubmission, + string? formSchema, + ILogger logger) + { + var fallbackPayload = BuildFallbackPromptDataPayload(application); + if (TryBuildPromptDataValues(formSubmission?.Submission, formSchema, out var values, out var exception)) + { + return JsonSerializer.SerializeToElement(values); + } + + if (exception != null) + { + logger.LogWarning( + exception, + "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", + application.Id); + } + + return JsonSerializer.SerializeToElement(fallbackPayload); + } + + private static object BuildFallbackPromptDataPayload(Application application) + { + var notSpecified = "Not specified"; + return new + { + project_name = application.ProjectName, + reference_number = application.ReferenceNo, + requested_amount = application.RequestedAmount, + total_project_budget = application.TotalProjectBudget, + project_summary = application.ProjectSummary ?? "Not provided", + city = application.City ?? notSpecified, + economic_region = application.EconomicRegion ?? notSpecified, + community = application.Community ?? notSpecified, + project_start_date = application.ProjectStartDate, + project_end_date = application.ProjectEndDate, + submission_date = application.SubmissionDate + }; + } + + private static bool TryBuildPromptDataValues( + string? submissionJson, + string? formSchema, + out Dictionary values, + out Exception? exception) + { + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + exception = null; + + if (string.IsNullOrWhiteSpace(submissionJson)) + { + return false; + } + + try + { + using var submissionDoc = JsonDocument.Parse(submissionJson); + if (!TryExtractSubmissionDataObject(submissionDoc.RootElement, out var submissionData)) + { + return false; + } + + values = BuildPromptDataValues(submissionData, formSchema); + return true; + } + catch (Exception ex) + { + exception = ex; + return false; + } + } + + private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonElement submissionData) + { + submissionData = root; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Object) + { + submissionData = dataElement; + return true; + } + + if (root.TryGetProperty("submission", out var submissionElement) && + submissionElement.ValueKind == JsonValueKind.Object && + submissionElement.TryGetProperty("data", out var nestedDataElement) && + nestedDataElement.ValueKind == JsonValueKind.Object) + { + submissionData = nestedDataElement; + return true; + } + + return true; + } + + private static Dictionary BuildPromptDataValues(JsonElement submissionData, string? formSchema) + { + var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? + new Dictionary(); + var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); + var allowedSchemaKeys = ExtractAllowedSchemaKeys(formSchema); + + foreach (var excludedKey in ExcludedPromptDataKeys) + { + values.Remove(excludedKey); + } + + if (allowedSchemaKeys.Count > 0) + { + foreach (var key in values.Keys.ToList()) + { + if (!allowedSchemaKeys.Contains(key)) + { + values.Remove(key); + } + } + } + + return values; + } + + private static HashSet ExtractAllowedSchemaKeys(string? formSchema) + { + if (string.IsNullOrWhiteSpace(formSchema)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + try + { + var schema = JObject.Parse(formSchema); + if (schema["components"] is not JArray components) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + ExtractSchemaKeys(components, keys); + return keys; + } + catch + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private static void ExtractSchemaKeys(JArray components, HashSet keys) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var type = component["type"]?.ToString(); + var isInput = component["input"]?.Value() == true; + + if (!string.IsNullOrWhiteSpace(key) && + !string.IsNullOrWhiteSpace(type) && + !NonDataComponentTypes.Contains(type) && + isInput) + { + keys.Add(key); + } + + ProcessNestedSchemaComponents(component, keys); + } + } + + private static void ProcessNestedSchemaComponents(JObject component, HashSet keys) + { + if (component["components"] is JArray nestedComponents) + { + ExtractSchemaKeys(nestedComponents, keys); + } + + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column["components"] is JArray columnComponents) + { + ExtractSchemaKeys(columnComponents, keys); + } + } + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index 124a84a4e2..b13f42bddb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -2,13 +2,31 @@ - Do not invent fields, documents, requirements, or facts. - Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material issues; avoid nitpicking. +- Ignore evidence that is not relevant to a reviewer-facing conclusion. +- Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. +- Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. +- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. - Use 3-6 words for title. +- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- When citing a positive conclusion, explain why that evidence matters for readiness, feasibility, or funding confidence. +- Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. +- Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. +- Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. +- Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. +- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. +- Avoid generic praise, generic checklist language, and repeated conclusions across lists. +- Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. +- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. +- Only include nextSteps when there is a specific evidence gap, inconsistency, or verification need; otherwise return an empty array. - recommendation.decision must be PROCEED or HOLD. +- Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. +- recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt index 26953aa2f3..a35dd58acf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt @@ -1,5 +1,10 @@ ROLE -You are an expert grant analyst assistant for human reviewers. +You are a careful grant analyst assistant for human reviewers. You do not fill gaps or turn weak signals into strong reviewer conclusions. TASK -Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES, return review findings. +Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: +1. Identify the strongest reviewer-relevant evidence in the application and attachments. +2. Determine which conclusions are directly supported by that evidence. +3. Exclude weak, repetitive, or loosely supported conclusions. +4. Before finalizing each conclusion, ask whether the evidence directly supports it and whether a more neutral description would be more accurate. +5. Return only the strongest evidence-backed reviewer conclusions. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 63427ac0e5..8008ef059a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -1,7 +1,10 @@ - Use only ATTACHMENT as evidence. -- If ATTACHMENT.text is present, summarize actual content. -- If ATTACHMENT.text is null or empty, provide a conservative file-level summary. +- Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. +- Ignore attachment details that are not relevant to describing what the file contains or contributes. +- Describe the attachment itself, including its apparent function or content type when supported by the evidence, rather than summarizing the overall project. +- If ATTACHMENT.text is primarily structured application, contact, organization, budget, or date fields, summarize it as a metadata-style attachment rather than rewriting it as a generic project summary. - Do not invent missing details. +- Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. - Return exactly one object with only the key: summary. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt index cb59d46c27..525825366e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt @@ -1,5 +1,10 @@ ROLE -You are a professional grant analyst for the BC Government. +You are a careful grant analyst assistant for human reviewers. You do not fill gaps or summarize the overall project when the attachment itself is the evidence. TASK -Produce a concise reviewer-facing summary of the provided attachment context. +Using ATTACHMENT, OUTPUT, and RULES: +1. Identify what the attachment contains. +2. Determine what type of attachment it appears to be, when the evidence supports that. +3. Summarize only the attachment-specific content or evidence it provides. +4. Before finalizing the summary, check that it describes the attachment itself and not the overall project. +5. Return a concise reviewer-facing summary. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index 41c8d580f4..bbdaaf55c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -3,29 +3,51 @@ - Return exactly one answer object per question ID in SECTION.questions. - Do not omit any question IDs from SECTION.questions. - Do not add keys that are not question IDs from SECTION.questions. +- Use the exact question IDs from RESPONSE and SECTION.questions without alteration; never rewrite, normalize, or regenerate a question ID. - Use RESPONSE as the output contract and fill every placeholder value. -- Follow this process in order: (1) copy RESPONSE, (2) iterate SECTION.questions in order, (3) fill answer+rationale+confidence for each matching question ID, (4) run final completeness check. - Each answer object must include: "answer", "rationale", and "confidence". - Never omit "answer", "rationale", or "confidence" for any question type. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- The "rationale" field must be 1-2 complete sentences and grounded in concrete DATA/ATTACHMENTS evidence. -- In "rationale", cite concrete source evidence from the provided input content; do not cite prompt section headers. +- The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. +- In rationale, cite concrete source evidence from the provided input content rather than prompt section headers. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- Do not treat missing or non-contradictory information as evidence. +- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. +- Ignore fields or details that are not relevant to the specific question being answered. +- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. +- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. +- Treat prefilled labels, ratings, rankings, or statuses in DATA as background context only; do not use them as evidence unless the question explicitly asks you to report that same item. +- Do not use one field's prior classification, rating, or judgment as evidence for a different question unless the question explicitly asks for that same classification, rating, or judgment. +- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. +- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. - The "confidence" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. - Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. +- Do not use maximum or near-maximum confidence when the answer depends on inference rather than an explicit statement of the exact condition. - For yes/no questions, the "answer" field must be exactly "Yes" or "No". - For numeric questions, answer must be a numeric value within the allowed range. - For numeric questions, answer must never be blank. - If evidence is insufficient for a numeric question, return the minimum allowed numeric value and explain uncertainty in rationale. - If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. -- For select list questions, return only the selected options.number as a string (the option index shown in options), never label text or points. -- For select list questions, the "answer" value must be one of question.allowed_answers exactly. +- For select list questions, use the matching SECTION.questions[].options entries and return only the selected options[].number as a string. +- For select list questions, the "answer" value must be one of the matching question.allowed_answers values exactly. +- For select list questions, return only the option number string, never the option label text such as "Yes", "No", or "N/A". - Never return 0 for select list answers unless 0 exists as an explicit option number. +- For select list questions, choose the lowest option fully supported by the evidence; use a higher option only when the specific condition and required strength are directly supported. +- If evidence supports the existence of a topic but not the required strength, completeness, or specificity, choose the lowest option consistent with that evidence. +- If evidence is insufficient for a select list question, choose the lowest allowed answer value from question.allowed_answers and explain the uncertainty. +- Do not treat broad project descriptions, general goals, high-level timelines, budget presence, or a single indirect reference as sufficient evidence for a higher-scored select-list answer. +- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. +- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. +- If a question asks whether something is eligible, complete, appropriate, or satisfied, require direct evidence of that exact condition rather than general relevance or presence. - For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. - For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. -- For comment fields, summarize key evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable. +- If no concerns are identified for a text or text area question, return a short non-empty evidence-based comment rather than leaving answer blank. +- For comment fields, summarize only the evidence-based conclusions supported by the scored answers, including uncertainty where applicable, and do not introduce stronger claims. +- For narrative comment fields, keep wording aligned with the scored answers and evidence; do not use stronger certainty or impact language than the evidence supports. +- For comment fields, describe the evidence and resulting answer without elevating it into an overall assessment unless the question explicitly asks for one. +- Do not treat the presence of a named person, partner, organization, location, document, or field as proof of a separate requirement, condition, or relationship unless that exact point is explicitly evidenced. +- Do not add recommendations or stronger conclusions unless the question explicitly asks for them. +- For comment fields, always provide a concise evidence-based summary even when no concerns are identified. +- For comment fields, do not leave answer empty even when all other answers are positive. - Do not leave rationale empty when answer is populated. - Final self-check before responding: every question ID in RESPONSE must have a non-empty "answer", non-empty "rationale", and "confidence". - If any answer object is incomplete, regenerate the full JSON response before returning it. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt index 3b180cb289..855286f824 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt @@ -1,5 +1,12 @@ ROLE -You are an expert grant application reviewer for the BC Government. +You are a careful grant review assistant for human reviewers. You do not fill gaps, assume compliance, or treat relevance as proof. TASK -Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES, answer only the questions in SECTION. +Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: +1. Review each question in SECTION one at a time. +2. Identify the exact condition the question asks about. +3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. +4. Choose the most conservative valid answer supported by that evidence. +5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. +6. Before finalizing each answer, ask: "What exact evidence supports this condition?" If no direct evidence exists, choose the most conservative valid answer. +7. Repeat for every question in SECTION. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 44d54844b6..c047e7e4fb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -16,7 +16,9 @@ namespace Unity.GrantManager.ApplicantProfile /// Provides address information for the applicant profile by querying /// application addresses linked to the applicant's form submissions. /// Addresses are resolved via both the ApplicationId and ApplicantId - /// relationships, with duplicates removed. + /// relationships, with duplicates removed. Addresses linked via + /// ApplicationId are always read-only. Addresses linked via ApplicantId + /// are editable only when that set resolves to a single ApplicantId. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class AddressInfoDataProvider( @@ -37,10 +39,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Addresses = [] }; - var subject = request.Subject ?? string.Empty; - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; using (currentTenant.Change(request.TenantId)) { @@ -56,26 +56,34 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ from submission in matchingSubmissions join address in addressesQuery on submission.ApplicationId equals address.ApplicationId join application in applicationsQuery on address.ApplicationId equals application.Id - select new { address, address.CreationTime, application.ReferenceNo, IsEditable = false }; + select new { address, address.CreationTime, application.ReferenceNo, IsFromApplicantPath = false, address.ApplicantId }; - // Addresses linked via ApplicantId — editable (directly from the applicant) + // Addresses linked via ApplicantId — conditionally editable var byApplicantId = from submission in matchingSubmissions join address in addressesQuery on submission.ApplicantId equals address.ApplicantId join application in applicationsQuery on address.ApplicationId equals application.Id into apps from application in apps.DefaultIfEmpty() - select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsEditable = true }; + select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsFromApplicantPath = true, address.ApplicantId }; var results = await byApplicationId .Concat(byApplicantId) .ToListAsync(); - // Deduplicate by address Id — application-linked (IsEditable = false) takes priority + // Deduplicate by address Id — application-linked (IsFromApplicantPath = false) takes priority var deduplicated = results .GroupBy(r => r.address.Id) - .Select(g => g.OrderBy(r => r.IsEditable).First()) + .Select(g => g.OrderBy(r => r.IsFromApplicantPath).First()) .ToList(); + // Addresses from the ApplicantId path are editable only when + // that path resolves to a single ApplicantId + var applicantPathEditable = results + .Where(r => r.IsFromApplicantPath && r.ApplicantId != null) + .Select(r => r.ApplicantId) + .Distinct() + .Count() <= 1; + var addressDtos = deduplicated.Select(r => new AddressInfoItemDto { Id = r.address.Id, @@ -88,7 +96,7 @@ from application in apps.DefaultIfEmpty() PostalCode = r.address.Postal ?? string.Empty, Country = r.address.Country ?? string.Empty, IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"), - IsEditable = r.IsEditable, + IsEditable = r.IsFromApplicantPath && applicantPathEditable, ReferenceNo = r.ReferenceNo }).ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs index df8617813d..e7146685ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs @@ -67,9 +67,8 @@ public async Task GetApplicantProfileAsync(ApplicantProfile public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request) { // Extract the username part from the OIDC sub (part before '@') - var subUsername = request.Subject.Contains('@') - ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() - : request.Subject.ToUpperInvariant(); + var subUsername = SubjectNormalizer.Normalize(request.Subject); + if (subUsername is null) return []; // Query the ApplicantTenantMaps table in the host database using (currentTenant.Change(null)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs index e028bb1b27..5062536063 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -26,6 +26,9 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Contacts = [] }; + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; + var tenantId = request.TenantId; using (currentTenant.Change(tenantId)) @@ -33,10 +36,6 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId); dto.Contacts.AddRange(profileContacts); - var normalizedSubject = request.Subject.Contains('@') - ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() - : request.Subject.ToUpperInvariant(); - var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject); dto.Contacts.AddRange(applicationContacts); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index cc0bc93682..0534e2f0a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -1,23 +1,82 @@ +using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.ApplicantProfile { /// - /// Provides organization information for the applicant profile. - /// This is a placeholder provider for future implementation. + /// Provides organization information for the applicant profile by querying + /// applicants linked to the applicant's form submissions via OIDC subject. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency + public class OrgInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicantRepository) + : IApplicantProfileDataProvider, ITransientDependency { /// public string Key => ApplicantProfileKeys.OrgInfo; /// - public Task GetDataAsync(ApplicantProfileInfoRequest request) + public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - return Task.FromResult(new ApplicantOrgInfoDto()); + var dto = new ApplicantOrgInfoDto + { + Organizations = [] + }; + + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicantsQuery = await applicantRepository.GetQueryableAsync(); + + var results = await ( + from submission in submissionsQuery + join applicant in applicantsQuery on submission.ApplicantId equals applicant.Id + where submission.OidcSub == normalizedSubject + select new + { + applicant.Id, + applicant.OrgName, + applicant.OrganizationType, + applicant.OrgNumber, + applicant.OrgStatus, + applicant.NonRegOrgName, + applicant.FiscalMonth, + applicant.FiscalDay, + applicant.OrganizationSize, + applicant.Sector, + applicant.SubSector + }) + .ToListAsync(); + + dto.Organizations.AddRange(results.Select(r => new OrgInfoItemDto + { + Id = r.Id, + OrgName = r.OrgName, + OrganizationType = r.OrganizationType, + OrgNumber = r.OrgNumber, + OrgStatus = r.OrgStatus, + NonRegOrgName = r.NonRegOrgName, + FiscalMonth = r.FiscalMonth, + FiscalDay = r.FiscalDay, + OrganizationSize = r.OrganizationSize, + Sector = r.Sector, + SubSector = r.SubSector + })); + } + + return dto; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index 9b7b86e62f..0fb17d0f99 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -1,23 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; using System.Threading.Tasks; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.Payments.Domain.PaymentRequests; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.ApplicantProfile { /// - /// Provides payment information for the applicant profile. - /// This is a placeholder provider for future implementation. + /// Provides payment information for the applicant profile by querying + /// payment requests linked to the applicant's form submissions. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class PaymentInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency + public class PaymentInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicationRepository, + IRepository paymentRequestRepository) + : IApplicantProfileDataProvider, ITransientDependency { /// public string Key => ApplicantProfileKeys.PaymentInfo; /// - public Task GetDataAsync(ApplicantProfileInfoRequest request) + public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - return Task.FromResult(new ApplicantPaymentInfoDto()); + var dto = new ApplicantPaymentInfoDto + { + Payments = [] + }; + + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var applicationLookup = await ( + from submission in submissionsQuery + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == normalizedSubject + select new { application.Id, application.ReferenceNo } + ).Distinct().ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); + + if (applicationLookup.Count == 0) return dto; + + // Payment info is secured via feature flags and permissions, so direct query for this data instead of using module service + + var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); + var paymentDetails = await paymentsQueryable + .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId)) + .ToListAsync(); + + dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto + { + Id = p.Id, + PaymentNumber = p.PaymentNumber ?? string.Empty, + ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, + Amount = p.Amount, + PaymentDate = p.PaymentDate, + PaymentStatus = p.Status.ToString() + })); + } + + return dto; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs new file mode 100644 index 0000000000..11c4d29b20 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs @@ -0,0 +1,26 @@ +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Centralises OIDC subject normalization used by applicant-profile providers. + /// Strips the domain portion after '@' and upper-cases the result. + /// Returns null when the input is null, empty, or whitespace so + /// callers can short-circuit before hitting the database. + /// + public static class SubjectNormalizer + { + public static string? Normalize(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return null; + + var atIndex = subject.IndexOf('@'); + + if (atIndex == 0) + return null; + + return atIndex > 0 + ? subject[..atIndex].ToUpperInvariant() + : subject.ToUpperInvariant(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 22a37c1687..6d67a53a31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -40,10 +40,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Submissions = [] }; - var subject = request.Subject ?? string.Empty; - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; dto.LinkSource = await ResolveFormViewUrlAsync(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs index 029466de6c..bd12f4410f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs @@ -189,7 +189,7 @@ protected internal static async Task UpdateMetadataIntern return attachment.CreatorId; } - public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) + public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false) { if (!await aiService.IsAvailableAsync()) { @@ -205,7 +205,10 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) { FileName = fileName, FileContent = fileContent, - ContentType = contentType + ContentType = contentType, + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = attachment.ApplicationId.ToString() }); attachment.AISummary = summaryResponse.Summary; @@ -214,7 +217,7 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) return summaryResponse.Summary; } - public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds) + public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false) { if (!await aiService.IsAvailableAsync()) { @@ -228,7 +231,7 @@ public async Task> GenerateAISummariesAttachmentsAsync(List a { try { - var summary = await GenerateAISummaryAttachmentAsync(attachmentId); + var summary = await GenerateAISummaryAttachmentAsync(attachmentId, promptVersion, capturePromptIo); summaries.Add(summary); } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs index 8f7bb06130..9858838ff8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs @@ -10,11 +10,11 @@ public class ApplicationAIAnalysisAppService( IApplicationAnalysisService applicationAnalysisService) : GrantManagerAppService, IApplicationAIAnalysisAppService { - public async Task GenerateAIAnalysisAsync(Guid applicationId) + public async Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId); + return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs new file mode 100644 index 0000000000..8780c833f3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Hosting; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.AI; +using Volo.Abp; + +namespace Unity.GrantManager.GrantApplications +{ + public class ApplicationAIPromptCaptureAppService( + IAIPromptCaptureStore promptIoCaptureStore, + IWebHostEnvironment webHostEnvironment) + : GrantManagerAppService, IApplicationAIPromptCaptureAppService + { + public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) + { + if (!string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase)) + { + throw new UserFriendlyException("Prompt capture is only available in development."); + } + + if (string.IsNullOrWhiteSpace(promptType)) + { + return Task.FromResult(new List()); + } + + var captures = promptIoCaptureStore.GetRecent(applicationId.ToString(), promptType, promptVersion); + return Task.FromResult(new List(captures)); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs index c777fd61f4..577dc6c6f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs @@ -13,11 +13,11 @@ public class ApplicationAIScoringAppService( IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService) : GrantManagerAppService, IApplicationAIScoringAppService { - public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId) + public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId); + return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index e61690138a..ddfa482d1b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -11,7 +11,6 @@ using System.Diagnostics; using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 0f864af8e3..0204698482 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -41,6 +41,10 @@ using Unity.GrantManager.Integrations.Chefs; using Unity.Modules.Shared.Http; using Unity.GrantManager.Integrations.Geocoder; +using Unity.GrantManager.GrantsPortal; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.Messaging; namespace Unity.GrantManager; @@ -147,6 +151,29 @@ public override void ConfigureServices(ServiceConfigurationContext context) } context.Services.ConfigureRabbitMQ(); + + // Grants Applicant Portal RabbitMQ integration + context.Services.Configure(configuration.GetSection(GrantsPortalRabbitMqOptions.SectionName)); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + + // Register generic IInboxMessageHandler adapters for each portal command handler + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + + context.Services.AddScoped(); + context.Services.AddHostedService(); // RabbitMQ → inbox table + context.Services.AddScoped(); context.Services.AddSingleton(provider => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs new file mode 100644 index 0000000000..e8613a13a9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs @@ -0,0 +1,37 @@ +namespace Unity.GrantManager.GrantsPortal.Configuration; + +public class GrantsPortalRabbitMqOptions +{ + public const string SectionName = "RabbitMQ:GrantsPortal"; + + /// + /// The integration source identifier used for Grants Portal inbox/outbox messages.. + /// + public const string SourceName = "GrantsPortal"; + + public string Exchange { get; set; } = "grants.messaging"; + public string ExchangeType { get; set; } = "topic"; + public string InboundQueue { get; set; } = "unity.commands"; + public string[] InboundRoutingKeys { get; set; } = ["commands.unity.plugindata"]; + public string AckRoutingKey { get; set; } = "grants.unity.acknowledgment"; + + /// + /// Number of days to retain processed/failed messages before cleanup. + /// + public int MessageRetentionDays { get; set; } = 7; + + /// + /// Cron expression for the inbox processor worker. Default: every 5 seconds. + /// + public string InboxProcessorCron { get; set; } = "0/5 * * * * ?"; + + /// + /// Cron expression for the outbox processor worker. Default: every 5 seconds. + /// + public string OutboxProcessorCron { get; set; } = "0/5 * * * * ?"; + + /// + /// Cron expression for the message cleanup worker. Default: once a day at midnight. + /// + public string MessageCleanupCron { get; set; } = "0 0 0 * * ?"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs new file mode 100644 index 0000000000..c3aeda6258 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs @@ -0,0 +1,59 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal; + +public class GrantsPortalAcknowledgmentPublisher( + IOptions options, + ILogger logger) +{ + private readonly GrantsPortalRabbitMqOptions _options = options.Value; + private static readonly JsonSerializerSettings s_jsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + public void Publish(IModel channel, string originalMessageId, string correlationId, string status, string details) + { + var ack = new MessageAcknowledgment + { + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = originalMessageId, + CorrelationId = correlationId, + Status = status, + Details = details, + CreatedAt = DateTime.UtcNow, + ProcessedAt = DateTime.UtcNow + }; + + var json = JsonConvert.SerializeObject(ack, s_jsonSettings); + var body = Encoding.UTF8.GetBytes(json); + + var properties = channel.CreateBasicProperties(); + properties.Type = "MessageAcknowledgment"; + properties.ContentType = "application/json"; + properties.ContentEncoding = "utf-8"; + properties.Persistent = true; + properties.MessageId = ack.MessageId; + properties.CorrelationId = correlationId; + properties.Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + channel.BasicPublish( + exchange: _options.Exchange, + routingKey: _options.AckRoutingKey, + basicProperties: properties, + body: body); + + logger.LogInformation( + "Published {Status} acknowledgment for message {OriginalMessageId} with ack id {AckMessageId}", + status, originalMessageId, ack.MessageId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs new file mode 100644 index 0000000000..c4c9ed0f6a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -0,0 +1,377 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Pulls messages off the RabbitMQ queue, saves them to the inbox table, and ACKs immediately. +/// Runs on every pod as a competing consumer — RabbitMQ distributes messages round-robin. +/// Actual processing is done by . +/// +public class GrantsPortalCommandConsumerService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) : BackgroundService +{ + private readonly GrantsPortalRabbitMqOptions _options = options.Value; + + // Guards against concurrent reconnect attempts within this process. + // RabbitMQ can fire ConnectionShutdown multiple times in rapid succession + // (e.g., network flap, broker restart) on different threadpool threads. + // Without this, parallel Task.Run calls would race on the shared + // _connection/_channel fields — one disposes while the other connects. + // This is NOT for cross-pod coordination (RabbitMQ handles that). + private readonly SemaphoreSlim _reconnectLock = new(1, 1); + + private IConnection? _connection; + private IModel? _channel; + + private const int MaxRetries = 5; + private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan SlowRetryInterval = TimeSpan.FromSeconds(60); + private CancellationToken _stoppingToken; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _stoppingToken = stoppingToken; + logger.LogInformation("Grants Portal command consumer starting..."); + + await ConnectAndConsumeAsync(stoppingToken); + + // Keep the service alive until cancellation + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException ex) + { + logger.LogInformation(ex, "Grants Portal command consumer stopping..."); + } + } + + private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) + { + for (int attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); + + _connection = connectionFactory.CreateConnection(); + _connection.ConnectionShutdown += OnConnectionShutdown; + _channel = _connection.CreateModel(); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + DeclareTopology(); + StartConsuming(); + + logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); + return; + } + catch (Exception ex) when (attempt < MaxRetries) + { + var delay = TimeSpan.FromSeconds(InitialRetryDelay.TotalSeconds * Math.Pow(2, attempt - 1)); + logger.LogWarning(ex, "Failed to connect to RabbitMQ (attempt {Attempt}). Retrying in {Delay}s...", attempt, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts. Entering slow reconnect loop (every {Interval}s).", + MaxRetries, SlowRetryInterval.TotalSeconds); + await SlowReconnectLoopAsync(cancellationToken); + } + } + } + + /// + /// Long-lived reconnect loop entered after the fast exponential-backoff retries are exhausted. + /// Retries at a fixed interval until the connection succeeds or the service is stopped. + /// This avoids leaving the pod alive-but-idle when the broker is down for an extended period. + /// + private async Task SlowReconnectLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SlowRetryInterval, cancellationToken); + + try + { + logger.LogInformation("Slow reconnect: attempting to connect to RabbitMQ..."); + CleanupConnection(); + + _connection = connectionFactory.CreateConnection(); + _connection.ConnectionShutdown += OnConnectionShutdown; + _channel = _connection.CreateModel(); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + DeclareTopology(); + StartConsuming(); + + logger.LogInformation("Slow reconnect: successfully reconnected. Listening on queue {Queue}", _options.InboundQueue); + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutting down — expected + } + catch (Exception ex) + { + logger.LogWarning(ex, "Slow reconnect: still unable to connect to RabbitMQ. Will retry in {Interval}s.", SlowRetryInterval.TotalSeconds); + } + } + } + + private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) + { + if (_stoppingToken.IsCancellationRequested) return; + + logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); + + _ = Task.Run(async () => + { + if (!await _reconnectLock.WaitAsync(0, _stoppingToken)) + { + logger.LogDebug("Reconnect already in progress, skipping duplicate attempt"); + return; + } + + try + { + await Task.Delay(InitialRetryDelay, _stoppingToken); + CleanupConnection(); + await ConnectAndConsumeAsync(_stoppingToken); + } + catch (OperationCanceledException) when (_stoppingToken.IsCancellationRequested) + { + // Shutting down — expected + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); + } + finally + { + _reconnectLock.Release(); + } + }, _stoppingToken); + } + + private void DeclareTopology() + { + if (_channel == null) return; + + _channel.ExchangeDeclare( + exchange: _options.Exchange, + type: _options.ExchangeType, + durable: true, + autoDelete: false); + + _channel.QueueDeclare( + queue: _options.InboundQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + { "x-queue-type", "quorum" } + }); + + foreach (var routingKey in _options.InboundRoutingKeys) + { + _channel.QueueBind( + queue: _options.InboundQueue, + exchange: _options.Exchange, + routingKey: routingKey); + } + + logger.LogInformation( + "Declared exchange {Exchange} (topic), queue {Queue}, bound with routing keys [{RoutingKeys}]", + _options.Exchange, _options.InboundQueue, string.Join(", ", _options.InboundRoutingKeys)); + } + + private void StartConsuming() + { + if (_channel == null) return; + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.Received += OnMessageReceivedAsync; + + _channel.BasicConsume( + queue: _options.InboundQueue, + autoAck: false, + consumer: consumer); + } + + /// + /// Receives a message from RabbitMQ and saves it to the inbox table. + /// The message is ACKed immediately after saving — no processing happens here. + /// + private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs ea) + { + var messageId = ea.BasicProperties?.MessageId ?? string.Empty; + var messageType = ea.BasicProperties?.Type ?? string.Empty; + var correlationId = ea.BasicProperties?.CorrelationId ?? string.Empty; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + + logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); + + // Guard: discard acknowledgment messages to prevent infinite loops (spec §4.2) + if (string.Equals(messageType, "MessageAcknowledgment", StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + try + { + var json = Encoding.UTF8.GetString(ea.Body.ToArray()); + var envelope = JsonConvert.DeserializeObject(json); + + if (envelope == null) + { + logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + // Use envelope values as fallback for AMQP properties + if (string.IsNullOrEmpty(messageId)) messageId = envelope.MessageId; + if (string.IsNullOrEmpty(correlationId)) correlationId = envelope.CorrelationId; + + // Validate MessageId after applying all fallbacks + if (string.IsNullOrWhiteSpace(messageId)) + { + logger.LogError("Received message with missing/blank MessageId. Discarding. CorrelationId={CorrelationId}", correlationId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + // Resolve tenant from the data.provider field (stored for later use by processors) + var payload = envelope.Data?.ToObject(); + var tenantId = ResolveTenantId(payload?.Provider); + + // Save to the central host inbox — no tenant context needed + using var scope = serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + + // Idempotency: skip if we already have this message + var existing = await inboxRepo.FindByMessageIdAsync(messageId); + if (existing != null) + { + logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + var inboxMessage = new InboxMessage + { + Source = GrantsPortalRabbitMqOptions.SourceName, + MessageId = messageId, + CorrelationId = correlationId, + DataType = envelope.DataType, + Payload = json, + Status = MessageStatus.Pending, + ReceivedAt = DateTime.UtcNow, + TenantId = tenantId + }; + + await inboxRepo.InsertAsync(inboxMessage, autoSave: true); + await uow.CompleteAsync(); + + logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); + } + catch (Exception ex) when (IsDuplicateKeyException(ex)) + { + // Another pod inserted the same MessageId between our check and insert (unique index). + // This is expected in multi-pod environments on RabbitMQ redelivery — treat as success. + logger.LogInformation(ex, "Message {MessageId} was concurrently inserted by another pod. Treating as idempotent success.", messageId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: true); + return; + } + + // ACK only after successful save to inbox + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + } + + private static Guid? ResolveTenantId(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + return null; + + if (Guid.TryParse(provider, out var tenantGuid)) + return tenantGuid; + + return null; + } + + private void CleanupConnection() + { + try + { + if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error during connection cleanup"); + } + + _channel = null; + _connection = null; + } + + /// + /// Detects PostgreSQL unique constraint violation (error code 23505) propagated through EF Core. + /// This occurs when two pods concurrently insert the same MessageId on RabbitMQ redelivery. + /// Uses reflection to avoid a direct Npgsql dependency in the Application layer. + /// + private static bool IsDuplicateKeyException(Exception ex) + { + var current = ex; + while (current != null) + { + // Npgsql.PostgresException has a SqlState property — check by type name to avoid package reference + var type = current.GetType(); + if (type.Name == "PostgresException") + { + var sqlState = type.GetProperty("SqlState")?.GetValue(current) as string; + if (sqlState == "23505") return true; + } + current = current.InnerException; + } + return false; + } + + public override void Dispose() + { + CleanupConnection(); + _reconnectLock.Dispose(); + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs new file mode 100644 index 0000000000..d6004c45a4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Extensions.Options; +using Quartz; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central inbox table for pending GrantsPortal inbound messages and processes them sequentially. +/// All orchestration logic (retry, tenant switching, outbox ack) is handled by . +/// Handlers are resolved as instances with Source == "GrantsPortal". +/// +public class GrantsPortalInboxWorker : InboxWorkerBase +{ + protected override string SourceName => GrantsPortalRabbitMqOptions.SourceName; + + public GrantsPortalInboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + var cronExpression = options.Value.InboxProcessorCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalInboxWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalInboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs new file mode 100644 index 0000000000..e1cd8a10f9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs @@ -0,0 +1,82 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Periodically deletes processed/failed messages older than the configured retention period. +/// Runs against the central host database. +/// +[DisallowConcurrentExecution] +public class GrantsPortalMessageCleanupWorker : QuartzBackgroundWorkerBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly int _retentionDays; + + public GrantsPortalMessageCleanupWorker( + IServiceProvider serviceProvider, + IOptions options) + { + _serviceProvider = serviceProvider; + _retentionDays = options.Value.MessageRetentionDays; + + var cronExpression = options.Value.MessageCleanupCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalMessageCleanupWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalMessageCleanupWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + public override async Task Execute(IJobExecutionContext context) + { + Logger.LogDebug("GrantsPortalMessageCleanupWorker executing (retention={RetentionDays} days)...", _retentionDays); + + try + { + await CleanupOldMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during integration message cleanup"); + } + } + + private async Task CleanupOldMessagesAsync() + { + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + + using var scope = _serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var inboxDeleted = await inboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + var outboxDeleted = await outboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + await uow.CompleteAsync(); + + var total = inboxDeleted + outboxDeleted; + if (total > 0) + { + Logger.LogInformation( + "Cleaned up {Total} messages older than {CutoffDate:yyyy-MM-dd} (inbox={InboxCount}, outbox={OutboxCount})", + total, cutoffDate, inboxDeleted, outboxDeleted); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs new file mode 100644 index 0000000000..a6de10b2be --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central outbox table for pending GrantsPortal acknowledgment messages and publishes them to RabbitMQ. +/// Uses publisher confirms to ensure delivery before the base class marks messages as sent. +/// All orchestration logic (retry, status updates) is handled by . +/// +public class GrantsPortalOutboxWorker : OutboxWorkerBase +{ + private readonly IAsyncConnectionFactory _connectionFactory; + private IConnection? _connection; + private IModel? _channel; + + protected override string SourceName => GrantsPortalRabbitMqOptions.SourceName; + + public GrantsPortalOutboxWorker( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options) + : base(serviceProvider) + { + _connectionFactory = connectionFactory; + + var cronExpression = options.Value.OutboxProcessorCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalOutboxWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalOutboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + protected override void OnBeforePublishCycle() + { + EnsureChannel(); + } + + protected override void OnPublishCycleError(Exception ex) + { + CleanupChannel(); + } + + protected override async Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg) + { + var publisher = scope.ServiceProvider.GetRequiredService(); + + publisher.Publish( + _channel!, + outboxMsg.OriginalMessageId, + outboxMsg.CorrelationId, + outboxMsg.AckStatus, + outboxMsg.Details); + + // Wait for broker to confirm + if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException("Broker did not confirm ack publish"); + } + + await Task.CompletedTask; + } + + private void EnsureChannel() + { + if (_channel is { IsOpen: true }) return; + + CleanupChannel(); + + _connection = _connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + _channel.ConfirmSelect(); + + Logger.LogInformation("Outbox worker RabbitMQ channel established"); + } + + private void CleanupChannel() + { + try + { + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Error during outbox channel cleanup"); + } + + _channel = null; + _connection = null; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs new file mode 100644 index 0000000000..202464a00e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressEditHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Address data is required"); + + logger.LogInformation("Editing address {AddressId} for profile {ProfileId}", addressId, payload.ProfileId); + + var address = await applicantAddressRepository.GetAsync(addressId); + + address.Street = innerData.Street; + address.Street2 = innerData.Street2; + address.Unit = innerData.Unit; + address.City = innerData.City; + address.Province = innerData.Province; + address.Postal = innerData.PostalCode; + address.Country = innerData.Country; + address.AddressType = MapAddressType(innerData.AddressType); + + await applicantAddressRepository.UpdateAsync(address); + + logger.LogInformation("Address {AddressId} updated successfully", addressId); + return "Address updated successfully"; + } + + private static AddressType MapAddressType(string? portalAddressType) + { + return portalAddressType?.ToUpperInvariant() switch + { + "MAILING" => AddressType.MailingAddress, + "PHYSICAL" => AddressType.PhysicalAddress, + "BUSINESS" => AddressType.BusinessAddress, + _ => AddressType.PhysicalAddress + }; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs new file mode 100644 index 0000000000..96607436d3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressSetPrimaryHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) + : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting address {AddressId} as primary for profile {ProfileId}", addressId, profileId); + + var address = await applicantAddressRepository.GetAsync(addressId); + + address.SetProperty("profileId", profileId.ToString()); + address.SetProperty("isPrimary", true); + + if (address.ApplicantId.HasValue) + { + var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(address.ApplicantId.Value); + + foreach (var sibling in siblingAddresses) + { + if (sibling.Id == addressId) continue; + if (!sibling.HasProperty("isPrimary")) continue; + + var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); + trackedSibling.SetProperty("isPrimary", false); + await applicantAddressRepository.UpdateAsync(trackedSibling); + } + } + + await applicantAddressRepository.UpdateAsync(address); + + logger.LogInformation("Address {AddressId} set as primary", addressId); + return "Address set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs new file mode 100644 index 0000000000..1e618a100b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactCreateHandler( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicantAgentRepository applicantAgentRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_CREATE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + // Idempotency: if the contact already exists, treat as success + var existing = await contactRepository.FindAsync(contactId); + if (existing != null) + { + logger.LogInformation("Contact {ContactId} already exists. Treating as idempotent success.", contactId); + return "Contact already exists"; + } + + logger.LogInformation("Creating contact {ContactId} for profile {ProfileId}", contactId, profileId); + + var contact = new Contact + { + Name = innerData.Name, + Email = innerData.Email, + Title = innerData.Title, + HomePhoneNumber = innerData.HomePhoneNumber, + MobilePhoneNumber = innerData.MobilePhoneNumber, + WorkPhoneNumber = innerData.WorkPhoneNumber, + WorkPhoneExtension = innerData.WorkPhoneExtension + }; + + EntityHelper.TrySetId(contact, () => contactId); + + // Lookup applicant agent IDs associated with this subject's submissions + var applicantAgentIds = await GetApplicantAgentIdsAsync(payload.Subject); + if (applicantAgentIds.Count > 0) + { + contact.SetProperty("applicantAgentIds", applicantAgentIds); + logger.LogInformation("Found {Count} applicant agent(s) for subject {Subject}", applicantAgentIds.Count, payload.Subject); + } + + await contactRepository.InsertAsync(contact); + + // Create a contact link to track the relationship and primary status + var contactLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = innerData.ContactType ?? "PORTAL", + RelatedEntityId = profileId, + Role = innerData.Role, + IsPrimary = innerData.IsPrimary, + IsActive = true + }; + + await contactLinkRepository.InsertAsync(contactLink); + + logger.LogInformation("Contact {ContactId} created successfully", contactId); + return "Contact created successfully"; + } + + private async Task> GetApplicantAgentIdsAsync(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + { + return []; + } + + var normalizedSub = NormalizeOidcSub(subject); + if (string.IsNullOrWhiteSpace(normalizedSub)) + { + return []; + } + + // Find submissions matching the normalized OidcSub + var submissions = await applicationFormSubmissionRepository.GetListAsync(s => s.OidcSub == normalizedSub); + if (submissions.Count == 0) + { + logger.LogDebug("No submissions found for subject {Subject} (normalized: {NormalizedSub})", subject, normalizedSub); + return []; + } + + // Get distinct application IDs from the submissions + var applicationIds = submissions + .Select(s => s.ApplicationId) + .Distinct() + .ToList(); + + // Lookup applicant agents linked to those applications + var agents = await applicantAgentRepository + .GetListAsync(a => a.ApplicationId != null && applicationIds.Contains(a.ApplicationId!.Value)); + + return [.. agents + .Select(a => a.Id.ToString()) + .Distinct()]; + } + + /// + /// Normalizes a raw OIDC subject by stripping the IDP suffix (after @) and uppercasing. + /// This matches the format stored in ApplicationFormSubmission.OidcSub. + /// + internal static string? NormalizeOidcSub(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + { + return null; + } + + var atIndex = subject.IndexOf('@'); + + if (atIndex == 0) + { + return null; + } + + return atIndex > 0 + ? subject[..atIndex].ToUpperInvariant() + : subject.ToUpperInvariant(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs new file mode 100644 index 0000000000..9b7029f7bb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactDeleteHandler( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_DELETE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + + logger.LogInformation("Deleting contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + // Delete all contact links first (FK dependency) + var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId); + if (links.Count > 0) + { + await contactLinkRepository.DeleteManyAsync(links); + } + + // Delete the contact + var contact = await contactRepository.FindAsync(contactId); + if (contact != null) + { + await contactRepository.DeleteAsync(contact); + } + + logger.LogInformation("Contact {ContactId} deleted successfully", contactId); + return "Contact deleted successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs new file mode 100644 index 0000000000..c640ec3808 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactEditHandler( + IContactRepository contactRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + logger.LogInformation("Editing contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + var contact = await contactRepository.GetAsync(contactId); + + contact.Name = innerData.Name; + contact.Email = innerData.Email; + contact.Title = innerData.Title; + contact.HomePhoneNumber = innerData.HomePhoneNumber; + contact.MobilePhoneNumber = innerData.MobilePhoneNumber; + contact.WorkPhoneNumber = innerData.WorkPhoneNumber; + contact.WorkPhoneExtension = innerData.WorkPhoneExtension; + + await contactRepository.UpdateAsync(contact); + + logger.LogInformation("Contact {ContactId} updated successfully", contactId); + return "Contact updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs new file mode 100644 index 0000000000..b02a8c43fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactSetPrimaryHandler( + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting contact {ContactId} as primary for profile {ProfileId}", contactId, profileId); + + // Find all contact links for this profile and clear their primary flag + var profileLinks = await contactLinkRepository.GetListAsync( + cl => cl.RelatedEntityId == profileId && cl.IsActive); + + foreach (var link in profileLinks) + { + link.IsPrimary = link.ContactId == contactId; + await contactLinkRepository.UpdateAsync(link); + } + + logger.LogInformation("Contact {ContactId} set as primary", contactId); + return "Contact set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs new file mode 100644 index 0000000000..88925c7ee2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public interface IPortalCommandHandler +{ + string DataType { get; } + Task HandleAsync(PluginDataPayload payload); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs new file mode 100644 index 0000000000..78770d0bf5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class OrganizationEditHandler( + IApplicantRepository applicantRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ORGANIZATION_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Organization data is required"); + + logger.LogInformation("Editing organization for profile {ProfileId}", payload.ProfileId); + + var organizationId = Guid.Parse(payload.OrganizationId ?? throw new ArgumentException("organizationId is required")); + var applicant = await applicantRepository.GetAsync(organizationId); + + applicant.OrgName = innerData.Name; + applicant.OrganizationType = innerData.OrganizationType; + applicant.OrgNumber = innerData.OrganizationNumber; + applicant.OrgStatus = innerData.Status; + applicant.NonRegOrgName = innerData.NonRegOrgName; + applicant.FiscalMonth = innerData.FiscalMonth; + applicant.OrganizationSize = innerData.OrganizationSize; + + if (int.TryParse(innerData.FiscalDay, out var fiscalDay)) + { + applicant.FiscalDay = fiscalDay; + } + + await applicantRepository.UpdateAsync(applicant); + + logger.LogInformation("Organization {OrganizationId} updated successfully", organizationId); + return "Organization updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs new file mode 100644 index 0000000000..768888c05f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +/// +/// Adapts the portal-specific to the generic +/// interface so existing handlers don't need to change. +/// +/// Each is wrapped in one of these adapters at DI registration time. +/// The adapter handles PluginDataEnvelope → PluginDataPayload deserialization before delegating. +/// +internal class PortalCommandHandlerAdapter(IPortalCommandHandler inner) : IInboxMessageHandler +{ + public string Source => GrantsPortalRabbitMqOptions.SourceName; + public string DataType => inner.DataType; + + public async Task HandleAsync(string rawPayload) + { + var envelope = JsonConvert.DeserializeObject(rawPayload) + ?? throw new JsonException("Failed to deserialize message payload"); + + var payload = envelope.Data?.ToObject() + ?? throw new ArgumentException("Message data payload is missing"); + + return await inner.HandleAsync(payload); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs new file mode 100644 index 0000000000..3d6c655fb4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class AddressEditData +{ + [JsonProperty("addressType")] + public string? AddressType { get; set; } + + [JsonProperty("street")] + public string Street { get; set; } = string.Empty; + + [JsonProperty("street2")] + public string? Street2 { get; set; } + + [JsonProperty("unit")] + public string? Unit { get; set; } + + [JsonProperty("city")] + public string City { get; set; } = string.Empty; + + [JsonProperty("province")] + public string Province { get; set; } = string.Empty; + + [JsonProperty("postalCode")] + public string PostalCode { get; set; } = string.Empty; + + [JsonProperty("country")] + public string? Country { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs new file mode 100644 index 0000000000..e89566c42d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactCreateData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs new file mode 100644 index 0000000000..a4f4f5d605 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactEditData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs new file mode 100644 index 0000000000..e2d08dc007 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class OrganizationEditData +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("organizationType")] + public string? OrganizationType { get; set; } + + [JsonProperty("organizationNumber")] + public string? OrganizationNumber { get; set; } + + [JsonProperty("status")] + public string? Status { get; set; } + + [JsonProperty("nonRegOrgName")] + public string? NonRegOrgName { get; set; } + + [JsonProperty("fiscalMonth")] + public string? FiscalMonth { get; set; } + + [JsonProperty("fiscalDay")] + public string? FiscalDay { get; set; } + + [JsonProperty("organizationSize")] + public string? OrganizationSize { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs new file mode 100644 index 0000000000..fdb25805e7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs @@ -0,0 +1,34 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class MessageAcknowledgment +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = Guid.NewGuid().ToString(); + + [JsonProperty("messageType")] + public string MessageType { get; set; } = "MessageAcknowledgment"; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = "UNITY"; + + [JsonProperty("originalMessageId")] + public string OriginalMessageId { get; set; } = string.Empty; + + [JsonProperty("status")] + public string Status { get; set; } = string.Empty; + + [JsonProperty("details")] + public string Details { get; set; } = string.Empty; + + [JsonProperty("processedAt")] + public DateTime ProcessedAt { get; set; } = DateTime.UtcNow; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs new file mode 100644 index 0000000000..926d80a067 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataEnvelope +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = string.Empty; + + [JsonProperty("messageType")] + public string MessageType { get; set; } = string.Empty; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = string.Empty; + + [JsonProperty("dataType")] + public string DataType { get; set; } = string.Empty; + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs new file mode 100644 index 0000000000..63290ffc4a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataPayload +{ + [JsonProperty("action")] + public string Action { get; set; } = string.Empty; + + [JsonProperty("contactId")] + public string? ContactId { get; set; } + + [JsonProperty("addressId")] + public string? AddressId { get; set; } + + [JsonProperty("organizationId")] + public string? OrganizationId { get; set; } + + [JsonProperty("profileId")] + public string? ProfileId { get; set; } + + [JsonProperty("provider")] + public string? Provider { get; set; } + + [JsonProperty("subject")] + public string? Subject { get; set; } + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index a4d07dd2aa..b06e30aae2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -39,15 +39,6 @@ public class GenerateAIContentHandler : ILocalEventHandler ExcludedPromptDataKeys = new(StringComparer.OrdinalIgnoreCase) - { - "simplefile", - "applicantAgent", - "submit", - "lateEntry", - "metadata", - "full_application_form_submission" - }; private static readonly string[] AllowedAnalysisRootProperties = { AIJsonKeys.Rating, @@ -287,7 +278,8 @@ private async Task GenerateApplicationAnalysisAsync(Application application, Lis application.Id); } - var analysisData = BuildPromptDataPayload(application, formSubmission); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var analysisData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, _logger); _logger.LogInformation("Generating analysis for application {ApplicationId}", application.Id); _logger.LogDebug("Generating AI analysis for application {ApplicationId} with {AttachmentCount} attachment summaries", @@ -333,25 +325,6 @@ private static List BuildAnalysisAttachments(List attachments) { try @@ -400,7 +373,8 @@ private async Task GenerateScoresheetAnalysisAsync(Application application, List var allSectionResults = new Dictionary(); var scoresheetAttachments = BuildScoresheetAttachments(attachments); var formSubmission = await _applicationFormSubmissionRepository.GetByApplicationAsync(application.Id); - var scoresheetData = BuildPromptDataPayload(application, formSubmission); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var scoresheetData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, _logger); LogFormSubmissionPreview(formSubmission?.RenderedHTML); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -443,85 +417,6 @@ private static List BuildScoresheetAttachments(List values) - { - values = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(formSubmission?.Submission)) - { - return false; - } - - try - { - using var submissionDoc = JsonDocument.Parse(formSubmission.Submission); - if (!TryExtractSubmissionDataObject(submissionDoc.RootElement, out var submissionData)) - { - return false; - } - - values = BuildPromptDataValues(submissionData); - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", - applicationId); - return false; - } - } - - private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonElement submissionData) - { - submissionData = root; - if (root.ValueKind != JsonValueKind.Object) - { - return false; - } - - if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Object) - { - submissionData = dataElement; - return true; - } - - if (root.TryGetProperty("submission", out var submissionElement) && - submissionElement.ValueKind == JsonValueKind.Object && - submissionElement.TryGetProperty("data", out var nestedDataElement) && - nestedDataElement.ValueKind == JsonValueKind.Object) - { - submissionData = nestedDataElement; - return true; - } - - return root.ValueKind == JsonValueKind.Object; - } - - private static Dictionary BuildPromptDataValues(JsonElement submissionData) - { - var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? - new Dictionary(); - var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); - - foreach (var excludedKey in ExcludedPromptDataKeys) - { - values.Remove(excludedKey); - } - - return values; - } - private void LogFormSubmissionPreview(string? renderedFormHtml) { _logger.LogInformation("Form submission HTML length: {HtmlLength} characters", renderedFormHtml?.Length ?? 0); @@ -737,6 +632,25 @@ private async Task ExtractFormFieldConfigurationSchemaAsync(Guid fo } } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await _applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private static JsonElement BuildEmptyFormFieldSchema() { return JsonSerializer.SerializeToElement(new Dictionary()); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs new file mode 100644 index 0000000000..c036f51775 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Messaging; + +/// +/// Base class for inbox processing workers. Provides the full orchestration loop: +/// poll pending → mark processing → dispatch to handler → retry on transient errors → mark complete → write outbox ack. +/// +/// Subclasses only need to provide the source name and configure the Quartz schedule in their constructor. +/// Handlers are resolved from DI as filtered by . +/// +[DisallowConcurrentExecution] +public abstract class InboxWorkerBase : QuartzBackgroundWorkerBase +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// The integration source discriminator (e.g. "GrantsPortal"). + /// Used to filter pending inbox messages and tag outbox acknowledgments. + /// + protected abstract string SourceName { get; } + + /// + /// Maximum number of retry attempts for transient errors before marking as failed. + /// Override to customize per integration. Default is 3. + /// + protected virtual int MaxRetryCount => 3; + + /// + /// Maximum number of pending messages to fetch per polling cycle. + /// Override to customize per integration. Default is 10. + /// + protected virtual int BatchSize => 10; + + private static readonly Dictionary s_userFriendlyErrors = new(StringComparer.OrdinalIgnoreCase) + { + { "EntityNotFoundException", "The requested record was not found. It may have been deleted." }, + { "DbUpdateConcurrencyException", "The record was modified by another process. Please try again." }, + { "AbpDbConcurrencyException", "The record was modified by another process. Please try again." } + }; + + /// + /// Exception types whose is safe to surface verbatim + /// in outbox acknowledgments. These are input/validation errors thrown by handlers + /// (e.g., missing required fields, malformed GUIDs, deserialization failures). + /// + private static readonly HashSet s_validationExceptionTypes = new(StringComparer.OrdinalIgnoreCase) + { + "ArgumentException", + "ArgumentNullException", + "FormatException", + "JsonException", + "JsonReaderException", + "JsonSerializationException" + }; + + protected InboxWorkerBase(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public override async Task Execute(IJobExecutionContext context) + { + try + { + await ProcessPendingMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error in {WorkerName} execution", GetType().Name); + } + } + + private async Task ProcessPendingMessagesAsync() + { + using var scope = _serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await inboxRepo.GetPendingAsync(SourceName, BatchSize); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return; + + foreach (var inboxMsg in pendingMessages) + { + await ProcessSingleMessageAsync(scope, inboxMsg); + } + } + + private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage inboxMsg) + { + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + var currentTenant = scope.ServiceProvider.GetRequiredService(); + var handlers = scope.ServiceProvider.GetServices(); + + Logger.LogInformation("Processing inbox message {MessageId} (source={Source}, dataType={DataType}, tenantId={TenantId})", + inboxMsg.MessageId, inboxMsg.Source, inboxMsg.DataType, inboxMsg.TenantId); + + string ackStatus; + string details; + + try + { + // Mark as processing + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = MessageStatus.Processing; + inboxMsg.RetryCount++; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + var handler = handlers.FirstOrDefault(h => + string.Equals(h.Source, SourceName, StringComparison.OrdinalIgnoreCase) + && string.Equals(h.DataType, inboxMsg.DataType, StringComparison.OrdinalIgnoreCase)); + + if (handler == null) + { + ackStatus = "FAILED"; + details = $"Unknown command type: {inboxMsg.DataType}"; + Logger.LogWarning("No handler registered for source {Source}, dataType {DataType}", + SourceName, inboxMsg.DataType); + } + else + { + // Switch to tenant context ONLY for the domain handler execution + using (currentTenant.Change(inboxMsg.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + details = await handler.HandleAsync(inboxMsg.Payload); + await uow.CompleteAsync(); + } + ackStatus = "SUCCESS"; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing inbox message {MessageId}", inboxMsg.MessageId); + ackStatus = "FAILED"; + details = ToUserFriendlyMessage(ex); + + // Check if we should retry + if (inboxMsg.RetryCount < MaxRetryCount && IsTransientError(ex)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + inboxMsg.Status = MessageStatus.Pending; + inboxMsg.Details = details; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + Logger.LogInformation("Message {MessageId} will be retried (attempt {Attempt}/{MaxRetries})", + inboxMsg.MessageId, inboxMsg.RetryCount, MaxRetryCount); + return; + } + } + + // Mark inbox as complete + write to outbox — same transaction + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = ackStatus == "SUCCESS" ? MessageStatus.Processed : MessageStatus.Failed; + inboxMsg.Details = details; + inboxMsg.ProcessedAt = DateTime.UtcNow; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + + var outboxMsg = new OutboxMessage + { + Source = SourceName, + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = inboxMsg.MessageId, + CorrelationId = inboxMsg.CorrelationId, + DataType = inboxMsg.DataType, + AckStatus = ackStatus, + Details = details, + Status = MessageStatus.Pending, + CreatedAt = DateTime.UtcNow, + TenantId = inboxMsg.TenantId + }; + + await outboxRepo.InsertAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + Logger.LogInformation("Inbox message {MessageId} processed with status {Status}", + inboxMsg.MessageId, ackStatus); + } + + /// + /// Maps exception types to user-friendly messages. Override to add integration-specific mappings. + /// + protected virtual string ToUserFriendlyMessage(Exception ex) + { + var exType = ex.GetType().Name; + + if (s_userFriendlyErrors.TryGetValue(exType, out var friendly)) + return friendly; + + if (ex.InnerException != null) + { + var innerType = ex.InnerException.GetType().Name; + if (s_userFriendlyErrors.TryGetValue(innerType, out var innerFriendly)) + return innerFriendly; + } + + // Validation / input errors — surface ex.Message so callers get actionable feedback + if (IsValidationException(ex)) + return ex.Message; + + return "An unexpected error occurred while processing your request. Please try again or contact support."; + } + + /// + /// Returns true when the exception (or its inner exception) is a validation/input error + /// whose message is safe to include in outbox acknowledgments. + /// + private static bool IsValidationException(Exception ex) + { + if (s_validationExceptionTypes.Contains(ex.GetType().Name)) + return true; + + return ex.InnerException != null + && s_validationExceptionTypes.Contains(ex.InnerException.GetType().Name); + } + + /// + /// Determines if an error is transient (eligible for retry). Override to add integration-specific checks. + /// + protected virtual bool IsTransientError(Exception ex) + { + var typeName = ex.GetType().Name; + return typeName.Contains("Timeout", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Concurrency", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Transient", StringComparison.OrdinalIgnoreCase) + || ex.InnerException is TimeoutException; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs new file mode 100644 index 0000000000..f41e23c52a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Messaging; + +/// +/// Base class for outbox processing workers. Provides the full publish loop: +/// poll pending → publish → mark sent/failed. +/// +/// Subclasses provide the source name, Quartz schedule, and the actual publish implementation +/// via . +/// +[DisallowConcurrentExecution] +public abstract class OutboxWorkerBase : QuartzBackgroundWorkerBase +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// The integration source discriminator (e.g. "GrantsPortal"). + /// Used to filter pending outbox messages. + /// + protected abstract string SourceName { get; } + + /// + /// Maximum number of publish retry attempts before marking as failed. + /// Override to customize per integration. Default is 3. + /// + protected virtual int MaxPublishRetries => 3; + + /// + /// Maximum number of pending messages to fetch per polling cycle. + /// Override to customize per integration. Default is 10. + /// + protected virtual int BatchSize => 10; + + protected OutboxWorkerBase(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public override async Task Execute(IJobExecutionContext context) + { + try + { + OnBeforePublishCycle(); + await PublishPendingMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in {WorkerName} execution. Resources will be reset on next run.", GetType().Name); + OnPublishCycleError(ex); + } + } + + /// + /// Called before each publish cycle. Use to ensure transport connections/channels are ready. + /// + protected virtual void OnBeforePublishCycle() { } + + /// + /// Called when the publish cycle throws an unhandled exception. Use to clean up transport resources. + /// + protected virtual void OnPublishCycleError(Exception ex) { } + + /// + /// Publishes a single outbox message to the external system. + /// Implementations should throw on failure — the base class handles retry and status updates. + /// + /// The current DI scope for resolving transport-specific services. + /// The outbox message to publish. + protected abstract Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg); + + private async Task PublishPendingMessagesAsync() + { + using var scope = _serviceProvider.CreateScope(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await outboxRepo.GetPendingAsync(SourceName, BatchSize); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return; + + foreach (var outboxMsg in pendingMessages) + { + await PublishSingleAsync(outboxMsg, scope, outboxRepo, unitOfWorkManager); + } + } + + private async Task PublishSingleAsync( + OutboxMessage outboxMsg, + IServiceScope scope, + IOutboxMessageRepository outboxRepo, + IUnitOfWorkManager unitOfWorkManager) + { + try + { + await PublishMessageAsync(scope, outboxMsg); + + // Mark as sent + using var uow = unitOfWorkManager.Begin(requiresNew: true); + outboxMsg.Status = MessageStatus.Processed; + outboxMsg.PublishedAt = DateTime.UtcNow; + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + + Logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", + outboxMsg.MessageId, outboxMsg.OriginalMessageId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); + + outboxMsg.RetryCount++; + if (outboxMsg.RetryCount >= MaxPublishRetries) + { + outboxMsg.Status = MessageStatus.Failed; + outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; + Logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", + outboxMsg.MessageId, MaxPublishRetries); + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs new file mode 100644 index 0000000000..b14408c0fe --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; + +namespace Unity.GrantManager.Messaging; + +/// +/// A source-agnostic handler for inbox messages. +/// Implementations receive the raw JSON payload and are responsible for their own deserialization. +/// Dispatched by based on and . +/// +public interface IInboxMessageHandler +{ + /// + /// The integration source this handler belongs to (e.g. "GrantsPortal"). + /// Must match the value. + /// + string Source { get; } + + /// + /// The command discriminator this handler processes (e.g. "CONTACT_CREATE_COMMAND"). + /// Must match the value. + /// + string DataType { get; } + + /// + /// Processes the raw JSON payload of an inbox message. + /// + /// The full JSON payload string from . + /// A human-readable details string describing the outcome. + Task HandleAsync(string rawPayload); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs new file mode 100644 index 0000000000..795eef08fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IInboxMessageRepository : IRepository +{ + Task FindByMessageIdAsync(string messageId); + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs new file mode 100644 index 0000000000..01a1cd6740 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IOutboxMessageRepository : IRepository +{ + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs new file mode 100644 index 0000000000..9ac8c6f41c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs @@ -0,0 +1,71 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.GrantManager.Messaging; + +public enum MessageStatus +{ + Pending = 1, + Processing = 2, + Processed = 3, + Failed = 4 +} + +/// +/// A message received from an external system, stored for sequential processing. +/// TenantId is stored as metadata for handler dispatch — not for data isolation. +/// +public class InboxMessage : AuditedAggregateRoot +{ + /// + /// Identifies the integration source (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// The message ID from the source system. Used for idempotency. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the source system. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The command discriminator (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The full JSON payload of the inbound message. + /// + public string Payload { get; set; } = string.Empty; + + /// + /// Current processing status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Human-readable details (processing result or error message). + /// + public string? Details { get; set; } + + /// + /// Number of processing attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the message was received from the broker. + /// + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully processed. + /// + public DateTime? ProcessedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs new file mode 100644 index 0000000000..ffbc1d7eeb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs @@ -0,0 +1,68 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.GrantManager.Messaging; + +/// +/// An acknowledgment or response message to be published to an external system. +/// TenantId is stored as metadata — not for data isolation. +/// +public class OutboxMessage : AuditedAggregateRoot +{ + /// + /// Identifies the integration target (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// A unique message ID for this outbound message. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The message ID of the original inbound command this is responding to. + /// + public string OriginalMessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the original inbound message. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The data type of the original command (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The acknowledgment status: SUCCESS, FAILED, or PROCESSING. + /// + public string AckStatus { get; set; } = string.Empty; + + /// + /// Human-readable details (shown to the Portal user on failure). + /// + public string Details { get; set; } = string.Empty; + + /// + /// Current publish status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Number of publish attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the outbox message was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully published to the broker. + /// + public DateTime? PublishedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 096adad348..18ffd18ae2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -32,5 +32,4 @@ **/Assessments/Assessment.cs, **/Assessments/AssessmentWithAssessorQueryResultItem.cs - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 498c00a127..657adaa3fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -19,6 +19,8 @@ using AppAny.Quartz.EntityFrameworkCore.Migrations; using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL; using Unity.GrantManager.Integrations; +using Unity.GrantManager.Messaging; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Unity.GrantManager.EntityFrameworkCore; @@ -42,6 +44,8 @@ public class GrantManagerDbContext : public DbSet RegionalDistricts { get; set; } public DbSet TenantTokens { get; set; } public DbSet Communities { get; set; } + public DbSet InboxMessages { get; set; } + public DbSet OutboxMessages { get; set; } #region Entities from the modules @@ -179,7 +183,51 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => x.OidcSubUsername); b.HasIndex(x => new { x.OidcSubUsername, x.TenantId }).IsUnique(); }); - + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "InboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.Payload).IsRequired().HasColumnType("jsonb"); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => x.MessageId).IsUnique(); + b.HasIndex(x => new { x.Source, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "OutboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.OriginalMessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.AckStatus).IsRequired().HasMaxLength(20); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => new { x.Source, x.Status }); + }); + var allEntityTypes = modelBuilder.Model.GetEntityTypes(); foreach (var type in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary)).Select(t => t.ClrType)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs new file mode 100644 index 0000000000..e29e33eb47 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs @@ -0,0 +1,2873 @@ +// +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.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260307013604_Add_InboxOutboxMessages")] + partial class Add_InboxOutboxMessages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applicants.ApplicantTenantMap", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastUpdated") + .HasColumnType("timestamp without time zone"); + + b.Property("OidcSubUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TenantName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OidcSubUsername"); + + b.HasIndex("OidcSubUsername", "TenantId") + .IsUnique(); + + b.ToTable("ApplicantTenantMaps", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .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("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("KeyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + 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("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DynamicUrls", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Community", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Communities", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.EconomicRegion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("EconomicRegionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("EconomicRegions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.ElectoralDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ElectoralDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ElectoralDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("ElectoralDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.RegionalDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RegionalDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorId") + .HasColumnType("uuid"); + + b.Property("SubSectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubSectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectorId"); + + b.ToTable("SubSectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.HasIndex("Source", "Status"); + + b.ToTable("InboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AckStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Status"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("ApplicationName"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("BrowserInfo"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientId"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientIpAddress"); + + b.Property("ClientName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ClientName"); + + b.Property("Comments") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Comments"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("CorrelationId"); + + b.Property("Exceptions") + .HasColumnType("text"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HttpMethod") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("HttpMethod"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("HttpStatusCode"); + + b.Property("ImpersonatorTenantId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorTenantId"); + + b.Property("ImpersonatorTenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ImpersonatorTenantName"); + + b.Property("ImpersonatorUserId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorUserId"); + + b.Property("ImpersonatorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ImpersonatorUserName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("TenantName"); + + b.Property("Url") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ExecutionTime"); + + b.HasIndex("TenantId", "UserId", "ExecutionTime"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ExecutionTime"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("MethodName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("MethodName"); + + b.Property("Parameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("Parameters"); + + b.Property("ServiceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ServiceName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "ServiceName", "MethodName", "ExecutionTime"); + + b.ToTable("AuditLogActions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ChangeTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ChangeTime"); + + b.Property("ChangeType") + .HasColumnType("smallint") + .HasColumnName("ChangeType"); + + b.Property("EntityId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityId"); + + b.Property("EntityTenantId") + .HasColumnType("uuid"); + + b.Property("EntityTypeFullName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityTypeFullName"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "EntityTypeFullName", "EntityId"); + + b.ToTable("EntityChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityChangeId") + .HasColumnType("uuid"); + + b.Property("NewValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("NewValue"); + + b.Property("OriginalValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("OriginalValue"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("PropertyName"); + + b.Property("PropertyTypeFullName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PropertyTypeFullName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("EntityChangeId"); + + b.ToTable("EntityPropertyChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", 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("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsAbandoned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JobArgs") + .IsRequired() + .HasMaxLength(1048576) + .HasColumnType("character varying(1048576)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("NextTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)15); + + b.Property("TryCount") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.HasKey("Id"); + + b.HasIndex("IsAbandoned", "NextTryTime"); + + b.ToTable("BackgroundJobs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedProviders") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DefaultValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsAvailableToHost") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ValueType") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Features", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("FeatureValues", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityClaimType", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsStatic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Regex") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RegexDescription") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClaimTypes", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityLinkUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("SourceTenantId") + .HasColumnType("uuid"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("TargetTenantId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceUserId", "SourceTenantId", "TargetUserId", "TargetTenantId") + .IsUnique(); + + b.ToTable("LinkUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("IsDefault"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("IsPublic"); + + b.Property("IsStatic") + .HasColumnType("boolean") + .HasColumnName("IsStatic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySecurityLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Action") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Identity") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Action"); + + b.HasIndex("TenantId", "ApplicationName"); + + b.HasIndex("TenantId", "Identity"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("SecurityLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySession", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Device") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeviceInfo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IpAddresses") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastAccessed") + .HasColumnType("timestamp without time zone"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SignedIn") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Device"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("Sessions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("AccessFailedCount"); + + 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("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.Property("EmailConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("EmailConfirmed"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("IsActive"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsExternal") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsExternal"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastPasswordChangeTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("LockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Name"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedEmail"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedUserName"); + + b.Property("OidcSub") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("PasswordHash"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("PhoneNumber"); + + b.Property("PhoneNumberConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("PhoneNumberConfirmed"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("SecurityStamp"); + + b.Property("ShouldChangePasswordOnNextLogin") + .HasColumnType("boolean"); + + b.Property("Surname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Surname"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("TwoFactorEnabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("NormalizedUserName"); + + b.HasIndex("UserName"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserDelegation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("UserDelegations", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderDisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(196) + .HasColumnType("character varying(196)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "LoginProvider"); + + b.HasIndex("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "UserId"); + + b.HasIndex("UserId", "OrganizationUnitId"); + + b.ToTable("UserOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(95) + .HasColumnType("character varying(95)") + .HasColumnName("Code"); + + 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("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DisplayName"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + 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("ParentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("ParentId"); + + b.ToTable("OrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "RoleId"); + + b.HasIndex("RoleId", "OrganizationUnitId"); + + b.ToTable("OrganizationUnitRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MultiTenancySide") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateCheckers") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("PermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PermissionGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.SettingDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultValue") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInherited") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SettingDefinitions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("TenantId", "Name"); + + b.ToTable("TenantConnectionStrings", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.HasOne("Unity.GrantManager.Locality.Sector", "Sector") + .WithMany("SubSectors") + .HasForeignKey("SectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sector"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("Actions") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("EntityChanges") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.EntityChange", null) + .WithMany("PropertyChanges") + .HasForeignKey("EntityChangeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("OrganizationUnits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany("Roles") + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.HasOne("Volo.Abp.TenantManagement.Tenant", null) + .WithMany("ConnectionStrings") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Navigation("SubSectors"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Navigation("Actions"); + + b.Navigation("EntityChanges"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Navigation("PropertyChanges"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("OrganizationUnits"); + + b.Navigation("Roles"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Navigation("ConnectionStrings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs new file mode 100644 index 0000000000..389f387d5b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs @@ -0,0 +1,98 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + /// + public partial class Add_InboxOutboxMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxMessages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + MessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DataType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Payload = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "text", nullable: false), + Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + RetryCount = table.Column(type: "integer", nullable: false), + ReceivedAt = table.Column(type: "timestamp without time zone", nullable: false), + ProcessedAt = table.Column(type: "timestamp without time zone", nullable: true), + TenantId = table.Column(type: "uuid", nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + MessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + OriginalMessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DataType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AckStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Status = table.Column(type: "text", nullable: false), + RetryCount = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp without time zone", nullable: false), + PublishedAt = table.Column(type: "timestamp without time zone", nullable: true), + TenantId = table.Column(type: "uuid", nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxMessages_MessageId", + table: "InboxMessages", + column: "MessageId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InboxMessages_Source_Status", + table: "InboxMessages", + columns: new[] { "Source", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessages_Source_Status", + table: "OutboxMessages", + columns: new[] { "Source", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InboxMessages"); + + migrationBuilder.DropTable( + name: "OutboxMessages"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index d034c14e8e..e3fd3ba2df 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -948,6 +948,186 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubSectors", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.Messaging.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.HasIndex("Source", "Status"); + + b.ToTable("InboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AckStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Status"); + + b.ToTable("OutboxMessages", (string)null); + }); + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => { b.Property("Id") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs new file mode 100644 index 0000000000..2fe97f303f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IInboxMessageRepository))] +public class InboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IInboxMessageRepository +{ + public async Task FindByMessageIdAsync(string messageId) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.FirstOrDefaultAsync(m => m.MessageId == messageId); + } + + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.ReceivedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.InboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.ReceivedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs new file mode 100644 index 0000000000..ec533caba2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IOutboxMessageRepository))] +public class OutboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IOutboxMessageRepository +{ + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.CreatedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.OutboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.CreatedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs new file mode 100644 index 0000000000..650eee29ee --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.AI +{ + public class AIPromptToolViewOptionsProvider( + IWebHostEnvironment webHostEnvironment, + IConfiguration configuration) : IAIPromptToolViewOptionsProvider, ITransientDependency + { + public bool IsDevPromptControlsEnabled => + string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); + + public string DefaultPromptVersion => + string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) + ? "v1" + : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs new file mode 100644 index 0000000000..f6e2242194 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.Web.AI +{ + public interface IAIPromptToolViewOptionsProvider + { + bool IsDevPromptControlsEnabled { get; } + + string DefaultPromptVersion { get; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 838523702e..860a412d4f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -63,10 +63,10 @@ - - - - + + + + @functions { @@ -268,7 +268,13 @@ } - + @if (Model.IsDevPromptControlsEnabled) + { + + } +
@@ -301,11 +307,9 @@ @await Component.InvokeAsync("UserInfoWidget", new { displayName = "", badge = "", title = "" })
-
Assessment Scores
- -
- @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId }) -
+
+ @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId }) +
Scoring Attachments
@@ -373,11 +377,11 @@ @*-------- Comments Tab Section END ---------*@ @*-------- Attachments Tab Section ---------*@ -
- @await Component.InvokeAsync("ApplicationAttachments") - - @await Component.InvokeAsync("ChefsAttachments") -
+
+ @await Component.InvokeAsync("ApplicationAttachments") + + @await Component.InvokeAsync("ChefsAttachments") +
@*-------- Attachments Tab Section END ---------*@ @*-------- Links Tab Section ---------*@ @@ -403,7 +407,10 @@
AI Application Analysis
@@ -458,8 +465,94 @@
} - @*-------- AI Analysis Tab Section END ---------*@ - + @*-------- AI Analysis Tab Section END ---------*@ + @if (Model.IsDevPromptControlsEnabled) + { +
+
+
AI Dev Tools
+
+
+ +
+
+
+
+ +
+
+
Attachment
+ +
+
+
+ +
+ +
+
+ +
+
+
Analysis
+ +
+
+
+ +
+ +
+
+ +
+
+
Scoring
+ +
+
+
+ +
+ +
+
+
+
+ } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 80015854cd..0754eafe3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -13,6 +13,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Flex; using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Web.AI; using Unity.GrantManager.Zones; using Unity.Modules.Shared.Correlation; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; @@ -87,6 +88,12 @@ public class DetailsModel : AbpPageModel [BindProperty] public HashSet ZoneStateSet { get; set; } = []; + [BindProperty(SupportsGet = true)] + public bool IsDevPromptControlsEnabled { get; set; } + + [BindProperty(SupportsGet = true)] + public string DefaultPromptVersion { get; set; } + public DetailsModel( GrantApplicationAppService grantApplicationAppService, IWorksheetLinkAppService worksheetLinkAppService, @@ -94,6 +101,7 @@ public DetailsModel( IFeatureChecker featureChecker, ICurrentUser currentUser, IConfiguration configuration, + IAIPromptToolViewOptionsProvider aiPromptToolViewOptionsProvider, IZoneManagementAppService zoneManagementAppService) { _grantApplicationAppService = grantApplicationAppService; @@ -106,6 +114,8 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; + IsDevPromptControlsEnabled = aiPromptToolViewOptionsProvider.IsDevPromptControlsEnabled; + DefaultPromptVersion = aiPromptToolViewOptionsProvider.DefaultPromptVersion; } public async Task OnGetAsync() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 4ad7bd5371..8b16531fd1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -60,11 +60,104 @@ border-bottom: 3px solid #003366; } -.spinner-loader { - display: flex; - align-items: center; - justify-content: center; -} +.spinner-loader { + display: flex; + align-items: center; + justify-content: center; +} + +.ai-button-content { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.ai-prompt-capture-output { + min-height: 18rem; + max-height: 32rem; + overflow: auto; + resize: vertical; + white-space: pre; + font-family: Consolas, "Courier New", monospace; + line-height: 1.35; +} + +.ai-prompt-capture-container { + position: relative; +} + +.ai-prompt-capture-actions { + position: absolute; + top: 0.375rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + z-index: 1; + padding: 0 0.25rem; + background: #ffffff; + border-radius: 999px; +} + +.ai-prompt-capture-actions .btn-icon { + width: 2rem; + height: 2rem; + padding: 0; + color: #5c6b7a; +} + +.dev-prompt-section { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e7ebef; +} + +.dev-tools-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.dev-prompt-toolbar-row { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.dev-prompt-toolbar-inline { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.dev-prompt-toolbar-row .form-select { + width: auto; + min-width: 5rem; +} + +.dev-prompt-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; +} + +.dev-prompt-section-header { + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.dev-prompt-section .ai-prompt-capture-output { + min-height: 4.5rem; + max-height: 16rem; + padding-top: 0.5rem; + padding-right: 2.5rem; +} .left-card { border-right: 1px solid #dddddd; @@ -633,7 +726,7 @@ form label.error { } .ai-analysis-tab-indicator.hold { - background-color: #ffc107; + background-color: #dc3545; } .ai-analysis-action-btn { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index c173d70141..2e78d0df13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -4,6 +4,95 @@ */ $(function () { + globalThis.getSelectedPromptVersion = function() { + return $('#devPromptVersion').val() || null; + }; + + function setPromptCaptureOutput(outputSelector, value) { + $(outputSelector).val(value); + } + + globalThis.hideAIPromptCapture = function(containerSelector, outputSelector) { + setPromptCaptureOutput(outputSelector, ''); + }; + + function formatAIPromptCaptureBlock(capture) { + const parts = []; + + parts.push(`PROMPT TYPE: ${capture.promptType || ''}`); + parts.push(`PROMPT VERSION: ${capture.promptVersion || ''}`); + + if (capture.captureLabel) { + parts.push(`LABEL: ${capture.captureLabel}`); + } + + if (capture.capturedAt) { + parts.push(`CAPTURED AT: ${capture.capturedAt}`); + } + + parts.push(''); + parts.push('SYSTEM PROMPT'); + parts.push(capture.systemPrompt || ''); + parts.push(''); + parts.push('USER PROMPT'); + parts.push(capture.userPrompt || ''); + parts.push(''); + parts.push('RAW OUTPUT'); + parts.push(capture.rawOutput || ''); + parts.push(''); + parts.push('FORMATTED OUTPUT'); + parts.push(capture.formattedOutput || ''); + + return parts.join('\n'); + } + + globalThis.renderAIPromptCapture = function(containerSelector, outputSelector, captures) { + if (!Array.isArray(captures) || captures.length === 0) { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + return; + } + + const formatted = captures + .map((capture) => formatAIPromptCaptureBlock(capture)) + .join('\n\n----------------------------------------\n\n'); + + setPromptCaptureOutput(outputSelector, formatted); + }; + + globalThis.loadAIPromptCapture = function(applicationId, promptType, promptVersion, containerSelector, outputSelector) { + if (!applicationId || !promptType) { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + return Promise.resolve(); + } + + return unity.grantManager.grantApplications.applicationAIPromptCapture + .getRecent(applicationId, promptType, promptVersion || null) + .then(function(captures) { + globalThis.renderAIPromptCapture(containerSelector, outputSelector, captures || []); + }) + .catch(function() { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + }); + }; + + $(document).on('click', '.ai-prompt-capture-copy-btn', async function () { + const targetSelector = $(this).data('target'); + const text = $(targetSelector).val(); + + if (!targetSelector || !text) { + return; + } + + try { + await navigator.clipboard.writeText(text); + abp.notify.success('Copied prompt capture.'); + } catch { + const output = $(targetSelector); + output.trigger('focus'); + output.trigger('select'); + } + }); + let selectedReviewDetails = null; let renderFormIoToHtml = document.getElementById('RenderFormIoToHtml').value; @@ -314,6 +403,23 @@ $(function () { PubSub.subscribe('refresh_assessment_scores', (msg, data) => { assessmentScoresWidgetManager.refresh(); updateSubtotal(); + + if (!data) { + return; + } + + if (data.capturePromptIo && globalThis.loadAIPromptCapture) { + globalThis.loadAIPromptCapture( + data.applicationId, + 'ScoresheetSection', + data.promptVersion || null, + '#aiScoringPromptCaptureContainer', + '#aiScoringPromptCaptureOutput' + ); + return; + } + + globalThis.hideAIPromptCapture?.('#aiScoringPromptCaptureContainer', '#aiScoringPromptCaptureOutput'); }); PubSub.subscribe('select_application_review', (msg, data) => { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 8abd07a076..3c16d8b86a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -391,24 +391,38 @@ function tryParseRawAnalysis(analysisJson) { } } -globalThis.regenerateAIAnalysis = function() { +globalThis.regenerateAIAnalysis = function(capturePromptIo = false, triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); - const $button = $('#regenerateAiAnalysis'); + const $button = triggerButton ? $(triggerButton) : $('#regenerateAiAnalysis'); const existingHtml = $button.html(); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; if (!applicationId || $button.prop('disabled')) { return; } + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#aiAnalysisPromptCaptureContainer', '#aiAnalysisPromptCaptureOutput'); + } + $button - .html(' Refreshing Analysis...') + .html('Generating...') .prop('disabled', true); unity.grantManager.grantApplications.applicationAIAnalysis - .generateAIAnalysis(applicationId) + .generateAIAnalysis(applicationId, promptVersion, capturePromptIo) .then(function() { abp.notify.success('AI analysis refreshed successfully.'); loadAIAnalysis(); + if (capturePromptIo && globalThis.loadAIPromptCapture) { + return globalThis.loadAIPromptCapture( + applicationId, + 'ApplicationAnalysis', + promptVersion, + '#aiAnalysisPromptCaptureContainer', + '#aiAnalysisPromptCaptureOutput' + ); + } }) .catch(function() { abp.message.error('Failed to refresh AI analysis. Please try again.'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index b613bd2d39..ab766c0069 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -19,10 +19,28 @@
@if (Model.Scoresheet.Sections.Any()) { -
- - - +
+
Assessment Scores
+
+ + + +
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css index aedd7ed609..17cc777f35 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css @@ -51,7 +51,7 @@ input { } .scoresheet-top-btn-group { - margin-top: -45px; + margin-top: 0; } /* AI-generated answer styling (blue text) */ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 9b1194b2a1..992aeefffa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -577,26 +577,35 @@ function collapseAllAccordions(divId) { }); } -function regenerateAIScoresheetAnswers() { +function regenerateAIScoresheetAnswers(capturePromptIo = false, triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); - const $button = $('#regenerateAiScoresheetBtn'); + const $button = triggerButton ? $(triggerButton) : $('#regenerateAiScoresheetBtn'); const existingHtml = $button.html(); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; if (!applicationId || $button.prop('disabled')) { return; } + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#aiScoringPromptCaptureContainer', '#aiScoringPromptCaptureOutput'); + } + $button .html( - 'Refreshing Scoring...' + 'Generating...' ) .prop('disabled', true); unity.grantManager.grantApplications.applicationAIScoring - .generateAIScoresheetAnswers(applicationId) + .generateAIScoresheetAnswers(applicationId, promptVersion, capturePromptIo) .done(function () { abp.notify.success('AI scoring refreshed successfully.'); - PubSub.publish('refresh_assessment_scores', null); + PubSub.publish('refresh_assessment_scores', { + promptVersion: promptVersion, + capturePromptIo: capturePromptIo, + applicationId: applicationId, + }); }) .fail(function () { abp.message.error( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index c5ac30b12a..e9d1d60177 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -1,5 +1,12 @@ // Note: File depends on Unity.GrantManager.Web\Views\Shared\Components\_Shared\Attachments.js $(function () { + globalThis.generateAIAttachmentSummaries = function(capturePromptIo = false, triggerButton = null) { + $('#generateAiSummaries') + .data('capture-prompt-io', capturePromptIo) + .data('trigger-button', triggerButton || null) + .trigger('click'); + }; + const downloadAll = $('#downloadAll'); const dt = $('#ChefsAttachmentsTable'); let chefsDataTable; @@ -200,32 +207,60 @@ $(function () { //Generate AI summaries for attachments const $generateAISummariesButton = $('#generateAiSummaries'); if ($generateAISummariesButton.length > 0) { + function resetAttachmentSelectionState() { + selectedAtttachments = []; + $('.select-all-chefs-files').prop('checked', false); + $('.chkbox').prop('checked', false); + $(downloadAll).prop('disabled', true); + $generateAISummariesButton.prop('disabled', true); + } + $generateAISummariesButton.on('click', function () { const $button = $(this); - const selectedRows = chefsDataTable.rows({ selected: true }).data(); - - if (selectedRows.length === 0) { + const triggerButton = $button.data('trigger-button'); + const $activeButton = triggerButton ? $(triggerButton) : $button; + const rowsToProcess = triggerButton + ? chefsDataTable.rows().data() + : chefsDataTable.rows({ selected: true }).data(); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + const capturePromptIo = $button.data('capture-prompt-io') === true; + const applicationId = $('#DetailsViewApplicationId').val(); + + $button.removeData('capture-prompt-io'); + $button.removeData('trigger-button'); + + if (rowsToProcess.length === 0) { abp.message.warn( - 'Please select at least one attachment to generate summaries.' + triggerButton + ? 'No attachments were found to generate summaries for.' + : 'Please select at least one attachment to generate summaries.' ); return; } - // Get attachment IDs from selected rows - const attachmentIds = selectedRows.toArray().map((row) => row.id); + const attachmentIds = rowsToProcess.toArray().map((row) => row.id); - const existingHTML = $button.html(); + const existingHTML = $activeButton.html(); + + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#attachmentPromptCaptureContainer', '#attachmentPromptCaptureOutput'); + } // Call the backend API $.ajax({ - url: '/api/app/attachment/generate-aISummaries-attachments', + url: + '/api/app/attachment/generate-aISummaries-attachments' + + '?promptVersion=' + + encodeURIComponent(promptVersion || '') + + '&capturePromptIo=' + + encodeURIComponent(String(capturePromptIo)), data: JSON.stringify(attachmentIds), contentType: 'application/json', type: 'POST', beforeSend: function () { - $button + $activeButton .html( - ' Generating...' + 'Generating...' ) .prop('disabled', true); }, @@ -236,20 +271,32 @@ $(function () { ' attachment(s).' ); + resetAttachmentSelectionState(); + // Reload the table to show new summaries chefsDataTable.ajax.reload(); // Enable the toggle button now that we have summaries $('#toggleAllAISummaries').prop('disabled', false); - $button.html(existingHTML).prop('disabled', false); + if (capturePromptIo && globalThis.loadAIPromptCapture) { + globalThis.loadAIPromptCapture( + applicationId, + 'AttachmentSummary', + promptVersion, + '#attachmentPromptCaptureContainer', + '#attachmentPromptCaptureOutput' + ); + } + + $activeButton.html(existingHTML).prop('disabled', false); }, error: function (error) { console.error('Error generating AI summaries:', error); abp.notify.error( 'An error occurred while generating AI summaries. Please try again.' ); - $button.html(existingHTML).prop('disabled', false); + $activeButton.html(existingHTML).prop('disabled', false); }, }); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 1b25d70409..6e56c53e13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -9,10 +9,25 @@
@if (ViewBag.IsAIAttachmentSummariesEnabled) { - - + + } - + @*
-
\ No newline at end of file +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index e41b752c50..bb92c5bf84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -24,8 +24,8 @@ "BackgroundJobs": { "IsJobExecutionEnabled": true, "Quartz": { - "UseCluster": false, - "IsAutoRegisterEnabled": false + "UseCluster": true, + "IsAutoRegisterEnabled": true }, "IntakeResync": { "Expression": "0 0 12 1/1 * ? *", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 7d5da8e4b4..eb29270cfa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -20,7 +20,15 @@ "HostName": "127.0.0.1", "Port": 5672, "UserName": "guest", - "VirtualHost": "/" + "VirtualHost": "/", + "GrantsPortal": { + "Exchange": "grants.messaging", + "ExchangeType": "topic", + "InboundQueue": "unity.commands", + "InboundRoutingKeys": [ "commands.unity.plugindata" ], + "AckRoutingKey": "grants.unity.acknowledgment", + "MessageRetentionDays": 30 + } }, "Payments": { "CasBaseUrl": "https://:/ords/cas/", diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs index 7b38a48e83..708cd4e30a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs @@ -433,5 +433,60 @@ public async Task GetDataAsync_ShouldNotOverridePrimaryWhenAlreadySet() var primary = dto.Addresses.Single(a => a.IsPrimary); primary.City.ShouldBe("Vancouver"); } + + [Fact] + public async Task GetDataAsync_MultipleApplicantIds_ShouldMakeApplicantPathNotEditable() + { + // Arrange + var request = CreateRequest(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + var applicantId1 = Guid.NewGuid(); + var applicantId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", s => s.ApplicantId = applicantId1), + CreateSubmission(applicationId2, "TESTUSER", s => s.ApplicantId = applicantId2) + ], + [ + CreateAddress(a => { a.ApplicantId = applicantId1; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicantId = applicantId2; a.City = "Vancouver"; }) + ]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert — multiple distinct ApplicantIds means applicant-path addresses are NOT editable + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + dto.Addresses.ShouldAllBe(a => !a.IsEditable); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.AddressInfo + }; + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index 7d7c20fc77..279d5ddfc6 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -9,6 +9,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Integrations; using Unity.GrantManager.TestHelpers; +using Unity.Payments.Domain.PaymentRequests; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Xunit; @@ -52,6 +53,17 @@ private static AddressInfoDataProvider CreateAddressInfoDataProvider() return new AddressInfoDataProvider(currentTenant, submissionRepo, addressRepo, applicationRepo); } + private static OrgInfoDataProvider CreateOrgInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicantRepo = Substitute.For>(); + applicantRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + return new OrgInfoDataProvider(currentTenant, submissionRepo, applicantRepo); + } + private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() { var currentTenant = Substitute.For(); @@ -87,14 +99,14 @@ public async Task ContactInfoDataProvider_GetDataAsync_ShouldReturnContactInfoDt [Fact] public void OrgInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new OrgInfoDataProvider(); + var provider = CreateOrgInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.OrgInfo); } [Fact] public async Task OrgInfoDataProvider_GetDataAsync_ShouldReturnOrgInfoDto() { - var provider = new OrgInfoDataProvider(); + var provider = CreateOrgInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.OrgInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -132,17 +144,30 @@ public async Task SubmissionInfoDataProvider_GetDataAsync_ShouldReturnSubmission result.ShouldBeOfType(); } + private static PaymentInfoDataProvider CreatePaymentInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicationRepo = Substitute.For>(); + applicationRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var paymentRequestRepo = Substitute.For>(); + paymentRequestRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + return new PaymentInfoDataProvider(currentTenant, submissionRepo, applicationRepo, paymentRequestRepo); + } + [Fact] public void PaymentInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new PaymentInfoDataProvider(); + var provider = CreatePaymentInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.PaymentInfo); } [Fact] public async Task PaymentInfoDataProvider_GetDataAsync_ShouldReturnPaymentInfoDto() { - var provider = new PaymentInfoDataProvider(); + var provider = CreatePaymentInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.PaymentInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -154,10 +179,10 @@ public void AllProviders_ShouldHaveUniqueKeys() IApplicantProfileDataProvider[] providers = [ CreateContactInfoDataProvider(), - new OrgInfoDataProvider(), + CreateOrgInfoDataProvider(), CreateAddressInfoDataProvider(), CreateSubmissionInfoDataProvider(), - new PaymentInfoDataProvider() + CreatePaymentInfoDataProvider() ]; var keys = providers.Select(p => p.Key).ToList(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs new file mode 100644 index 0000000000..fe252f011b --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs @@ -0,0 +1,323 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class OrgInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _applicantRepo; + private readonly OrgInfoDataProvider _provider; + + public OrgInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _applicantRepo = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new OrgInfoDataProvider(_currentTenant, _submissionRepo, _applicantRepo); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicantRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable applicants) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _applicantRepo.GetQueryableAsync() + .Returns(Task.FromResult(applicants.AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Guid applicantId) + { + var entity = new ApplicationFormSubmission + { + ApplicationId = applicationId, + OidcSub = oidcSub, + ApplicantId = applicantId + }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + return entity; + } + + private static Applicant CreateApplicant(Guid id, Action? configure = null) + { + var entity = new Applicant(); + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + [Fact] + public void Key_ShouldMatchExpected() + { + _provider.Key.ShouldBe(ApplicantProfileKeys.OrgInfo); + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + var request = CreateRequest(); + + await _provider.GetDataAsync(request); + + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + result.DataType.ShouldBe("ORGINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoSubmissions_ShouldReturnEmptyList() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapAllApplicantFields() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => + { + a.OrgName = "Acme Corp"; + a.OrganizationType = "Non-Profit"; + a.OrgNumber = "BC1234567"; + a.OrgStatus = "Active"; + a.NonRegOrgName = "Acme Trading"; + a.FiscalMonth = "April"; + a.FiscalDay = 1; + a.OrganizationSize = "51-100"; + a.Sector = "Technology"; + a.SubSector = "Software"; + })]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + + var org = dto.Organizations[0]; + org.Id.ShouldBe(applicantId); + org.OrgName.ShouldBe("Acme Corp"); + org.OrganizationType.ShouldBe("Non-Profit"); + org.OrgNumber.ShouldBe("BC1234567"); + org.OrgStatus.ShouldBe("Active"); + org.NonRegOrgName.ShouldBe("Acme Trading"); + org.FiscalMonth.ShouldBe("April"); + org.FiscalDay.ShouldBe(1); + org.OrganizationSize.ShouldBe("51-100"); + org.Sector.ShouldBe("Technology"); + org.SubSector.ShouldBe("Software"); + } + + [Fact] + public async Task GetDataAsync_ShouldHandleNullApplicantFields() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId)]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + + var org = dto.Organizations[0]; + org.OrgName.ShouldBeNull(); + org.OrganizationType.ShouldBeNull(); + org.OrgNumber.ShouldBeNull(); + org.OrgStatus.ShouldBeNull(); + org.NonRegOrgName.ShouldBeNull(); + org.FiscalMonth.ShouldBeNull(); + org.FiscalDay.ShouldBeNull(); + org.OrganizationSize.ShouldBeNull(); + org.Sector.ShouldBeNull(); + org.SubSector.ShouldBeNull(); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnApplicantsForOtherSubjects() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Other Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleApplicants() + { + var request = CreateRequest(); + var applicantId1 = Guid.NewGuid(); + var applicantId2 = Guid.NewGuid(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", applicantId1), + CreateSubmission(applicationId2, "TESTUSER", applicantId2) + ], + [ + CreateApplicant(applicantId1, a => a.OrgName = "Org One"), + CreateApplicant(applicantId2, a => a.OrgName = "Org Two") + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(2); + dto.Organizations.ShouldContain(o => o.OrgName == "Org One"); + dto.Organizations.ShouldContain(o => o.OrgName == "Org Two"); + } + + [Fact] + public async Task GetDataAsync_MultipleSubmissionsSameApplicant_ShouldReturnDuplicates() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", applicantId), + CreateSubmission(applicationId2, "TESTUSER", applicantId) + ], + [CreateApplicant(applicantId, a => a.OrgName = "Same Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(2); + dto.Organizations.ShouldAllBe(o => o.OrgName == "Same Org"); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithAtSign() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Test Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Test Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_WithNullSubject_ShouldReturnEmptyList() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = null!, + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs new file mode 100644 index 0000000000..33d09b85d2 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -0,0 +1,353 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.TestHelpers; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class PaymentInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _applicationRepo; + private readonly IRepository _paymentRequestRepo; + private readonly PaymentInfoDataProvider _provider; + + public PaymentInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _applicationRepo = Substitute.For>(); + _paymentRequestRepo = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new PaymentInfoDataProvider(_currentTenant, _submissionRepo, _applicationRepo, _paymentRequestRepo); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _paymentRequestRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable applications, + IEnumerable paymentRequests) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(applications.AsAsyncQueryable())); + _paymentRequestRepo.GetQueryableAsync() + .Returns(Task.FromResult(paymentRequests.AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.PaymentInfo + }; + + private static ApplicationFormSubmission CreateSubmission(Guid applicationId, string oidcSub) + { + var entity = new ApplicationFormSubmission { ApplicationId = applicationId, OidcSub = oidcSub }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + return entity; + } + + private static Application CreateApplication(Guid id, string referenceNo = "") + { + var entity = new Application { ReferenceNo = referenceNo }; + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m) + { + var siteId = Guid.NewGuid(); + var dto = new CreatePaymentRequestDto + { + InvoiceNumber = "INV-001", + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "C-001", + SupplierNumber = "SUP-001", + SupplierName = "Test Supplier", + SiteId = siteId, + CorrelationId = correlationId, + CorrelationProvider = "Application" + }; + return new PaymentRequest(Guid.NewGuid(), dto); + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + var request = CreateRequest(); + + await _provider.GetDataAsync(request); + + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + result.DataType.ShouldBe("PAYMENTINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoSubmissions_ShouldReturnEmptyList() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_WithNullSubject_ShouldReturnEmptyDto() + { + var request = CreateRequest(); + request.Subject = ""; + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_WithNoPayments_ShouldReturnEmptyList() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + []); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapPaymentFields() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + var payment = CreatePaymentRequest(applicationId, 5000m); + payment.SetPaymentNumber("PAY-100"); + payment.SetPaymentDate("15-Jan-2025"); + payment.SetPaymentRequestStatus(PaymentRequestStatus.Paid); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [payment]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(1); + + var item = dto.Payments[0]; + item.PaymentNumber.ShouldBe("PAY-100"); + item.ReferenceNo.ShouldBe("REF-001"); + item.Amount.ShouldBe(5000m); + item.PaymentDate.ShouldBe("2025-01-15"); + item.PaymentStatus.ShouldBe("Paid"); + } + + [Fact] + public async Task GetDataAsync_ShouldUseApplicationReferenceNo_NotPaymentReferenceNumber() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + var payment = CreatePaymentRequest(applicationId); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "APP-REF-999")], + [payment]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments[0].ReferenceNo.ShouldBe("APP-REF-999"); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultiplePaymentsForSameApplication() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [ + CreatePaymentRequest(applicationId, 1000m), + CreatePaymentRequest(applicationId, 2000m) + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnPaymentsAcrossMultipleApplications() + { + var request = CreateRequest(); + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(appId1, "TESTUSER"), + CreateSubmission(appId2, "TESTUSER") + ], + [ + CreateApplication(appId1, "REF-001"), + CreateApplication(appId2, "REF-002") + ], + [ + CreatePaymentRequest(appId1, 1000m), + CreatePaymentRequest(appId2, 2000m) + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(2); + dto.Payments.ShouldContain(p => p.ReferenceNo == "REF-001"); + dto.Payments.ShouldContain(p => p.ReferenceNo == "REF-002"); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnPaymentsForOtherSubjects() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER")], + [CreateApplication(applicationId, "REF-001")], + [CreatePaymentRequest(applicationId)]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldHandleNullPaymentNumber() + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + var payment = CreatePaymentRequest(applicationId); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [payment]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments[0].PaymentNumber.ShouldBe(string.Empty); + } + + [Theory] + [InlineData(PaymentRequestStatus.L1Pending, "L1Pending")] + [InlineData(PaymentRequestStatus.Submitted, "Submitted")] + [InlineData(PaymentRequestStatus.Paid, "Paid")] + [InlineData(PaymentRequestStatus.Failed, "Failed")] + public async Task GetDataAsync_ShouldMapPaymentStatus(PaymentRequestStatus status, string expectedStatusString) + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + var payment = CreatePaymentRequest(applicationId); + payment.SetPaymentRequestStatus(status); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [payment]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments[0].PaymentStatus.ShouldBe(expectedStatusString); + } + + [Fact] + public void Key_ShouldMatchExpected() + { + _provider.Key.ShouldBe(ApplicantProfileKeys.PaymentInfo); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnPaymentsForUnrelatedApplications() + { + var request = CreateRequest(); + var matchedAppId = Guid.NewGuid(); + var unrelatedAppId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(matchedAppId, "TESTUSER")], + [ + CreateApplication(matchedAppId, "REF-001"), + CreateApplication(unrelatedAppId, "REF-999") + ], + [ + CreatePaymentRequest(matchedAppId, 1000m), + CreatePaymentRequest(unrelatedAppId, 5000m) + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(1); + dto.Payments[0].ReferenceNo.ShouldBe("REF-001"); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs index ecae9fbfcb..501a684ee4 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs @@ -315,5 +315,104 @@ public async Task GetDataAsync_ShouldNotReturnSubmissionsForOtherSubjects() var dto = result.ShouldBeOfType(); dto.Submissions.ShouldBeEmpty(); } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleSubmissions() + { + // Arrange + var request = CreateRequest(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", s => s.ChefsSubmissionGuid = "sub-1"), + CreateSubmission(applicationId2, "TESTUSER", s => s.ChefsSubmissionGuid = "sub-2") + ], + [ + CreateApplication(applicationId1, statusId, a => a.ReferenceNo = "REF-001"), + CreateApplication(applicationId2, statusId, a => a.ReferenceNo = "REF-002") + ], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(2); + dto.Submissions.ShouldContain(s => s.ReferenceNo == "REF-001"); + dto.Submissions.ShouldContain(s => s.ReferenceNo == "REF-002"); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.SubmissionInfo + }; + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveLinkSourceWithTrailingSlash() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1/")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBe("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/user/view?s="); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenSubmissionIsNullOrEmpty() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = null!; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs index dde051f27b..40bce57b1a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -192,5 +192,25 @@ public async Task GetDataAsync_ShouldReturnCorrectDataType() // Assert result.DataType.ShouldBe("CONTACTINFO"); } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.ContactInfo + }; + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync("TESTUSER"); + await _applicantProfileContactService.Received(1).GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs new file mode 100644 index 0000000000..dd6ea9ae46 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class AddressEditHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressEditHandler _handler; + + public AddressEditHandlerTests() + { + _addressRepository = Substitute.For(); + + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new AddressEditHandler( + _addressRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? addressId = null, + JObject? data = null) + { + addressId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + street = "123 Main St", + street2 = "Suite 100", + unit = "4A", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + country = "Canada", + addressType = "MAILING", + isPrimary = true + }); + + return new PluginDataPayload + { + Action = "ADDRESS_EDIT_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateAddressFields() + { + // Arrange + var addressId = Guid.NewGuid(); + var existingAddress = WithId(new ApplicantAddress + { + Street = "Old Street", + City = "Old City" + }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(existingAddress); + + ApplicantAddress? updatedAddress = null; + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedAddress = ci.ArgAt(0); + return updatedAddress; + }); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address updated successfully"); + updatedAddress.ShouldNotBeNull(); + updatedAddress.Street.ShouldBe("123 Main St"); + updatedAddress.Street2.ShouldBe("Suite 100"); + updatedAddress.Unit.ShouldBe("4A"); + updatedAddress.City.ShouldBe("Victoria"); + updatedAddress.Province.ShouldBe("BC"); + updatedAddress.Postal.ShouldBe("V8W 1A1"); + updatedAddress.Country.ShouldBe("Canada"); + updatedAddress.AddressType.ShouldBe(AddressType.MailingAddress); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var addressId = Guid.NewGuid(); + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(WithId(new ApplicantAddress(), addressId)); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _addressRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Address type mapping + + [Theory] + [InlineData("MAILING", AddressType.MailingAddress)] + [InlineData("mailing", AddressType.MailingAddress)] + [InlineData("PHYSICAL", AddressType.PhysicalAddress)] + [InlineData("physical", AddressType.PhysicalAddress)] + [InlineData("BUSINESS", AddressType.BusinessAddress)] + [InlineData("business", AddressType.BusinessAddress)] + [InlineData("UNKNOWN", AddressType.PhysicalAddress)] + [InlineData(null, AddressType.PhysicalAddress)] + public async Task HandleAsync_ShouldMapAddressTypeCorrectly(string? addressType, AddressType expected) + { + // Arrange + var addressId = Guid.NewGuid(); + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(WithId(new ApplicantAddress(), addressId)); + + ApplicantAddress? updatedAddress = null; + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedAddress = ci.ArgAt(0); + return updatedAddress; + }); + + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1" + }); + if (addressType != null) + { + data["addressType"] = addressType; + } + + var payload = CreatePayload(addressId: addressId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + updatedAddress.ShouldNotBeNull(); + updatedAddress.AddressType.ShouldBe(expected); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenAddressIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.AddressId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs new file mode 100644 index 0000000000..224f396b89 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class AddressSetPrimaryHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressSetPrimaryHandler _handler; + + public AddressSetPrimaryHandlerTests() + { + _addressRepository = Substitute.For(); + + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new AddressSetPrimaryHandler( + _addressRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? addressId = null, + Guid? profileId = null) + { + addressId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "ADDRESS_SET_PRIMARY_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = profileId.Value.ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldSetPrimaryOnTargetAddress() + { + // Arrange + var addressId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List()); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address set as primary"); + address.GetProperty("isPrimary").ShouldBeTrue(); + await _addressRepository.Received(1).UpdateAsync(address, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldClearPrimaryOnSiblingAddresses() + { + // Arrange + var addressId = Guid.NewGuid(); + var siblingId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + + var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); + sibling.SetProperty("isPrimary", true); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) + .Returns(sibling); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List { address, sibling }); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert — sibling should have isPrimary cleared + sibling.GetProperty("isPrimary").ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenNoApplicantId_ShouldNotLookupSiblings() + { + // Arrange + var addressId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = null }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address set as primary"); + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSetProfileIdProperty() + { + // Arrange + var addressId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = null }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + address.GetProperty("profileId").ShouldBe(profileId.ToString()); + } + + [Fact] + public async Task HandleAsync_ShouldSkipSiblingsWithoutIsPrimaryProperty() + { + // Arrange + var addressId = Guid.NewGuid(); + var siblingWithoutProp = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingWithoutProp); + // sibling does NOT have isPrimary property + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List { address, sibling }); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert — sibling should not have been fetched for update + await _addressRepository.DidNotReceive().GetAsync(siblingWithoutProp, Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenAddressIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.AddressId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenProfileIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ProfileId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs new file mode 100644 index 0000000000..47206b4812 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs @@ -0,0 +1,439 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactCreateHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly IApplicationFormSubmissionRepository _submissionRepository; + private readonly IApplicantAgentRepository _agentRepository; + private readonly ContactCreateHandler _handler; + + public ContactCreateHandlerTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + _submissionRepository = Substitute.For(); + _agentRepository = Substitute.For(); + + // Default: no existing contact, no submissions, no agents + _contactRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Contact?)null); + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + _contactLinkRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _handler = new ContactCreateHandler( + _contactRepository, + _contactLinkRepository, + _submissionRepository, + _agentRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + string? profileId = null, + string? subject = null, + JObject? data = null) + { + contactId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid().ToString(); + + data ??= JObject.FromObject(new + { + name = "Jane Doe", + email = "jane@example.com", + title = "Director", + contactType = "ApplicantProfile", + homePhoneNumber = "111-1111", + mobilePhoneNumber = "222-2222", + workPhoneNumber = "333-3333", + workPhoneExtension = "101", + role = "Primary Contact", + isPrimary = true + }); + + return new PluginDataPayload + { + Action = "CONTACT_CREATE_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = profileId, + Subject = subject, + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldCreateContactAndLink() + { + // Arrange + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact created successfully"); + await _contactRepository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _contactLinkRepository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSetContactFields() + { + // Arrange + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.Name.ShouldBe("Jane Doe"); + savedContact.Email.ShouldBe("jane@example.com"); + savedContact.Title.ShouldBe("Director"); + savedContact.HomePhoneNumber.ShouldBe("111-1111"); + savedContact.MobilePhoneNumber.ShouldBe("222-2222"); + savedContact.WorkPhoneNumber.ShouldBe("333-3333"); + savedContact.WorkPhoneExtension.ShouldBe("101"); + } + + [Fact] + public async Task HandleAsync_ShouldSetContactLinkFields() + { + // Arrange + var profileId = Guid.NewGuid().ToString(); + var contactId = Guid.NewGuid(); + ContactLink? savedLink = null; + + _contactLinkRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedLink = ci.ArgAt(0); + return savedLink; + }); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedLink.ShouldNotBeNull(); + savedLink.ContactId.ShouldBe(contactId); + savedLink.RelatedEntityType.ShouldBe("ApplicantProfile"); + savedLink.RelatedEntityId.ShouldBe(Guid.Parse(profileId)); + savedLink.Role.ShouldBe("Primary Contact"); + savedLink.IsPrimary.ShouldBeTrue(); + savedLink.IsActive.ShouldBeTrue(); + } + + #endregion + + #region Idempotency + + [Fact] + public async Task HandleAsync_WhenContactAlreadyExists_ShouldReturnIdempotentSuccess() + { + // Arrange + var contactId = Guid.NewGuid(); + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Contact { Name = "Existing" }, contactId)); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact already exists"); + await _contactRepository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _contactLinkRepository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion + + #region Applicant agent ID lookup + + [Fact] + public async Task HandleAsync_WhenSubmissionsExistWithAgents_ShouldSetApplicantAgentIds() + { + // Arrange — subject arrives as raw IDP value; OidcSub is stored normalized + var rawSubject = "testuser@idir"; + var normalizedSub = "TESTUSER"; + var applicationId = Guid.NewGuid(); + var agentId = Guid.NewGuid(); + + var submission = new ApplicationFormSubmission + { + OidcSub = normalizedSub, + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }; + + var agent = WithId(new ApplicantAgent { ApplicationId = applicationId }, agentId); + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns([submission]); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns([agent]); + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: rawSubject); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldContainKey("applicantAgentIds"); + var agentIds = (List)savedContact.ExtraProperties["applicantAgentIds"]!; + agentIds.ShouldContain(agentId.ToString()); + } + + [Fact] + public async Task HandleAsync_WhenMultipleSubmissionsAndAgents_ShouldSetDistinctAgentIds() + { + // Arrange + var rawSubject = "multiuser@idir"; + var normalizedSub = "MULTIUSER"; + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); + var agentId1 = Guid.NewGuid(); + var agentId2 = Guid.NewGuid(); + + var submissions = new List + { + new() + { + OidcSub = normalizedSub, + ApplicationId = appId1, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }, + new() + { + OidcSub = normalizedSub, + ApplicationId = appId2, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + } + }; + + var agents = new List + { + WithId(new ApplicantAgent { ApplicationId = appId1 }, agentId1), + WithId(new ApplicantAgent { ApplicationId = appId2 }, agentId2) + }; + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(submissions); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(agents); + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: rawSubject); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldContainKey("applicantAgentIds"); + var agentIds = (List)savedContact.ExtraProperties["applicantAgentIds"]!; + agentIds.Count.ShouldBe(2); + agentIds.ShouldContain(agentId1.ToString()); + agentIds.ShouldContain(agentId2.ToString()); + } + + [Fact] + public async Task HandleAsync_WhenNoSubmissions_ShouldNotSetApplicantAgentIds() + { + // Arrange — default mock returns empty submissions + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + [Fact] + public async Task HandleAsync_WhenSubmissionsExistButNoAgents_ShouldNotSetApplicantAgentIds() + { + // Arrange + var submission = new ApplicationFormSubmission + { + OidcSub = "SOMEUSER", + ApplicationId = Guid.NewGuid(), + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }; + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List { submission }); + // agents remain empty (default mock) + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: "someuser@idir"); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + [Fact] + public async Task HandleAsync_WhenSubjectIsNull_ShouldNotSetApplicantAgentIds() + { + // Arrange — subject not provided + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + payload.Subject = null; + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + #endregion + + #region NormalizeOidcSub + + [Theory] + [InlineData("testuser@idir", "TESTUSER")] + [InlineData("abc@bceidbusiness", "ABC")] + [InlineData("ALREADY", "ALREADY")] + [InlineData("mixedCase", "MIXEDCASE")] + [InlineData("user@", "USER")] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("@idir", null)] + public void NormalizeOidcSub_ShouldStripIdpSuffixAndUppercase(string? input, string? expected) + { + ContactCreateHandler.NormalizeOidcSub(input).ShouldBe(expected); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs new file mode 100644 index 0000000000..9c20cfe9e2 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactDeleteHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactDeleteHandler _handler; + + public ContactDeleteHandlerTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + + // Defaults: no contact, no links + _contactRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Contact?)null); + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _handler = new ContactDeleteHandler( + _contactRepository, + _contactLinkRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload(Guid? contactId = null) + { + contactId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "CONTACT_DELETE_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldDeleteContactAndLinks() + { + // Arrange + var contactId = Guid.NewGuid(); + var contact = WithId(new Contact { Name = "To Delete" }, contactId); + var links = new List + { + WithId(new ContactLink { ContactId = contactId, RelatedEntityType = "Profile" }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = contactId, RelatedEntityType = "Profile" }, Guid.NewGuid()) + }; + + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(contact); + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(links); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact deleted successfully"); + await _contactLinkRepository.Received(1).DeleteManyAsync(links, Arg.Any(), Arg.Any()); + await _contactRepository.Received(1).DeleteAsync(contact, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenNoLinksExist_ShouldOnlyDeleteContact() + { + // Arrange + var contactId = Guid.NewGuid(); + var contact = WithId(new Contact { Name = "No Links" }, contactId); + + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(contact); + // links default to empty list + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact deleted successfully"); + await _contactLinkRepository.DidNotReceive().DeleteManyAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + await _contactRepository.Received(1).DeleteAsync(contact, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenContactDoesNotExist_ShouldNotThrow() + { + // Arrange — contact not found (default mock returns null) + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert — should still return success (idempotent delete) + result.ShouldBe("Contact deleted successfully"); + await _contactRepository.DidNotReceive().DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs new file mode 100644 index 0000000000..cf9ddc0e73 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactEditHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly ContactEditHandler _handler; + + public ContactEditHandlerTests() + { + _contactRepository = Substitute.For(); + + _contactRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new ContactEditHandler( + _contactRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + JObject? data = null) + { + contactId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + name = "Updated Name", + email = "updated@example.com", + title = "Manager", + homePhoneNumber = "444-4444", + mobilePhoneNumber = "555-5555", + workPhoneNumber = "666-6666", + workPhoneExtension = "202" + }); + + return new PluginDataPayload + { + Action = "CONTACT_EDIT_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateContactFields() + { + // Arrange + var contactId = Guid.NewGuid(); + var existingContact = WithId(new Contact + { + Name = "Old Name", + Email = "old@example.com" + }, contactId); + + _contactRepository.GetAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(existingContact); + + Contact? updatedContact = null; + _contactRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedContact = ci.ArgAt(0); + return updatedContact; + }); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact updated successfully"); + updatedContact.ShouldNotBeNull(); + updatedContact.Name.ShouldBe("Updated Name"); + updatedContact.Email.ShouldBe("updated@example.com"); + updatedContact.Title.ShouldBe("Manager"); + updatedContact.HomePhoneNumber.ShouldBe("444-4444"); + updatedContact.MobilePhoneNumber.ShouldBe("555-5555"); + updatedContact.WorkPhoneNumber.ShouldBe("666-6666"); + updatedContact.WorkPhoneExtension.ShouldBe("202"); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var contactId = Guid.NewGuid(); + _contactRepository.GetAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Contact { Name = "Old" }, contactId)); + + var payload = CreatePayload(contactId: contactId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _contactRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs new file mode 100644 index 0000000000..799dabb18e --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs @@ -0,0 +1,172 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactSetPrimaryHandlerTests +{ + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactSetPrimaryHandler _handler; + + public ContactSetPrimaryHandlerTests() + { + _contactLinkRepository = Substitute.For(); + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _contactLinkRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new ContactSetPrimaryHandler( + _contactLinkRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + Guid? profileId = null) + { + contactId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "CONTACT_SET_PRIMARY_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = profileId.Value.ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldSetMatchingContactAsPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var otherContactId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + + var targetLink = WithId(new ContactLink + { + ContactId = contactId, + RelatedEntityId = profileId, + IsPrimary = false, + IsActive = true + }, Guid.NewGuid()); + + var otherLink = WithId(new ContactLink + { + ContactId = otherContactId, + RelatedEntityId = profileId, + IsPrimary = true, + IsActive = true + }, Guid.NewGuid()); + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List { targetLink, otherLink }); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact set as primary"); + targetLink.IsPrimary.ShouldBeTrue(); + otherLink.IsPrimary.ShouldBeFalse(); + await _contactLinkRepository.Received(2).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenNoLinksExist_ShouldReturnSuccess() + { + // Arrange — default mock returns empty list + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact set as primary"); + await _contactLinkRepository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldOnlySetTargetAsPrimary() + { + // Arrange — three links, only the target should be primary + var contactId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + + var links = new List + { + WithId(new ContactLink { ContactId = contactId, RelatedEntityId = profileId, IsPrimary = false, IsActive = true }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = Guid.NewGuid(), RelatedEntityId = profileId, IsPrimary = true, IsActive = true }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = Guid.NewGuid(), RelatedEntityId = profileId, IsPrimary = true, IsActive = true }, Guid.NewGuid()) + }; + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(links); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + links.Single(l => l.ContactId == contactId).IsPrimary.ShouldBeTrue(); + links.Where(l => l.ContactId != contactId).ShouldAllBe(l => !l.IsPrimary); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenProfileIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ProfileId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs new file mode 100644 index 0000000000..f30098b8c4 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class OrganizationEditHandlerTests +{ + private readonly IApplicantRepository _applicantRepository; + private readonly OrganizationEditHandler _handler; + + public OrganizationEditHandlerTests() + { + _applicantRepository = Substitute.For(); + + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new OrganizationEditHandler( + _applicantRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? organizationId = null, + JObject? data = null) + { + organizationId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + name = "Updated Org", + organizationType = "Non-Profit", + organizationNumber = "ORG-12345", + status = "Active", + nonRegOrgName = "Friendly Name", + fiscalMonth = "April", + fiscalDay = "15", + organizationSize = "Medium" + }); + + return new PluginDataPayload + { + Action = "ORGANIZATION_EDIT_COMMAND", + OrganizationId = organizationId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateAllApplicantFields() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant + { + OrgName = "Old Org", + OrganizationType = "For-Profit" + }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var payload = CreatePayload(organizationId: orgId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Organization updated successfully"); + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.OrgName.ShouldBe("Updated Org"); + updatedApplicant.OrganizationType.ShouldBe("Non-Profit"); + updatedApplicant.OrgNumber.ShouldBe("ORG-12345"); + updatedApplicant.OrgStatus.ShouldBe("Active"); + updatedApplicant.NonRegOrgName.ShouldBe("Friendly Name"); + updatedApplicant.FiscalMonth.ShouldBe("April"); + updatedApplicant.FiscalDay.ShouldBe(15); + updatedApplicant.OrganizationSize.ShouldBe("Medium"); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var orgId = Guid.NewGuid(); + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Applicant(), orgId)); + + var payload = CreatePayload(organizationId: orgId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _applicantRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Fiscal day parsing + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsValidInt_ShouldParseFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Applicant(), orgId)); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org", fiscalDay = "28" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(28); + } + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsNotNumeric_ShouldNotSetFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant { FiscalDay = 10 }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org", fiscalDay = "not-a-number" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert — FiscalDay should remain unchanged (still 10 from initial) + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(10); + } + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsNull_ShouldNotSetFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant { FiscalDay = 5 }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert — FiscalDay remains unchanged + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(5); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenOrganizationIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.OrganizationId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md index 438f7a1bbf..135d63b9f2 100644 --- a/documentation/applicant-portal/applicant-profile-data-providers.md +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -30,8 +30,8 @@ All providers are registered via ABP's `[ExposeServices]` attribute and collecte | `CONTACTINFO` | `ContactInfoDataProvider` | `ApplicantContactInfoDto` | ✅ Implemented | | `ADDRESSINFO` | `AddressInfoDataProvider` | `ApplicantAddressInfoDto` | ✅ Implemented | | `SUBMISSIONINFO` | `SubmissionInfoDataProvider` | `ApplicantSubmissionInfoDto` | ✅ Implemented | -| `ORGINFO` | `OrgInfoDataProvider` | `ApplicantOrgInfoDto` | ⬜ Placeholder | -| `PAYMENTINFO` | `PaymentInfoDataProvider` | `ApplicantPaymentInfoDto` | ⬜ Placeholder | +| `ORGINFO` | `OrgInfoDataProvider` | `ApplicantOrgInfoDto` | ✅ Implemented | +| `PAYMENTINFO` | `PaymentInfoDataProvider` | `ApplicantPaymentInfoDto` | ✅ Implemented | **Response:** `ApplicantProfileDto` with a polymorphic `Data` property (JSON discriminator: `dataType`). @@ -55,11 +55,8 @@ graph TB ProviderDict --> ContactProvider["ContactInfoDataProvider
CONTACTINFO"] ProviderDict --> AddressProvider["AddressInfoDataProvider
ADDRESSINFO"] ProviderDict --> SubmissionProvider["SubmissionInfoDataProvider
SUBMISSIONINFO"] - ProviderDict --> OrgProvider["OrgInfoDataProvider
ORGINFO
placeholder"] - ProviderDict --> PaymentProvider["PaymentInfoDataProvider
PAYMENTINFO
placeholder"] - - style OrgProvider fill:#f5f5f5,stroke:#bbb,stroke-dasharray:5 - style PaymentProvider fill:#f5f5f5,stroke:#bbb,stroke-dasharray:5 + ProviderDict --> OrgProvider["OrgInfoDataProvider
ORGINFO"] + ProviderDict --> PaymentProvider["PaymentInfoDataProvider
PAYMENTINFO"] ``` --- @@ -97,6 +94,20 @@ sequenceDiagram --- +## Provider Interface + +```csharp +public interface IApplicantProfileDataProvider +{ + string Key { get; } + Task GetDataAsync(ApplicantProfileInfoRequest request); +} +``` + +All providers are registered via ABP's `[ExposeServices(typeof(IApplicantProfileDataProvider))]` attribute and resolved as an `IEnumerable` collection. The app service indexes them by `Key` for O(1) dispatch. + +--- + ## Provider Details ### 1. ContactInfoDataProvider (`CONTACTINFO`) @@ -333,19 +344,97 @@ flowchart LR --- -### 4. OrgInfoDataProvider (`ORGINFO`) — Placeholder +### 4. OrgInfoDataProvider (`ORGINFO`) + +**Purpose:** Provides organization information for the applicant profile. + +**Source**: `Applicant` entity, linked via `ApplicationFormSubmission.ApplicantId`. + +**Query**: Joins `ApplicationFormSubmission` → `Applicant` where `OidcSub` matches the normalized subject. Returns all matching applicant records — duplicates are **not** removed, since a single user may have multiple submissions pointing to the same or different applicant records. The UI is responsible for presenting this appropriately. + +**Response DTO**: `ApplicantOrgInfoDto` + +```json +{ + "dataType": "ORGINFO", + "organizations": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "orgName": "Acme Corp", + "organizationType": "Non-Profit", + "orgNumber": "BC1234567", + "orgStatus": "Active", + "nonRegOrgName": null, + "fiscalMonth": "April", + "fiscalDay": 1, + "organizationSize": "51-100", + "sector": "Technology", + "subSector": "Software" + } + ] +} +``` + +**Fields** (from `Applicant` entity): -**Purpose:** Will provide organization information for the applicant profile. +| DTO Field | Entity Field | Type | Description | +|-----------|-------------|------|-------------| +| `Id` | `Applicant.Id` | `Guid` | Applicant ID — used as `organizationId` for edit commands | +| `OrgName` | `Applicant.OrgName` | `string?` | Organization name | +| `OrganizationType` | `Applicant.OrganizationType` | `string?` | Type of organization | +| `OrgNumber` | `Applicant.OrgNumber` | `string?` | Organization registration number | +| `OrgStatus` | `Applicant.OrgStatus` | `string?` | Organization status | +| `NonRegOrgName` | `Applicant.NonRegOrgName` | `string?` | Non-registered organization name | +| `FiscalMonth` | `Applicant.FiscalMonth` | `string?` | Fiscal year start month | +| `FiscalDay` | `Applicant.FiscalDay` | `int?` | Fiscal year start day | +| `OrganizationSize` | `Applicant.OrganizationSize` | `string?` | Size category | +| `Sector` | `Applicant.Sector` | `string?` | Industry sector | +| `SubSector` | `Applicant.SubSector` | `string?` | Industry sub-sector | -**Current Status:** Returns an empty `ApplicantOrgInfoDto` with no data fields populated. No dependencies or query logic implemented yet. +**Multiple Applicants**: It is possible for a single OIDC subject to be linked to multiple distinct `Applicant` records (via different `ApplicationFormSubmission` rows). The provider returns all of them. When the same applicant is linked by multiple submissions, each join result is returned — the UI handles presentation and any eventual deduplication is a process-level concern. + +**Relationship to OrganizationEditHandler**: The `ORGANIZATION_EDIT_COMMAND` handler (see [RabbitMQ integration](./grants-portal-rabbitmq-integration.md)) updates a single `Applicant` entity by its ID. The `Id` field in the org info response corresponds to the `organizationId` expected by the edit command payload. --- -### 5. PaymentInfoDataProvider (`PAYMENTINFO`) — Placeholder +### 5. PaymentInfoDataProvider (`PAYMENTINFO`) + +**Purpose:** Provides payment information for the applicant profile. + +**Source**: `PaymentRequest` entity (from `Unity.Payments` module), linked via `ApplicationFormSubmission` → `Application` where `PaymentRequest.CorrelationId` matches the application ID. + +**Query**: Normalizes the OIDC subject, then joins `ApplicationFormSubmission` → `Application` to build a lookup of `ApplicationId → ReferenceNo`. Payment requests whose `CorrelationId` is in that set are returned with the application's `ReferenceNo` resolved from the lookup. + +**Response DTO**: `ApplicantPaymentInfoDto` + +```json +{ + "dataType": "PAYMENTINFO", + "payments": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "paymentNumber": "PAY-100", + "referenceNo": "REF-001", + "amount": 5000.00, + "paymentDate": "2025-01-15", + "paymentStatus": "Paid" + } + ] +} +``` -**Purpose:** Will provide payment information for the applicant profile. +**Fields** (from `PaymentRequest` entity): -**Current Status:** Returns an empty `ApplicantPaymentInfoDto` with no data fields populated. No dependencies or query logic implemented yet. +| DTO Field | Source | Type | Description | +|-----------|--------|------|-------------| +| `Id` | `PaymentRequest.Id` | `Guid` | Payment request identifier | +| `PaymentNumber` | `PaymentRequest.PaymentNumber` | `string` | CAS payment number (empty string if null) | +| `ReferenceNo` | `Application.ReferenceNo` | `string` | Application reference number, resolved via `CorrelationId → Application` lookup | +| `Amount` | `PaymentRequest.Amount` | `decimal` | Requested payment amount | +| `PaymentDate` | `PaymentRequest.PaymentDate` | `string?` | Date string populated during CAS reconciliation | +| `PaymentStatus` | `PaymentRequest.Status` | `string` | Enum converted to string (e.g. `L1Pending`, `Submitted`, `Paid`, `Failed`) | + +**Cross-module note**: This provider queries the `PaymentRequest` entity directly from the `Unity.Payments` module via `IRepository`. The `CorrelationId` on `PaymentRequest` corresponds to the `Application.Id` in the grant manager domain. --- @@ -545,10 +634,10 @@ src/ │ └── ProfileData/ │ ├── ApplicantProfileDataDto.cs # Polymorphic base (discriminator) │ ├── ApplicantContactInfoDto.cs # CONTACTINFO response -│ ├── ApplicantOrgInfoDto.cs # ORGINFO response (placeholder) +│ ├── ApplicantOrgInfoDto.cs # ORGINFO response │ ├── ApplicantAddressInfoDto.cs # ADDRESSINFO response │ ├── ApplicantSubmissionInfoDto.cs # SUBMISSIONINFO response -│ ├── ApplicantPaymentInfoDto.cs # PAYMENTINFO response (placeholder) +│ ├── ApplicantPaymentInfoDto.cs # PAYMENTINFO response │ ├── ContactInfoItemDto.cs # Individual contact item │ ├── AddressInfoItemDto.cs # Individual address item │ └── SubmissionInfoItemDto.cs # Individual submission item @@ -560,8 +649,8 @@ src/ │ ├── AddressInfoDataProvider.cs # ADDRESSINFO provider │ ├── ContactInfoDataProvider.cs # CONTACTINFO provider │ ├── SubmissionInfoDataProvider.cs # SUBMISSIONINFO provider -│ ├── OrgInfoDataProvider.cs # ORGINFO provider (placeholder) -│ └── PaymentInfoDataProvider.cs # PAYMENTINFO provider (placeholder) +│ ├── OrgInfoDataProvider.cs # ORGINFO provider +│ └── PaymentInfoDataProvider.cs # PAYMENTINFO provider │ ├── Unity.GrantManager.Application/Intakes/ │ ├── IntakeFormSubmissionManager.cs # Import orchestrator (calls ExtractOidcSub) @@ -570,3 +659,25 @@ src/ └── Unity.GrantManager.HttpApi/Controllers/ └── ApplicantProfileController.cs # API controller entry point ``` + +--- + +## Data Flow: Read vs. Write + +| Direction | Mechanism | Example | +|-----------|-----------|--------| +| **Read** (Portal → Unity) | HTTP GET via `ApplicantProfileController` → provider | Portal requests org info by key `ORGINFO` | +| **Write** (Portal → Unity) | RabbitMQ command via [messaging pipeline](./grants-portal-rabbitmq-integration.md) | Portal sends `ORGANIZATION_EDIT_COMMAND` with applicant ID | + +The `Id` returned by each provider's read response is used as the entity identifier in the corresponding write command. For organization data, the `OrgInfoItemDto.Id` maps to the `organizationId` field in `PluginDataPayload`. + +--- + +## Adding a New Provider + +1. Create a DTO class inheriting from `ApplicantProfileDataDto` in `Application.Contracts/ApplicantProfile/ProfileData/` +2. Register the DTO as a `[JsonDerivedType]` on `ApplicantProfileDataDto` +3. Add a key constant to `ApplicantProfileKeys` +4. Implement `IApplicantProfileDataProvider` in `Application/ApplicantProfile/` +5. Annotate with `[ExposeServices(typeof(IApplicantProfileDataProvider))]` and `ITransientDependency` +6. Add unit tests following the patterns in `OrgInfoDataProviderTests` or `AddressInfoDataProviderTests` diff --git a/documentation/applicant-portal/grants-portal-rabbitmq-integration.md b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md new file mode 100644 index 0000000000..1e8cbd532f --- /dev/null +++ b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md @@ -0,0 +1,555 @@ +# Grants Portal — RabbitMQ Messaging Integration + +## Overview + +The Unity Grant Manager receives commands from the Applicant Portal (Grants Portal) via RabbitMQ and sends acknowledgment responses back. This provides reliable, decoupled communication for profile data mutations (contacts, addresses, organizations) that the portal user initiates. + +The integration is built on the [Transactional Outbox Pattern](./transactional-outbox-pattern.md) with a message processing pipeline: the consumer runs as a `BackgroundService` (competing consumer on every pod), while the inbox processor, outbox publisher, and cleanup workers run as Quartz `[DisallowConcurrentExecution]` jobs coordinated via the clustered Quartz scheduler. + +**Source name**: `"GrantsPortal"` (used as the discriminator in inbox/outbox tables) + +--- + +## Architecture + +```mermaid +graph TB + subgraph Portal["Applicant Portal"] + PP["Portal Plugin"] + end + + subgraph Broker["RabbitMQ"] + EX["Exchange: grants.messaging
(topic)"] + QI["Queue: unity.commands
Routing: commands.unity.plugindata"] + QA["Routing: grants.unity.acknowledgment"] + end + + subgraph Unity["Unity Grant Manager"] + S1["① GrantsPortalCommandConsumerService
RabbitMQ → InboxMessages"] + IT[(InboxMessages)] + S2["② GrantsPortalInboxWorker
InboxMessages → Handler → OutboxMessages"] + H["IPortalCommandHandler
implementations"] + OT[(OutboxMessages)] + S3["③ GrantsPortalOutboxWorker
OutboxMessages → RabbitMQ"] + S4["④ GrantsPortalMessageCleanupWorker
Purge old rows"] + end + + PP -->|"Publish command"| EX + EX -->|"commands.unity.plugindata"| QI + QI --> S1 + S1 --> IT + IT --> S2 + S2 --> H + S2 --> OT + OT --> S3 + S3 -->|"grants.unity.acknowledgment"| EX + EX -->|"grants.*.acknowledgment"| Portal + S4 -.-> IT + S4 -.-> OT + + style IT fill:#e8f5e9 + style OT fill:#fff4e6 + style Broker fill:#fff4e6 +``` + +--- + +## RabbitMQ Topology + +The consumer service declares the following topology on startup: + +| Element | Name | Type | Durable | +|---------|------|------|---------| +| **Exchange** | `grants.messaging` | `topic` | ✅ | +| **Queue** | `unity.commands` | — | ✅ | +| **Binding** | `unity.commands` ← `grants.messaging` | Routing key: `commands.unity.plugindata` | — | +| **Ack Routing Key** | `grants.unity.acknowledgment` | Published to same exchange | — | + +The exchange and queue are declared idempotently each time the consumer connects (including reconnects). + +**Prefetch**: `prefetchCount = 1` — messages are consumed one at a time per connection. + +--- + +## Message Format + +### Inbound — PluginDataEnvelope + +All inbound commands use the `PluginDataEnvelope` wrapper: + +```json +{ + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "messageType": "PluginData", + "createdAt": "2026-01-15T22:42:24.115Z", + "correlationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "pluginId": "grants-portal", + "dataType": "CONTACT_CREATE_COMMAND", + "data": { + "action": "CONTACT_CREATE_COMMAND", + "contactId": "a1b2c3d4-...", + "profileId": "3fa85f64-...", + "subject": "testuser@idir", + "provider": "7c9e6679-...", + "data": { } + } +} +``` + +**AMQP Properties** (set by the publisher): + +| Property | Usage | +|----------|-------| +| `MessageId` | Idempotency key (falls back to `envelope.messageId`) | +| `Type` | Message type discriminator | +| `CorrelationId` | Correlation ID (falls back to `envelope.correlationId`) | + +**Envelope Fields**: + +| Field | Type | Description | +|-------|------|-------------| +| `messageId` | `string` | Unique message ID | +| `messageType` | `string` | Message type | +| `createdAt` | `DateTime` | When the message was created | +| `correlationId` | `string` | Groups related messages | +| `pluginId` | `string` | Source plugin identifier | +| `dataType` | `string` | **Command discriminator** — used to route to the correct handler | +| `data` | `JObject` | Nested `PluginDataPayload` with command-specific fields | + +**PluginDataPayload Fields** (inside `data`): + +| Field | Type | Description | +|-------|------|-------------| +| `action` | `string` | Redundant with `dataType` | +| `contactId` | `string?` | Target contact ID (for contact commands) | +| `addressId` | `string?` | Target address ID (for address commands) | +| `organizationId` | `string?` | Target organization/applicant ID | +| `profileId` | `string?` | Applicant profile ID | +| `subject` | `string?` | Raw OIDC subject identifier (e.g. `testuser@idir`). Used by `ContactCreateHandler` to match submissions — Unity normalizes by stripping the IDP suffix and uppercasing before comparison. | +| `provider` | `string?` | **Tenant ID** as a GUID string — used for tenant resolution | +| `data` | `JObject?` | Inner command-specific data payload | + +### Outbound — MessageAcknowledgment + +After processing, Unity publishes an acknowledgment: + +```json +{ + "messageId": "new-uuid", + "messageType": "MessageAcknowledgment", + "createdAt": "2026-01-15T22:42:25.003Z", + "correlationId": "7c9e6679-...", + "pluginId": "UNITY", + "originalMessageId": "550e8400-...", + "status": "SUCCESS", + "details": "Contact created successfully", + "processedAt": "2026-01-15T22:42:25.003Z" +} +``` + +**AMQP Properties**: + +| Property | Value | +|----------|-------| +| `Type` | `"MessageAcknowledgment"` | +| `ContentType` | `"application/json"` | +| `Persistent` | `true` | +| `MessageId` | New UUID for the ack | +| `CorrelationId` | Passthrough from original | + +**Ack Loop Prevention**: The consumer discards any received messages where `Type == "MessageAcknowledgment"` to prevent infinite loops when the same exchange is used for both directions. + +--- + +## Pipeline Services + +### ① GrantsPortalCommandConsumerService + +**Role**: Pulls messages from RabbitMQ, saves to inbox, ACKs. + +**Startup**: +- Connects to RabbitMQ with exponential backoff retry (5 attempts, starting at 5s) +- Declares exchange + queue + bindings +- Starts async consumer with `autoAck: false` + +**On message received**: +1. Extract `MessageId`, `Type`, `CorrelationId` from AMQP properties (with envelope fallbacks) +2. Discard if `Type == "MessageAcknowledgment"` +3. Deserialize JSON body to `PluginDataEnvelope` +4. Resolve `TenantId` from `data.provider` field (GUID parse) +5. **Idempotency check**: `FindByMessageIdAsync(messageId)` — skip if already exists +6. Insert `InboxMessage` with `Status = Pending` +7. **ACK** the delivery tag (only after commit) + +**Connection recovery**: On `ConnectionShutdown`, waits 5s then re-runs `ConnectAndConsumeAsync`. A `SemaphoreSlim` guard prevents parallel reconnect attempts within the same process when RabbitMQ fires multiple shutdown events in rapid succession (e.g., network flap). + +**Multi-pod idempotency**: The `InboxMessages.MessageId` column has a **unique index**. The consumer first checks `FindByMessageIdAsync` (fast path), but if two pods race on a redelivered message, the unique constraint prevents duplicate inserts. The consumer catches the PostgreSQL `23505` (unique violation) and treats it as idempotent success — ACKs without requeueing. + +### ② GrantsPortalInboxWorker + +**Role**: Polls inbox, dispatches to handlers, writes ack to outbox. + +**Schedule**: Quartz cron (default: `0/5 * * * * ?` — every 5 seconds). Configurable via `InboxProcessorCron`. + +**Concurrency**: `[DisallowConcurrentExecution]` — only one instance runs at a time. + +**Per message**: +1. Mark `Status = Processing`, increment `RetryCount` +2. Deserialize `Payload` → `PluginDataEnvelope` → `PluginDataPayload` +3. Find matching `IPortalCommandHandler` by `DataType` (case-insensitive) +4. If no handler: `ackStatus = "FAILED"`, `details = "Unknown command type: ..."` +5. If handler found: + - **Switch to tenant context** (`ICurrentTenant.Change(inboxMsg.TenantId)`) + - Execute handler inside a new Unit of Work + - `ackStatus = "SUCCESS"`, `details` = handler return string +6. On exception: + - If **transient** and under max retries (3): reset to `Pending` for retry + - Otherwise: `ackStatus = "FAILED"`, `details` = user-friendly error message +7. Mark inbox as `Processed` or `Failed` and write `OutboxMessage` — **same transaction** + +**Tenant context**: Only the handler execution runs under the tenant context. Inbox/outbox operations run against the host database without tenant scoping. + +### ③ GrantsPortalOutboxWorker + +**Role**: Polls outbox, publishes acks to RabbitMQ with publisher confirms. + +**Schedule**: Quartz cron (default: `0/5 * * * * ?` — every 5 seconds). Configurable via `OutboxProcessorCron`. + +**Concurrency**: `[DisallowConcurrentExecution]` — only one instance runs at a time. + +**Per message**: +1. Ensure RabbitMQ channel is open (with `ConfirmSelect` enabled) +2. Publish via `GrantsPortalAcknowledgmentPublisher` to `grants.messaging` exchange with routing key `grants.unity.acknowledgment` +3. Wait for broker confirm (`WaitForConfirms` with 5s timeout) +4. On confirm: mark `Status = Processed`, set `PublishedAt` +5. On failure: increment `RetryCount`; after 3 attempts mark as `Failed` + +### ④ GrantsPortalMessageCleanupWorker + +**Role**: Purges old processed/failed messages from both tables. + +- **Schedule**: Quartz cron (default: `0 0 0/1 * * ?` — every hour). Configurable via `MessageCleanupCron`. +- **Retention**: Configurable via `MessageRetentionDays` (default: 30) +- **Scope**: Deletes rows where `Status ∈ {Processed, Failed}` and `ReceivedAt`/`CreatedAt` < cutoff +- **Concurrency**: `[DisallowConcurrentExecution]` + +--- + +## Command Handlers + +All handlers implement `IPortalCommandHandler` and are registered as transient services: + +```csharp +public interface IPortalCommandHandler +{ + string DataType { get; } + Task HandleAsync(PluginDataPayload payload); +} +``` + +The return string becomes the `Details` field in the outbound acknowledgment. + +### Implemented Commands + +| DataType | Handler | Entity | Description | +|----------|---------|--------|-------------| +| `CONTACT_CREATE_COMMAND` | `ContactCreateHandler` | `Contact` + `ContactLink` | Creates a new contact and links it to the profile. Enriches the contact with applicant agent IDs from matching submissions. Idempotent — skips if contact already exists. | +| `CONTACT_EDIT_COMMAND` | `ContactEditHandler` | `Contact` | Updates an existing contact's fields. | +| `CONTACT_SET_PRIMARY_COMMAND` | `ContactSetPrimaryHandler` | `ContactLink` | Sets one contact as primary for a profile; clears primary on all other links. | +| `CONTACT_DELETE_COMMAND` | `ContactDeleteHandler` | `ContactLink` + `Contact` | Deletes contact links then the contact entity. | +| `ADDRESS_EDIT_COMMAND` | `AddressEditHandler` | `ApplicantAddress` | Updates address fields (street, city, province, etc.) and address type. | +| `ADDRESS_SET_PRIMARY_COMMAND` | `AddressSetPrimaryHandler` | `ApplicantAddress` | Sets `isPrimary` extra property on the target address; clears it on sibling addresses that had it set. | +| `ORGANIZATION_EDIT_COMMAND` | `OrganizationEditHandler` | `Applicant` | Updates organization fields on the applicant entity. The `organizationId` corresponds to `Applicant.Id` returned by [OrgInfoDataProvider](./applicant-profile-data-providers.md#orginfordataprovider). | + +### Command Data Payloads + +Each command that requires inner data deserializes `payload.Data` to a typed class in `GrantsPortal/Messages/Commands/`: + +**ContactCreateData / ContactEditData**: +```json +{ + "name": "John Doe", + "email": "john@example.com", + "title": "Director", + "contactType": "ApplicantProfile", + "homePhoneNumber": "(555) 111-1111", + "mobilePhoneNumber": "(555) 222-2222", + "workPhoneNumber": "(555) 333-3333", + "workPhoneExtension": "123", + "role": "Primary Contact", + "isPrimary": true +} +``` + +### Applicant Agent ID Enrichment + +When a contact is created via `CONTACT_CREATE_COMMAND`, the handler enriches the `Contact` entity with applicant agent IDs linked to the subject's submissions. This allows downstream systems to associate portal contacts with existing application agents. + +**How it works**: + +1. The handler reads the raw OIDC subject from `payload.Subject` (e.g. `testuser@idir`). +2. It normalizes the subject to match the format stored in `ApplicationFormSubmission.OidcSub`: + - Strips the IDP suffix (everything after and including `@`) + - Converts to uppercase + - Example: `testuser@idir` → `TESTUSER` +3. It queries `ApplicationFormSubmission` records where `OidcSub` matches the normalized value. +4. From those submissions, it collects distinct `ApplicationId` values. +5. It queries `ApplicantAgent` records linked to those applications. +6. The distinct agent IDs are stored on the contact's `ExtraProperties` as `applicantAgentIds`. + +> **Note**: The normalization follows the same convention as `IntakeSubmissionHelper.ExtractOidcSub`, which is used when CHEFS submissions are ingested. + +**Resulting ExtraProperties** (on the `Contact` entity): +```json +{ + "applicantAgentIds": ["agent-guid-1", "agent-guid-2"] +} +``` + +If `subject` is null/empty, or no matching submissions or agents are found, the `applicantAgentIds` property is not set. This is a best-effort enrichment and does not fail the contact creation. + +**AddressEditData**: +```json +{ + "addressType": "PHYSICAL", + "street": "123 Main St", + "street2": "Suite 100", + "unit": "4B", + "city": "Victoria", + "province": "BC", + "postalCode": "V8V 1A1", + "country": "Canada", + "isPrimary": false +} +``` + +**OrganizationEditData**: +```json +{ + "name": "Acme Corp", + "organizationType": "Non-Profit", + "organizationNumber": "BC1234567", + "status": "Active", + "nonRegOrgName": null, + "fiscalMonth": "April", + "fiscalDay": "1", + "organizationSize": "51-100" +} +``` + +--- + +## Tenant Resolution + +The consumer extracts the tenant ID from `data.provider` in the message payload: + +```csharp +private static Guid? ResolveTenantId(string? provider) +{ + if (string.IsNullOrWhiteSpace(provider)) return null; + if (Guid.TryParse(provider, out var tenantGuid)) return tenantGuid; + return null; +} +``` + +The tenant ID is stored on the `InboxMessage.TenantId` field. The inbox processor uses `ICurrentTenant.Change(tenantId)` only when executing the domain handler — inbox/outbox table operations always run against the host database. + +--- + +## Configuration + +### appsettings.json + +```json +{ + "RabbitMQ": { + "HostName": "127.0.0.1", + "Port": 5672, + "UserName": "guest", + "VirtualHost": "/", + "GrantsPortal": { + "Exchange": "grants.messaging", + "ExchangeType": "topic", + "InboundQueue": "unity.commands", + "InboundRoutingKeys": [ "commands.unity.plugindata" ], + "AckRoutingKey": "grants.unity.acknowledgment", + "MessageRetentionDays": 30, + "InboxProcessorCron": "0/5 * * * * ?", + "OutboxProcessorCron": "0/5 * * * * ?", + "MessageCleanupCron": "0 0 0/1 * * ?" + } + } +} +``` + +### GrantsPortalRabbitMqOptions + +``` +Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `Exchange` | `"grants.messaging"` | Topic exchange name | +| `ExchangeType` | `"topic"` | Exchange type | +| `InboundQueue` | `"unity.commands"` | Queue to consume from | +| `InboundRoutingKeys` | `["commands.unity.plugindata"]` | Routing keys to bind | +| `AckRoutingKey` | `"grants.unity.acknowledgment"` | Routing key for outbound acks | +| `MessageRetentionDays` | `30` | Days to retain processed/failed messages | +| `InboxProcessorCron` | `"0/5 * * * * ?"` | Quartz cron for inbox polling (every 5s) | +| `OutboxProcessorCron` | `"0/5 * * * * ?"` | Quartz cron for outbox publishing (every 5s) | +| `MessageCleanupCron` | `"0 0 0/1 * * ?"` | Quartz cron for message cleanup (every hour) | + +**Section path**: `RabbitMQ:GrantsPortal` + +--- + +## Service Registration + +All services are registered in `GrantManagerApplicationModule.ConfigureServices`: + +```csharp +// Options +context.Services.Configure( + configuration.GetSection(GrantsPortalRabbitMqOptions.SectionName)); + +// Command handlers +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); + +// Acknowledgment publisher +context.Services.AddScoped(); + +// Pipeline services +context.Services.AddHostedService(); // ① RabbitMQ → inbox +// ② GrantsPortalInboxWorker — Quartz (auto-registered) — inbox → handler → outbox +// ③ GrantsPortalOutboxWorker — Quartz (auto-registered) — outbox → RabbitMQ +// ④ GrantsPortalMessageCleanupWorker — Quartz (auto-registered) — purge old rows +``` + +> **Note**: Workers ②③④ extend `QuartzBackgroundWorkerBase` with `[DisallowConcurrentExecution]` and are auto-registered by ABP when `BackgroundJobs:Quartz:IsAutoRegisterEnabled` is `true`. + +--- + +## End-to-End Example + +A portal user edits a contact: + +```mermaid +sequenceDiagram + participant Portal as Applicant Portal + participant RMQ as RabbitMQ + participant Consumer as ① Consumer + participant Inbox as InboxMessages + participant Processor as ② Inbox Worker + participant Handler as ContactEditHandler + participant Outbox as OutboxMessages + participant Publisher as ③ Outbox Worker + + Portal->>RMQ: Publish CONTACT_EDIT_COMMAND
routing: commands.unity.plugindata + RMQ->>Consumer: Deliver message + Consumer->>Inbox: INSERT (Pending) + Consumer->>RMQ: ACK + + loop Poll every 5s + Processor->>Inbox: GetPendingAsync("GrantsPortal") + end + + Processor->>Inbox: UPDATE Status=Processing + Processor->>Handler: HandleAsync(payload)
[tenant context] + Handler->>Handler: Update Contact entity + Processor->>Inbox: UPDATE Status=Processed + Processor->>Outbox: INSERT ack (Pending, SUCCESS) + + loop Poll every 5s + Publisher->>Outbox: GetPendingAsync("GrantsPortal") + end + + Publisher->>RMQ: Publish MessageAcknowledgment
routing: grants.unity.acknowledgment + RMQ-->>Publisher: Confirm + Publisher->>Outbox: UPDATE Status=Processed + + RMQ->>Portal: Deliver acknowledgment +``` + +--- + +## Monitoring + +### Key Queries + +**Pending messages (stuck?)**: +```sql +SELECT "Source", "DataType", "Status", COUNT(*), MIN("ReceivedAt") +FROM "InboxMessages" +WHERE "Status" IN ('Pending', 'Processing') +GROUP BY "Source", "DataType", "Status"; +``` + +**Failed messages**: +```sql +SELECT "MessageId", "DataType", "Details", "RetryCount", "ReceivedAt" +FROM "InboxMessages" +WHERE "Status" = 'Failed' AND "Source" = 'GrantsPortal' +ORDER BY "ReceivedAt" DESC +LIMIT 20; +``` + +**Outbox backlog**: +```sql +SELECT COUNT(*), MIN("CreatedAt") +FROM "OutboxMessages" +WHERE "Status" = 'Pending' AND "Source" = 'GrantsPortal'; +``` + +### Log Markers + +| Log Message | Service | Meaning | +|-------------|---------|---------| +| `"Grants Portal command consumer starting..."` | Consumer | Service starting | +| `"Message {id} saved to inbox for processing"` | Consumer | Message received and saved | +| `"Message {id} already in inbox"` | Consumer | Duplicate detected | +| `"Processing inbox message {id}"` | Processor | Handler dispatch starting | +| `"Inbox message {id} processed with status {status}"` | Processor | Handler completed | +| `"Message {id} will be retried"` | Processor | Transient error, will retry | +| `"Outbox message {id} published"` | Publisher | Ack sent to broker | +| `"Cleaned up {n} messages older than ..."` | Cleanup | Old rows purged | + +--- + +## Troubleshooting + +### Messages stuck in Pending (Inbox) + +**Cause**: Inbox processor may have crashed or is not running. + +**Check**: +```sql +SELECT * FROM "InboxMessages" +WHERE "Status" = 'Pending' AND "ReceivedAt" < NOW() - INTERVAL '5 minutes'; +``` + +**Resolution**: Verify `GrantsPortalInboxWorker` is running in application logs. Restart the application if needed. + +### Messages stuck in Pending (Outbox) + +**Cause**: Outbox processor can't connect to RabbitMQ or broker is not confirming. + +**Check**: Look for `"Error in outbox processor loop"` in logs. Verify RabbitMQ is reachable. + +### Unknown command type + +**Cause**: Portal sent a `dataType` that has no registered `IPortalCommandHandler`. + +**Resolution**: Register the new handler in `GrantManagerApplicationModule`. The failed message will have `Details = "Unknown command type: ..."`. + +### Consumer not connecting + +**Cause**: RabbitMQ unreachable or credentials wrong. + +**Check**: Consumer retries 5 times with exponential backoff. After 5 failures it throws and the hosted service stops. Look for `"Failed to connect to RabbitMQ after 5 attempts"` in logs. diff --git a/documentation/transactional-outbox-pattern.md b/documentation/transactional-outbox-pattern.md new file mode 100644 index 0000000000..253d606770 --- /dev/null +++ b/documentation/transactional-outbox-pattern.md @@ -0,0 +1,488 @@ +# Transactional Outbox Pattern + +## Overview + +Unity Grant Manager uses the **Transactional Inbox/Outbox** pattern for reliable asynchronous messaging with external systems. The pattern ensures that message receipt, processing, and response publishing are each atomic operations — even if the broker or application crashes mid-flow. + +The implementation is **integration-source agnostic**. The same `InboxMessage` and `OutboxMessage` tables, entities, and repositories are shared by all integrations. Each integration is identified by a `Source` discriminator (e.g. `"GrantsPortal"`). + +--- + +## Why This Pattern + +Direct RabbitMQ consumption with inline processing has several failure modes: + +| Problem | Without Outbox | With Outbox | +|---------|---------------|-------------| +| App crashes after processing but before ACK | Message redelivered, duplicate side-effects | Message already saved to inbox; ACK happened at save time | +| Broker unavailable when sending response | Response lost | Response saved to outbox table; publisher retries independently | +| Database commit fails after ACK | ACK'd but no state change | ACK only happens after inbox save commits | +| Need to audit message history | Logs only | Full database trail with status, timestamps, retry counts | + +--- + +## Architecture + +The pattern separates the messaging pipeline into four independent stages: + +```mermaid +graph LR + RMQ[(RabbitMQ
Broker)] + + subgraph Unity["Unity Grant Manager — Host Database"] + S1["① Consumer
BackgroundService"] + IT[(InboxMessages
Table)] + S2["② Inbox Worker
Quartz [DisallowConcurrentExecution]"] + OT[(OutboxMessages
Table)] + S3["③ Outbox Worker
Quartz [DisallowConcurrentExecution]"] + S4["④ Cleanup Worker
Quartz [DisallowConcurrentExecution]"] + end + + RMQ -->|"Consume + ACK"| S1 + S1 -->|"INSERT (Pending)"| IT + S2 -->|"Poll Pending"| IT + S2 -->|"Process → INSERT ack"| OT + S3 -->|"Poll Pending"| OT + S3 -->|"Publish + Confirm"| RMQ + S4 -.->|"DELETE old rows"| IT + S4 -.->|"DELETE old rows"| OT + + style IT fill:#e8f5e9 + style OT fill:#fff4e6 +``` + +### Stage Responsibilities + +| # | Stage | Scope | Transaction Boundary | +|---|-------|-------|---------------------| +| ① | **Consumer** | Receive from broker → save to inbox → ACK | Inbox INSERT committed before ACK sent | +| ② | **Inbox Processor** | Poll inbox → dispatch to handler → write ack to outbox | Handler execution + outbox INSERT in one UoW | +| ③ | **Outbox Processor** | Poll outbox → publish to broker → mark as sent | Publish with broker confirms before UPDATE | +| ④ | **Cleanup** | Delete old Processed/Failed rows | Periodic bulk delete | + +--- + +## Domain Entities + +Both entities live in `Unity.GrantManager.Domain/Messaging/` and are stored in the **host database** (`GrantManagerDbContext`), not in tenant databases. They inherit from ABP's `AuditedAggregateRoot`. + +### InboxMessage + +Represents a message received from an external system, staged for sequential processing. + +``` +Unity.GrantManager.Domain/Messaging/InboxMessage.cs +``` + +| Property | Type | Description | +|----------|------|-------------| +| `Source` | `string` | Integration discriminator (e.g. `"GrantsPortal"`) | +| `MessageId` | `string` | Source system's message ID — used for **idempotency** | +| `CorrelationId` | `string` | Correlation ID passed through from the source | +| `DataType` | `string` | Command discriminator (e.g. `CONTACT_CREATE_COMMAND`) | +| `Payload` | `string` | Full JSON payload of the inbound message | +| `Status` | `MessageStatus` | `Pending` → `Processing` → `Processed` / `Failed` | +| `Details` | `string?` | Processing result or user-friendly error message | +| `RetryCount` | `int` | Number of processing attempts | +| `ReceivedAt` | `DateTime` | When the message arrived from the broker | +| `ProcessedAt` | `DateTime?` | When processing completed | +| `TenantId` | `Guid?` | Tenant context for handler dispatch (metadata, not data isolation) | + +### OutboxMessage + +Represents a response/acknowledgment to be published back to an external system. + +``` +Unity.GrantManager.Domain/Messaging/OutboxMessage.cs +``` + +| Property | Type | Description | +|----------|------|-------------| +| `Source` | `string` | Integration discriminator | +| `MessageId` | `string` | Unique ID for this outbound message | +| `OriginalMessageId` | `string` | The inbound message ID this responds to | +| `CorrelationId` | `string` | Correlation ID from the original message | +| `DataType` | `string` | Command type of the original message | +| `AckStatus` | `string` | `SUCCESS` or `FAILED` | +| `Details` | `string` | Human-readable result or error (safe for end-user display) | +| `Status` | `MessageStatus` | `Pending` → `Processed` / `Failed` | +| `RetryCount` | `int` | Number of publish attempts | +| `CreatedAt` | `DateTime` | When the outbox entry was created | +| `PublishedAt` | `DateTime?` | When the message was confirmed by the broker | +| `TenantId` | `Guid?` | Tenant context metadata | + +### MessageStatus Enum + +```csharp +public enum MessageStatus +{ + Pending = 1, + Processing = 2, + Processed = 3, + Failed = 4 +} +``` + +--- + +## Database Tables + +Both tables are in the **host database** and were added in migration `20260307013604_Add_InboxOutboxMessages`. + +### InboxMessages + +```sql +CREATE TABLE "InboxMessages" ( + "Id" UUID PRIMARY KEY, + "Source" VARCHAR(50) NOT NULL, + "MessageId" VARCHAR(64) NOT NULL, + "CorrelationId" VARCHAR(128) NOT NULL, + "DataType" VARCHAR(100) NOT NULL, + "Payload" JSONB NOT NULL, + "Status" TEXT NOT NULL, + "Details" VARCHAR(2000), + "RetryCount" INTEGER NOT NULL DEFAULT 0, + "ReceivedAt" TIMESTAMP NOT NULL, + "ProcessedAt" TIMESTAMP, + "TenantId" UUID, + -- ABP audit columns + "ExtraProperties" TEXT NOT NULL, + "ConcurrencyStamp" VARCHAR(40) NOT NULL, + "CreationTime" TIMESTAMP NOT NULL, + "CreatorId" UUID, + "LastModificationTime" TIMESTAMP, + "LastModifierId" UUID +); + +CREATE UNIQUE INDEX "IX_InboxMessages_MessageId" + ON "InboxMessages" ("MessageId"); + +CREATE INDEX "IX_InboxMessages_Source_Status" + ON "InboxMessages" ("Source", "Status"); +``` + +### OutboxMessages + +```sql +CREATE TABLE "OutboxMessages" ( + "Id" UUID PRIMARY KEY, + "Source" VARCHAR(50) NOT NULL, + "MessageId" VARCHAR(64) NOT NULL, + "OriginalMessageId" VARCHAR(64) NOT NULL, + "CorrelationId" VARCHAR(128) NOT NULL, + "DataType" VARCHAR(100) NOT NULL, + "AckStatus" VARCHAR(20) NOT NULL, + "Details" VARCHAR(2000) NOT NULL, + "Status" TEXT NOT NULL, + "RetryCount" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TIMESTAMP NOT NULL, + "PublishedAt" TIMESTAMP, + "TenantId" UUID, + -- ABP audit columns + "ExtraProperties" TEXT NOT NULL, + "ConcurrencyStamp" VARCHAR(40) NOT NULL, + "CreationTime" TIMESTAMP NOT NULL, + "CreatorId" UUID, + "LastModificationTime" TIMESTAMP, + "LastModifierId" UUID +); + +CREATE INDEX "IX_OutboxMessages_Source_Status" + ON "OutboxMessages" ("Source", "Status"); +``` + +--- + +## Repository Interfaces + +Both repositories extend ABP's `IRepository` and add integration-specific queries. + +``` +Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs +Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs +``` + +### IInboxMessageRepository + +| Method | Description | +|--------|-------------| +| `FindByMessageIdAsync(string messageId)` | Idempotency check — find by source message ID | +| `GetPendingAsync(string source, int maxCount)` | Poll for messages with `Status == Pending`, ordered by `ReceivedAt` | +| `DeleteProcessedOlderThanAsync(DateTime cutoff)` | Bulk delete `Processed` or `Failed` rows older than cutoff | + +### IOutboxMessageRepository + +| Method | Description | +|--------|-------------| +| `GetPendingAsync(string source, int maxCount)` | Poll for messages with `Status == Pending`, ordered by `CreatedAt` | +| `DeleteProcessedOlderThanAsync(DateTime cutoff)` | Bulk delete `Processed` or `Failed` rows older than cutoff | + +EF Core implementations are in `Unity.GrantManager.EntityFrameworkCore/Repositories/` and use `GrantManagerDbContext` (host DB). + +--- + +## Message Lifecycle + +```mermaid +stateDiagram-v2 + direction LR + + state "Inbox" as inbox { + [*] --> Pending : Consumer saves + Pending --> Processing : Processor picks up + Processing --> Processed : Handler succeeds + Processing --> Failed : Handler fails (max retries) + Processing --> Pending : Transient error (retry) + } + + state "Outbox" as outbox { + [*] --> OPending : Processor writes ack + OPending --> OProcessed : Publisher confirms + OPending --> OFailed : Max publish retries + + state "Pending" as OPending + state "Processed" as OProcessed + state "Failed" as OFailed + } + + inbox --> outbox : On inbox completion +``` + +### Detailed Flow + +1. **Consumer receives** a message from the broker. +2. Consumer saves it to `InboxMessages` with `Status = Pending` inside a Unit of Work. +3. After the UoW commits, the consumer **ACKs** the broker delivery. If the save fails, the message is **rejected/requeued**. +4. **Inbox Processor** polls `InboxMessages` for `Pending` rows (filtered by `Source`). +5. For each message, the processor: + - Sets `Status = Processing` and increments `RetryCount` + - Deserializes the payload and dispatches to the appropriate handler + - On **success**: sets `Status = Processed` and writes an `OutboxMessage` with `AckStatus = "SUCCESS"` — both in the **same Unit of Work** + - On **transient failure** (under max retries): resets `Status = Pending` for retry + - On **permanent failure** (or max retries exceeded): sets `Status = Failed` and writes an `OutboxMessage` with `AckStatus = "FAILED"` +6. **Outbox Processor** polls `OutboxMessages` for `Pending` rows. +7. For each message, the processor: + - Publishes to the broker using **publisher confirms** + - After broker confirmation: sets `Status = Processed` and records `PublishedAt` + - On failure (under max retries): increments `RetryCount` + - On max retries exceeded: sets `Status = Failed` +8. **Cleanup Service** periodically deletes `Processed` and `Failed` rows older than the retention period. + +--- + +## Idempotency + +The consumer performs an idempotency check before saving to the inbox: + +``` +FindByMessageIdAsync(messageId) → if exists, ACK and skip +``` + +This prevents duplicate inbox rows if the broker redelivers a message (e.g. after a network hiccup before the ACK reached the broker). + +**Multi-pod safety**: In a multi-pod deployment, two pods could race past the `FindByMessageIdAsync` check on the same redelivered message. The `MessageId` column has a **unique index** (`IX_InboxMessages_MessageId`) as the definitive guard. If the second pod’s insert hits the unique constraint (PostgreSQL error `23505`), the consumer catches it and treats it as idempotent success — ACKs without requeueing. + +--- + +## Error Handling + +### User-Friendly Error Messages + +The inbox processor maps known exception types to user-friendly messages that are safe to return to the external system: + +| Exception Type | User-Facing Message | +|---------------|-------------------| +| `EntityNotFoundException` | The requested record was not found. It may have been deleted. | +| `DbUpdateConcurrencyException` | The record was modified by another process. Please try again. | +| `AbpDbConcurrencyException` | The record was modified by another process. Please try again. | +| _(any other)_ | An unexpected error occurred while processing your request. Please try again or contact support. | + +Stack traces and internal details are **never** leaked to the external system. + +### Transient Error Detection + +Errors are considered transient (eligible for retry) if the exception type name contains `Timeout`, `Concurrency`, or `Transient`, or if the inner exception is a `TimeoutException`. + +--- + +## Cleanup / Retention + +A dedicated Quartz worker runs hourly and deletes `Processed` and `Failed` messages older than the configured retention period. The default retention is **30 days**. + +Both inbox and outbox tables are cleaned in the same pass. + +--- + +## Adding a New Integration Source + +The inbox/outbox infrastructure provides base classes that handle all orchestration logic. +A new integration only needs to provide source-specific configuration, handlers, and a publish implementation. + +### What you get for free + +| Concern | Provided by | +|---------|------------| +| Poll pending → mark processing → dispatch → retry → mark complete → write outbox ack | `InboxWorkerBase` (`Unity.GrantManager.Application/Messaging/`) | +| Poll pending outbox → publish → mark sent/failed with retry | `OutboxWorkerBase` (`Unity.GrantManager.Application/Messaging/`) | +| Handler dispatch by `Source` + `DataType` | `IInboxMessageHandler` (`Unity.GrantManager.Domain/Messaging/`) | +| Shared tables, entities, repos, status machine | `InboxMessage`, `OutboxMessage`, `IInboxMessageRepository`, `IOutboxMessageRepository` | +| Message cleanup | Existing `GrantsPortalMessageCleanupWorker` (deletes all sources — can be reused or cloned) | + +### Step-by-step + +#### 1. Choose a source name + +Pick a unique string (e.g. `"Finance"`) that will be stored in the `Source` column of both tables. + +#### 2. Create an options class + +```csharp +// YourIntegration/Configuration/FinanceIntegrationOptions.cs +public class FinanceIntegrationOptions +{ + public const string SectionName = "Integrations:Finance"; + public const string SourceName = "Finance"; + + public string InboxProcessorCron { get; set; } = "0/10 * * * * ?"; + public string OutboxProcessorCron { get; set; } = "0/10 * * * * ?"; + + // Add transport-specific properties (endpoints, queues, API keys, etc.) +} +``` + +#### 3. Create message handlers + +Implement `IInboxMessageHandler` for each command type. Each handler receives the raw JSON payload and is responsible for its own deserialization: + +```csharp +// YourIntegration/Handlers/InvoiceCreatedHandler.cs +public class InvoiceCreatedHandler : IInboxMessageHandler, ITransientDependency +{ + public string Source => FinanceIntegrationOptions.SourceName; + public string DataType => "INVOICE_CREATED"; + + public async Task HandleAsync(string rawPayload) + { + var data = JsonConvert.DeserializeObject(rawPayload) + ?? throw new JsonException("Invalid payload"); + + // Domain logic here... + + return "Invoice processed successfully"; + } +} +``` + +#### 4. Create an inbox worker + +Subclass `InboxWorkerBase` — typically ~15 lines: + +```csharp +// YourIntegration/FinanceInboxWorker.cs +public class FinanceInboxWorker : InboxWorkerBase +{ + protected override string SourceName => FinanceIntegrationOptions.SourceName; + + public FinanceInboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + JobDetail = JobBuilder.Create() + .WithIdentity(nameof(FinanceInboxWorker)).Build(); + + Trigger = TriggerBuilder.Create() + .WithIdentity(nameof(FinanceInboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(options.Value.InboxProcessorCron) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } +} +``` + +The base class handles: polling, status transitions, tenant context switching, handler dispatch by `Source` + `DataType`, transient error retry, user-friendly error mapping, and writing the outbox ack. + +Override `ToUserFriendlyMessage()` or `IsTransientError()` if your integration has custom error types. + +#### 5. Create an outbox worker + +Subclass `OutboxWorkerBase` and implement `PublishMessageAsync`: + +```csharp +// YourIntegration/FinanceOutboxWorker.cs +public class FinanceOutboxWorker : OutboxWorkerBase +{ + protected override string SourceName => FinanceIntegrationOptions.SourceName; + + public FinanceOutboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + JobDetail = JobBuilder.Create() + .WithIdentity(nameof(FinanceOutboxWorker)).Build(); + + Trigger = TriggerBuilder.Create() + .WithIdentity(nameof(FinanceOutboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(options.Value.OutboxProcessorCron) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + protected override async Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg) + { + // Your transport-specific publish logic here (HTTP, RabbitMQ, gRPC, etc.) + // Throw on failure — the base class handles retry and status updates. + } + + // Optional: override OnBeforePublishCycle() to ensure connections are ready + // Optional: override OnPublishCycleError() to clean up connections on failure +} +``` + +#### 6. Create a consumer (optional — depends on your transport) + +If consuming from a message broker (RabbitMQ, Kafka, etc.), create a `BackgroundService` that: +- Receives messages from the broker +- Saves them to `InboxMessages` with your source name inside a Unit of Work +- ACKs the broker only after the UoW commits + +See `GrantsPortalCommandConsumerService` for a RabbitMQ reference implementation. + +If your inbound messages arrive via HTTP webhook, save them to the inbox table in the webhook endpoint controller instead. + +#### 7. Register in DI + +In your module's `ConfigureServices`: + +```csharp +// Options +context.Services.Configure( + configuration.GetSection(FinanceIntegrationOptions.SectionName)); + +// Handlers (auto-registered if using ITransientDependency, otherwise register explicitly) +context.Services.AddTransient(); + +// Consumer (if using a BackgroundService) +context.Services.AddHostedService(); +``` + +The Quartz inbox/outbox workers auto-register when `BackgroundJobs:Quartz:IsAutoRegisterEnabled` is `true`. + +#### 8. Cleanup + +The existing `GrantsPortalMessageCleanupWorker` deletes all `Processed`/`Failed` rows regardless of source. If the default 30-day retention works for your integration, no additional cleanup worker is needed. + +### What you do NOT need to do + +- No schema changes — the shared tables already support multiple sources via the `Source` column +- No changes to existing integrations — each source's workers and handlers are fully independent +- No custom orchestration logic — `InboxWorkerBase` and `OutboxWorkerBase` handle the full lifecycle + +### Future considerations + +| Area | Status | Notes | +|------|--------|-------| +| Base options interface | Not yet extracted | Could extract `IIntegrationSourceOptions` with shared cron/retention fields | +| Base consumer service | Not yet extracted | RabbitMQ connection management could be shared; currently each consumer owns its own | +| Source-aware cleanup | Not yet needed | Current cleanup worker deletes all sources; could filter by source if retention policies differ |