From 047e03e7012e36c1740a2e094179f28c4ba7230c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 8 Apr 2026 16:25:16 -0700 Subject: [PATCH 01/13] AB#32465 Refactor AI endpoint configuration management --- .../AI/Runtime/OpenAIRuntimeService.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 8319a3bbd..f747af2a2 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -49,6 +49,8 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string DefaultMaxTokensParameterName = "max_completion_tokens"; private const string LegacyMaxTokensParameterName = "max_tokens"; private const string DefaultProviderName = "OpenAI"; + private const string OpenAiApiKeyEnvironmentVariableName = "AZURE_OPENAI_API_KEY"; + private const string OpenAiEndpointEnvironmentVariableName = "AZURE_OPENAI_ENDPOINT"; private const int DefaultCompletionTokens = 2000; private const int DefaultAttachmentSummaryCompletionTokens = 2000; private const int DefaultApplicationAnalysisCompletionTokens = 4000; @@ -742,6 +744,15 @@ private string ResolveProviderName(string? operationName = null) private string? ResolveApiKey(string? operationName = null) { var providerName = ResolveProviderName(operationName); + if (string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + var injectedApiKey = _configuration[OpenAiApiKeyEnvironmentVariableName]; + if (!string.IsNullOrWhiteSpace(injectedApiKey)) + { + return injectedApiKey; + } + } + return _configuration[$"Azure:{providerName}:ApiKey"]; } @@ -772,8 +783,14 @@ private string ResolveApiUrl(string? operationName) var providerName = ResolveProviderName(operationName); var profileName = ResolveProfileName(operationName); var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl"); + var injectedEndpoint = ResolveInjectedEndpoint(providerName); var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"]; + if (!string.IsNullOrWhiteSpace(injectedEndpoint) && !string.IsNullOrWhiteSpace(profileApiUrl)) + { + return CombineEndpointAndPath(injectedEndpoint, profileApiUrl); + } + if (!string.IsNullOrWhiteSpace(profileApiUrl)) { return profileApiUrl; @@ -787,6 +804,22 @@ private string ResolveApiUrl(string? operationName) throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'."); } + private string? ResolveInjectedEndpoint(string providerName) + { + if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + return _configuration[$"Azure:{providerName}:Endpoint"]; + } + + var injectedEndpoint = _configuration[OpenAiEndpointEnvironmentVariableName]; + if (!string.IsNullOrWhiteSpace(injectedEndpoint)) + { + return injectedEndpoint; + } + + return _configuration["Azure:OpenAI:Endpoint"]; + } + private string? ResolveProfileName(string? operationName) { if (!string.IsNullOrWhiteSpace(operationName)) @@ -813,6 +846,23 @@ private string ResolveApiUrl(string? operationName) return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting; } + private static string CombineEndpointAndPath(string endpoint, string profilePath) + { + if (Uri.TryCreate(profilePath, UriKind.Absolute, out var absoluteUri)) + { + return absoluteUri.ToString(); + } + + var trimmedEndpoint = endpoint.Trim().TrimEnd('/'); + var trimmedPath = profilePath.Trim(); + if (!trimmedPath.StartsWith("/", StringComparison.Ordinal)) + { + trimmedPath = "/" + trimmedPath; + } + + return trimmedEndpoint + trimmedPath; + } + private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) { var response = new ApplicationAnalysisResponse(); From 3b1f28c0196a2c9b6a14d94f104fc7c69392555b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Wed, 8 Apr 2026 16:30:28 -0700 Subject: [PATCH 02/13] AB#32465 Update AI development configuration template --- .../appsettings.Development.json | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) 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 0cff0f219..074261e6c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -152,20 +152,21 @@ "ApplicationScoring": { "MaxCompletionTokens": 5000 } - }, - "OpenAI": { - "ApiKey": "", - "Profiles": { - "Gpt4oMini": { - "ApiUrl": "", - "MaxTokensParameter": "max_tokens", - "Temperature": 0.3 - }, - "Gpt5Mini": { - "ApiUrl": "", - "MaxTokensParameter": "max_completion_tokens" - } - } + }, + "OpenAI": { + "ApiKey": "", + "Endpoint": "", + "Profiles": { + "Gpt4oMini": { + "ApiUrl": "/openai/deployments/gpt-4o-mini/chat/completions?api-version=2024-02-01", + "MaxTokensParameter": "max_tokens", + "Temperature": 0.3 + }, + "Gpt5Mini": { + "ApiUrl": "/openai/deployments/gpt-5-mini/chat/completions?api-version=2024-10-01-preview", + "MaxTokensParameter": "max_completion_tokens" + } + } }, "Logging": { "EnablePromptFileLog": true From df9bb3236f1ca55ed826ae9a0fc70ef802b3c216 Mon Sep 17 00:00:00 2001 From: Velang Date: Thu, 9 Apr 2026 14:26:07 -0700 Subject: [PATCH 03/13] fix applicationbar cypress test --- .../cypress/pages/ApplicationsListPage.ts | 100 ++++++++++-------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 108d6b0cf..2128650c0 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -37,7 +37,9 @@ export class ApplicationsListPage extends ApplicationsPage { private readonly extendedActionBar = { customButtons: "#app_custom_buttons", dynamicButtonContainer: "#dynamicButtonContainerId", - exportButton: "#dynamicButtonContainerId .dt-buttons button span", + // Export can be rendered as button/span or anchor depending on DataTables build. + exportButton: + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", saveViewButton: "button.grp-savedStates", }; @@ -94,7 +96,7 @@ export class ApplicationsListPage extends ApplicationsPage { | "last3months" | "last6months" | "alltime" - | "custom" + | "custom", ): this { cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) .should("be.visible") @@ -127,10 +129,11 @@ export class ApplicationsListPage extends ApplicationsPage { | "last3months" | "last6months" | "alltime" - | "custom" + | "custom", ): this { - cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.dateFilters.quickDateRange, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -139,7 +142,9 @@ export class ApplicationsListPage extends ApplicationsPage { * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only. */ setSubmittedFromDate(date: string): this { - cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.dateFilters.submittedFromDate, { + timeout: this.STANDARD_TIMEOUT, + }) .click({ force: true }) .clear({ force: true }) .type(date, { force: true }) @@ -172,7 +177,7 @@ export class ApplicationsListPage extends ApplicationsPage { cy.wrap($s) .should("have.attr", "style") .and("contain", "display: none"); - } + }, ); return this; } @@ -225,10 +230,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Verify table has rows (using scroll body selector) */ verifyTableHasData(): this { - cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.greaterThan", - 1 - ); + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.greaterThan", 1); return this; } @@ -274,7 +278,9 @@ export class ApplicationsListPage extends ApplicationsPage { .then(($els: JQuery) => { const titles: string[] = Cypress.$($els) .toArray() - .map((el: HTMLElement) => (el.textContent || "").replace(/\s+/g, " ").trim()) + .map((el: HTMLElement) => + (el.textContent || "").replace(/\s+/g, " ").trim(), + ) .filter((t: string) => t.length > 0); return titles; }); @@ -287,10 +293,9 @@ export class ApplicationsListPage extends ApplicationsPage { this.getVisibleHeaderTitles().then((titles: string[]) => { const titlesLower = titles.map((t: string) => t.toLowerCase()); expected.forEach((e: string) => { - expect( - titlesLower, - `visible headers should include "${e}"` - ).to.include(e.toLowerCase()); + expect(titlesLower, `visible headers should include "${e}"`).to.include( + e.toLowerCase(), + ); }); }); return this; @@ -302,7 +307,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Scroll to and verify action bar exists */ verifyActionBarExists(): this { - cy.get(this.extendedActionBar.customButtons, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.extendedActionBar.customButtons, { + timeout: this.STANDARD_TIMEOUT, + }) .should("exist") .scrollIntoView(); return this; @@ -325,6 +332,7 @@ export class ApplicationsListPage extends ApplicationsPage { verifyExportButtonVisible(): this { cy.contains(this.extendedActionBar.exportButton, "Export", { timeout: this.STANDARD_TIMEOUT, + matchCase: false, }).should("be.visible"); return this; } @@ -336,7 +344,7 @@ export class ApplicationsListPage extends ApplicationsPage { cy.contains( "#dynamicButtonContainerId button.grp-savedStates", "Save View", - { timeout: this.STANDARD_TIMEOUT } + { timeout: this.STANDARD_TIMEOUT }, ).should("be.visible"); return this; } @@ -346,9 +354,9 @@ export class ApplicationsListPage extends ApplicationsPage { */ verifyColumnsButtonVisible(): this { cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", "Columns", - { timeout: this.STANDARD_TIMEOUT } + { timeout: this.STANDARD_TIMEOUT }, ).should("be.visible"); return this; } @@ -393,9 +401,11 @@ export class ApplicationsListPage extends ApplicationsPage { // Try Cancel button if available (check existence first to avoid timeout) cy.get("body").then(($body: JQuery) => { - const $cancelBtn = $body.find(this.paymentModal.cancelButton).filter( - (_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel") - ); + const $cancelBtn = $body + .find(this.paymentModal.cancelButton) + .filter((_: number, el: HTMLElement) => + (el.textContent || "").includes("Cancel"), + ); if ($cancelBtn.length > 0) { cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); } else { @@ -441,11 +451,11 @@ export class ApplicationsListPage extends ApplicationsPage { ($m: JQuery) => { const isHidden = !$m.is(":visible") || !$m.hasClass("show"); expect(isHidden, "payment-modal hidden or not shown").to.eq(true); - } - ); - cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).should( - "not.exist" + }, ); + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } @@ -479,10 +489,9 @@ export class ApplicationsListPage extends ApplicationsPage { .click({ force: true }); // Wait for table to rebuild - cy.get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.gt", - 5 - ); + cy.get(this.scrollTable.columnTitles, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 5); return this; } @@ -490,15 +499,18 @@ export class ApplicationsListPage extends ApplicationsPage { * Open the Columns menu */ openColumnsMenu(): this { - cy.contains("span", "Columns", { timeout: this.STANDARD_TIMEOUT }) + cy.contains( + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", + /^Columns$/i, + { timeout: this.STANDARD_TIMEOUT }, + ) .should("be.visible") .click(); // Wait for dropdown to be fully populated - cy.get(this.columnsMenu.dropdownItem, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.gt", - 50 - ); + cy.get(this.columnsMenu.dropdownItem, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 50); return this; } @@ -530,7 +542,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Close the Columns menu */ closeColumnsMenu(): this { - cy.get(this.columnsMenu.buttonBackground, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.columnsMenu.buttonBackground, { + timeout: this.STANDARD_TIMEOUT, + }) .should("exist") .click({ force: true }); @@ -567,7 +581,7 @@ export class ApplicationsListPage extends ApplicationsPage { if (switchLink.length === 0) { cy.log( - 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session' + 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', ); cy.get("body").click(0, 0); return; @@ -577,10 +591,12 @@ export class ApplicationsListPage extends ApplicationsPage { cy.url({ timeout: this.STANDARD_TIMEOUT }).should( "include", - "/GrantPrograms" + "/GrantPrograms", ); - cy.get(this.grantProgram.searchInput, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.grantProgram.searchInput, { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .clear() .type(programName); @@ -596,9 +612,9 @@ export class ApplicationsListPage extends ApplicationsPage { cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should( (p: string) => { expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0 + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, ).to.eq(true); - } + }, ); }); }); From 3ac2dea3ea0508bc6f8b1a8706df4586635b7cbf Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 9 Apr 2026 16:33:01 -0700 Subject: [PATCH 04/13] AB#32491 address updates from portal --- .../GrantManagerApplicationModule.cs | 4 + .../Handlers/AddressCreateHandler.cs | 89 +++++ .../Handlers/AddressDeleteHandler.cs | 33 ++ .../Handlers/AddressEditHandler.cs | 27 ++ .../Messages/Commands/AddressCreateData.cs | 37 ++ .../GrantsPortal/AddressCreateHandlerTests.cs | 369 ++++++++++++++++++ .../GrantsPortal/AddressDeleteHandlerTests.cs | 104 +++++ .../GrantsPortal/AddressEditHandlerTests.cs | 110 ++++++ .../grants-portal-rabbitmq-integration.md | 26 +- 9 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 0b46ff244..dae27b6bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -161,8 +161,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddTransient(); 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 @@ -170,8 +172,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) 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.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); context.Services.AddScoped(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs new file mode 100644 index 000000000..3b889f9ce --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs @@ -0,0 +1,89 @@ +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.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressCreateHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_CREATE_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")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Address data is required"); + + // Idempotency: if the address already exists, treat as success + var existing = await applicantAddressRepository.FindAsync(addressId); + if (existing != null) + { + logger.LogInformation("Address {AddressId} already exists. Treating as idempotent success.", addressId); + return "Address already exists"; + } + + logger.LogInformation("Creating address {AddressId} for profile {ProfileId}", addressId, profileId); + + var address = new ApplicantAddress + { + ApplicantId = innerData.ApplicantId, + Street = innerData.Street, + Street2 = innerData.Street2, + Unit = innerData.Unit, + City = innerData.City, + Province = innerData.Province, + Postal = innerData.PostalCode, + Country = innerData.Country, + AddressType = MapAddressType(innerData.AddressType) + }; + + EntityHelper.TrySetId(address, () => addressId); + + address.SetProperty("profileId", profileId.ToString()); + address.SetProperty("isPrimary", innerData.IsPrimary); + + // Demote existing primary addresses for the same applicant + if (innerData.IsPrimary) + { + var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(innerData.ApplicantId); + + foreach (var sibling in siblingAddresses) + { + if (!sibling.HasProperty("isPrimary")) continue; + if (!sibling.GetProperty("isPrimary")) continue; + + var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); + trackedSibling.SetProperty("isPrimary", false); + await applicantAddressRepository.UpdateAsync(trackedSibling); + } + } + + await applicantAddressRepository.InsertAsync(address); + + logger.LogInformation("Address {AddressId} created successfully", addressId); + return "Address created 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/AddressDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs new file mode 100644 index 000000000..a45a2c8a4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressDeleteHandler.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressDeleteHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_DELETE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + + logger.LogInformation("Deleting address {AddressId} for profile {ProfileId}", addressId, payload.ProfileId); + + var address = await applicantAddressRepository.FindAsync(addressId); + if (address != null) + { + await applicantAddressRepository.DeleteAsync(address); + } + + logger.LogInformation("Address {AddressId} deleted successfully", addressId); + return "Address deleted successfully"; + } +} 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 index 202464a00..441cf8ac5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantsPortal.Messages; using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Uow; @@ -36,6 +37,32 @@ public virtual async Task HandleAsync(PluginDataPayload payload) address.Country = innerData.Country; address.AddressType = MapAddressType(innerData.AddressType); + // Sync isPrimary extra property and demote/promote siblings + if (innerData.IsPrimary) + { + 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; + if (!sibling.GetProperty("isPrimary")) continue; + + var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); + trackedSibling.SetProperty("isPrimary", false); + await applicantAddressRepository.UpdateAsync(trackedSibling); + } + } + + address.SetProperty("isPrimary", true); + } + else + { + address.SetProperty("isPrimary", false); + } + await applicantAddressRepository.UpdateAsync(address); logger.LogInformation("Address {AddressId} updated successfully", addressId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs new file mode 100644 index 000000000..17b53d0ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class AddressCreateData +{ + [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; } + + [JsonProperty("applicantId")] + public Guid ApplicantId { get; set; } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs new file mode 100644 index 000000000..49dd61c6c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs @@ -0,0 +1,369 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +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.Data; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class AddressCreateHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressCreateHandler _handler; + + public AddressCreateHandlerTests() + { + _addressRepository = Substitute.For(); + + // Default: no existing address + _addressRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ApplicantAddress?)null); + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + _addressRepository.FindByApplicantIdAsync(Arg.Any()) + .Returns(new List()); + + _handler = new AddressCreateHandler( + _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, + Guid? applicantId = null, + JObject? data = null) + { + addressId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid(); + applicantId ??= 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, + applicantId = applicantId.Value + }); + + return new PluginDataPayload + { + Action = "ADDRESS_CREATE_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = profileId.Value.ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldCreateAddress() + { + // Arrange + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address created successfully"); + savedAddress.ShouldNotBeNull(); + savedAddress.Street.ShouldBe("123 Main St"); + savedAddress.Street2.ShouldBe("Suite 100"); + savedAddress.Unit.ShouldBe("4A"); + savedAddress.City.ShouldBe("Victoria"); + savedAddress.Province.ShouldBe("BC"); + savedAddress.Postal.ShouldBe("V8W 1A1"); + savedAddress.Country.ShouldBe("Canada"); + savedAddress.AddressType.ShouldBe(AddressType.MailingAddress); + } + + [Fact] + public async Task HandleAsync_ShouldCallInsertOnRepository() + { + // Arrange + var payload = CreatePayload(); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _addressRepository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSetApplicantId() + { + // Arrange + var applicantId = Guid.NewGuid(); + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var payload = CreatePayload(applicantId: applicantId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedAddress.ShouldNotBeNull(); + savedAddress.ApplicantId.ShouldBe(applicantId); + } + + [Fact] + public async Task HandleAsync_ShouldSetEntityId() + { + // Arrange + var addressId = Guid.NewGuid(); + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedAddress.ShouldNotBeNull(); + savedAddress.Id.ShouldBe(addressId); + } + + [Fact] + public async Task HandleAsync_ShouldSetProfileIdAndIsPrimaryProperties() + { + // Arrange + var profileId = Guid.NewGuid(); + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var payload = CreatePayload(profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedAddress.ShouldNotBeNull(); + savedAddress.GetProperty("profileId").ShouldBe(profileId.ToString()); + savedAddress.GetProperty("isPrimary").ShouldBeTrue(); + } + + #endregion + + #region Idempotency + + [Fact] + public async Task HandleAsync_WhenAddressAlreadyExists_ShouldReturnIdempotentMessage() + { + // Arrange + var addressId = Guid.NewGuid(); + _addressRepository.FindAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(WithId(new ApplicantAddress(), addressId)); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address already exists"); + await _addressRepository.DidNotReceive().InsertAsync(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 + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + applicantId = Guid.NewGuid() + }); + if (addressType != null) + { + data["addressType"] = addressType; + } + + var payload = CreatePayload(data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedAddress.ShouldNotBeNull(); + savedAddress.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_WhenProfileIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ProfileId = 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 Primary demotion + + [Fact] + public async Task HandleAsync_WhenIsPrimaryTrue_ShouldDemoteSiblingAddresses() + { + // Arrange + var addressId = Guid.NewGuid(); + var siblingId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); + sibling.SetProperty("isPrimary", true); + + _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) + .Returns(sibling); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List { sibling }); + + ApplicantAddress? savedAddress = null; + _addressRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedAddress = ci.ArgAt(0); + return savedAddress; + }); + + var payload = CreatePayload(addressId: addressId, applicantId: applicantId); + + // Act + await _handler.HandleAsync(payload); + + // Assert — sibling should have isPrimary cleared + sibling.GetProperty("isPrimary").ShouldBeFalse(); + savedAddress.ShouldNotBeNull(); + savedAddress.GetProperty("isPrimary").ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_WhenIsPrimaryFalse_ShouldNotDemoteSiblings() + { + // Arrange + var applicantId = Guid.NewGuid(); + + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + country = "Canada", + addressType = "MAILING", + isPrimary = false, + applicantId = applicantId + }); + + var payload = CreatePayload(data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert — should not lookup siblings + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs new file mode 100644 index 000000000..63d2900b3 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressDeleteHandlerTests.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging.Abstractions; +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 AddressDeleteHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressDeleteHandler _handler; + + public AddressDeleteHandlerTests() + { + _addressRepository = Substitute.For(); + + // Default: no existing address + _addressRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ApplicantAddress?)null); + + _handler = new AddressDeleteHandler( + _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) + { + addressId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "ADDRESS_DELETE_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldDeleteAddress() + { + // Arrange + var addressId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { City = "Victoria" }, addressId); + + _addressRepository.FindAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address deleted successfully"); + await _addressRepository.Received(1).DeleteAsync(address, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenAddressDoesNotExist_ShouldNotThrow() + { + // Arrange — address 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("Address deleted successfully"); + await _addressRepository.DidNotReceive().DeleteAsync(Arg.Any(), 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)); + } + + #endregion +} 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 index dd6ea9ae4..8d9e22a46 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs @@ -3,12 +3,14 @@ using NSubstitute; using Shouldly; using System; +using System.Collections.Generic; 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.Data; using Volo.Abp.Domain.Entities; using Xunit; @@ -202,4 +204,112 @@ public async Task HandleAsync_WhenDataMissing_ShouldThrow() } #endregion + + #region Primary tracking + + [Fact] + public async Task HandleAsync_WhenIsPrimaryTrue_ShouldPromoteAndDemoteSiblings() + { + // 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 + address.GetProperty("isPrimary").ShouldBeTrue(); + sibling.GetProperty("isPrimary").ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenIsPrimaryFalse_ShouldClearIsPrimary() + { + // Arrange + var addressId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress(), addressId); + address.SetProperty("isPrimary", true); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + addressType = "MAILING", + isPrimary = false + }); + + var payload = CreatePayload(addressId: addressId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + address.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 + await _handler.HandleAsync(payload); + + // Assert + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + [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 } diff --git a/documentation/applicant-portal/grants-portal-rabbitmq-integration.md b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md index 1e8cbd532..f32003a1b 100644 --- a/documentation/applicant-portal/grants-portal-rabbitmq-integration.md +++ b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md @@ -1,4 +1,4 @@ -# Grants Portal — RabbitMQ Messaging Integration +# Grants Portal — RabbitMQ Messaging Integration ## Overview @@ -254,11 +254,13 @@ The return string becomes the `Details` field in the outbound acknowledgment. | 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_EDIT_COMMAND` | `ContactEditHandler` | `Contact` | Updates an existing contact's fields. Syncs `IsPrimary` on the contact link — when set to true, demotes other links for the same profile. | | `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_CREATE_COMMAND` | `AddressCreateHandler` | `ApplicantAddress` | Creates a new address for the applicant. Sets `profileId` and `isPrimary` as extra properties. When `isPrimary` is true, demotes any existing primary addresses for the same applicant. Idempotent — skips if address already exists. | +| `ADDRESS_EDIT_COMMAND` | `AddressEditHandler` | `ApplicantAddress` | Updates address fields (street, city, province, etc.) and address type. Syncs `isPrimary` extra property — when set to true, demotes sibling addresses; when false, clears the flag. | | `ADDRESS_SET_PRIMARY_COMMAND` | `AddressSetPrimaryHandler` | `ApplicantAddress` | Sets `isPrimary` extra property on the target address; clears it on sibling addresses that had it set. | +| `ADDRESS_DELETE_COMMAND` | `AddressDeleteHandler` | `ApplicantAddress` | Deletes the address entity. Idempotent — succeeds silently if address does not exist. | | `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 @@ -323,6 +325,22 @@ If `subject` is null/empty, or no matching submissions or agents are found, the } ``` +**AddressCreateData** (same fields as edit, plus `applicantId`): +```json +{ + "addressType": "PHYSICAL", + "street": "123 Main St", + "street2": "Suite 100", + "unit": "4B", + "city": "Victoria", + "province": "BC", + "postalCode": "V8V 1A1", + "country": "Canada", + "isPrimary": false, + "applicantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" +} +``` + **OrganizationEditData**: ```json { @@ -418,8 +436,10 @@ context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); +context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); +context.Services.AddTransient(); context.Services.AddTransient(); // Acknowledgment publisher From 08b480f3bfc7bd165e86e1851f2dc6bafe9c2739 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 10 Apr 2026 08:14:37 -0700 Subject: [PATCH 05/13] AB#32491 sonarQube cleanup --- .../ApplicantProfile/AddressInfoDataProvider.cs | 2 +- .../GrantsPortal/Handlers/AddressCreateHandler.cs | 10 +++++----- .../GrantsPortal/Handlers/AddressEditHandler.cs | 10 +++++----- .../GrantsPortal/Handlers/AddressSetPrimaryHandler.cs | 9 +++++---- .../GrantApplications/AddressExtraPropertyNames.cs | 7 +++++++ 5 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressExtraPropertyNames.cs 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 c047e7e4f..8139d4b39 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -95,7 +95,7 @@ from application in apps.DefaultIfEmpty() Province = r.address.Province ?? string.Empty, PostalCode = r.address.Postal ?? string.Empty, Country = r.address.Country ?? string.Empty, - IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"), + IsPrimary = r.address.HasProperty(AddressExtraPropertyNames.IsPrimary) && r.address.GetProperty(AddressExtraPropertyNames.IsPrimary), IsEditable = r.IsFromApplicantPath && applicantPathEditable, ReferenceNo = r.ReferenceNo }).ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs index 3b889f9ce..4bcfd40d0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs @@ -51,8 +51,8 @@ public virtual async Task HandleAsync(PluginDataPayload payload) EntityHelper.TrySetId(address, () => addressId); - address.SetProperty("profileId", profileId.ToString()); - address.SetProperty("isPrimary", innerData.IsPrimary); + address.SetProperty(AddressExtraPropertyNames.ProfileId, profileId.ToString()); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, innerData.IsPrimary); // Demote existing primary addresses for the same applicant if (innerData.IsPrimary) @@ -61,11 +61,11 @@ public virtual async Task HandleAsync(PluginDataPayload payload) foreach (var sibling in siblingAddresses) { - if (!sibling.HasProperty("isPrimary")) continue; - if (!sibling.GetProperty("isPrimary")) continue; + if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; + if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty("isPrimary", false); + trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); await applicantAddressRepository.UpdateAsync(trackedSibling); } } 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 index 441cf8ac5..dd0ac1279 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -47,20 +47,20 @@ public virtual async Task HandleAsync(PluginDataPayload payload) foreach (var sibling in siblingAddresses) { if (sibling.Id == addressId) continue; - if (!sibling.HasProperty("isPrimary")) continue; - if (!sibling.GetProperty("isPrimary")) continue; + if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; + if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty("isPrimary", false); + trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); await applicantAddressRepository.UpdateAsync(trackedSibling); } } - address.SetProperty("isPrimary", true); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); } else { - address.SetProperty("isPrimary", false); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, false); } await applicantAddressRepository.UpdateAsync(address); 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 index 96607436d..c218028b7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantsPortal.Messages; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; @@ -26,8 +27,8 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var address = await applicantAddressRepository.GetAsync(addressId); - address.SetProperty("profileId", profileId.ToString()); - address.SetProperty("isPrimary", true); + address.SetProperty(AddressExtraPropertyNames.ProfileId, profileId.ToString()); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); if (address.ApplicantId.HasValue) { @@ -36,10 +37,10 @@ public virtual async Task HandleAsync(PluginDataPayload payload) foreach (var sibling in siblingAddresses) { if (sibling.Id == addressId) continue; - if (!sibling.HasProperty("isPrimary")) continue; + if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty("isPrimary", false); + trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); await applicantAddressRepository.UpdateAsync(trackedSibling); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressExtraPropertyNames.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressExtraPropertyNames.cs new file mode 100644 index 000000000..7139afe1d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AddressExtraPropertyNames.cs @@ -0,0 +1,7 @@ +namespace Unity.GrantManager.GrantApplications; + +public static class AddressExtraPropertyNames +{ + public const string IsPrimary = "isPrimary"; + public const string ProfileId = "profileId"; +} From 42fb95d045db5f5f1780eae395e1834a837fe7a6 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 10 Apr 2026 10:40:56 -0700 Subject: [PATCH 06/13] AB#32465 fix sonar issues in endpoint path join --- .../AI/Runtime/OpenAIRuntimeService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index f747af2a2..22137b919 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -848,16 +848,18 @@ private string ResolveApiUrl(string? operationName) private static string CombineEndpointAndPath(string endpoint, string profilePath) { + const char UrlPathSeparator = '/'; + if (Uri.TryCreate(profilePath, UriKind.Absolute, out var absoluteUri)) { return absoluteUri.ToString(); } - var trimmedEndpoint = endpoint.Trim().TrimEnd('/'); + var trimmedEndpoint = endpoint.Trim().TrimEnd(UrlPathSeparator); var trimmedPath = profilePath.Trim(); - if (!trimmedPath.StartsWith("/", StringComparison.Ordinal)) + if (!trimmedPath.StartsWith(UrlPathSeparator)) { - trimmedPath = "/" + trimmedPath; + trimmedPath = string.Concat(UrlPathSeparator, trimmedPath); } return trimmedEndpoint + trimmedPath; From a5cbab816d88e7d262877174c980637f4c2a19cc Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Fri, 10 Apr 2026 11:08:36 -0700 Subject: [PATCH 07/13] feature/AB#32612 - hardening lists.cy.ts to account for slow report load timing (wait for dashboard intake control and select Test intake before asserting dashboard counts) --- .../Unity.AutoUI/cypress/e2e/lists.cy.ts | 81 +++++++++---------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts index 1774c123e..1654cf556 100644 --- a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts @@ -57,48 +57,37 @@ describe('Grant Manager Login and List Navigation', () => { const listboxSel = '#bs-select-1[role="listbox"]' const searchSel = 'input[type="search"][aria-controls="bs-select-1"]' - cy.get('body').then(($body) => { - if ($body.find(btnSel).length === 0) { - cy.log('Skipping intake switch: selector not found') - return - } + cy.get(btnSel, { timeout: 30000 }) + .should('be.visible') + .first() + .click({ force: true }) - cy.get(btnSel).first().click({ force: true }) - cy.get(listboxSel, { timeout: 20000 }).should('exist') + cy.get(listboxSel, { timeout: 30000 }).should('be.visible') - cy.get('body').then(($b2) => { - if ($b2.find(searchSel).length > 0) { - cy.get(searchSel).clear().type('Test') - } - }) + cy.get(searchSel, { timeout: 30000 }) + .should('be.visible') + .clear() + .type('Test') - cy.get(listboxSel).within(() => { - cy.get('a.dropdown-item[role="option"]').then(($opts) => { - const match = $opts.filter((_, el) => { - const textNode = el.querySelector('span.text') - const text = textNode ? textNode.textContent || '' : '' - return text.trim() === 'Test' - }) + cy.contains(`${listboxSel} a.dropdown-item[role="option"] span.text`, /^Test$/, { timeout: 30000 }) + .closest('a.dropdown-item') + .then(($opt) => { + const selected = + $opt.attr('aria-selected') === 'true' || + $opt.hasClass('selected') - if (match.length === 0) { - cy.log('Skipping intake switch: Test not found') - return - } - - const el = match.get(0) - const selected = - el.getAttribute('aria-selected') === 'true' || - el.classList.contains('selected') - - if (!selected) { - cy.wrap(el).scrollIntoView().click({ force: true }) - } - }) + if (!selected) { + cy.wrap($opt).scrollIntoView().click({ force: true }) + } }) - cy.get(btnSel).first().click({ force: true }) - cy.get(btnSel).first().should('have.attr', 'aria-expanded', 'false') + cy.get('select#dashboardIntakeId option:selected').should(($opts) => { + const texts = Array.from($opts, (opt) => (opt.textContent || '').trim()) + expect(texts).to.include('Test') }) + + cy.get(btnSel).first().click({ force: true }) + cy.get(btnSel).first().should('have.attr', 'aria-expanded', 'false') } it('Verify Login', () => { @@ -160,28 +149,30 @@ describe('Grant Manager Login and List Navigation', () => { cy.get('#user-dropdown .btn-dropdown span') .should('contain', 'Default Grants Program') - cy.contains("Applications").click() + cy.contains('Applications').click() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains("Roles").click() + cy.contains('Roles').click() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains("Users").click() + cy.contains('Users').click() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains("Intakes").click() + cy.contains('Intakes').click() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains("Forms").click() + cy.contains('Forms').click() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains("Dashboard").click() + cy.contains('Dashboard').click() + cy.location('pathname', { timeout: 30000 }).should('include', '/Dashboard') setDashboardIntakeToTestIfAvailable() - cy.get('#applicationStatusChart text') + cy.get('#applicationStatusChart text', { timeout: 30000 }) .first() - .invoke('text') - .then(n => expect(parseInt(n, 10)).to.be.gt(0)) + .should(($el) => { + expect(parseInt($el.text(), 10)).to.be.gt(0) + }) cy.visit(Cypress.env('webapp.url')) }) @@ -189,4 +180,4 @@ describe('Grant Manager Login and List Navigation', () => { it('Verify Logout', () => { cy.logout() }) -}) +}) \ No newline at end of file From dedecd3401d6aff1f0c4d26e733e551bd5eec8c0 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 10 Apr 2026 12:34:24 -0700 Subject: [PATCH 08/13] AB#32491 abstract appropriate base classes and refactor cognitive complexity --- .../Handlers/AddressEditHandler.cs | 43 +++++++++---------- .../Messages/Commands/AddressCreateData.cs | 29 +------------ .../Messages/Commands/AddressDataBase.cs | 33 ++++++++++++++ .../Messages/Commands/AddressEditData.cs | 30 +------------ .../Messages/Commands/ContactCreateData.cs | 37 +--------------- .../Messages/Commands/ContactDataBase.cs | 40 +++++++++++++++++ .../Messages/Commands/ContactEditData.cs | 37 +--------------- 7 files changed, 97 insertions(+), 152 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressDataBase.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactDataBase.cs 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 index dd0ac1279..01f6010d0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -37,38 +37,35 @@ public virtual async Task HandleAsync(PluginDataPayload payload) address.Country = innerData.Country; address.AddressType = MapAddressType(innerData.AddressType); - // Sync isPrimary extra property and demote/promote siblings - if (innerData.IsPrimary) + if (innerData.IsPrimary && address.ApplicantId.HasValue) { - 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(AddressExtraPropertyNames.IsPrimary)) continue; - if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; - - var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); - trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); - await applicantAddressRepository.UpdateAsync(trackedSibling); - } - } - - address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); - } - else - { - address.SetProperty(AddressExtraPropertyNames.IsPrimary, false); + await DemoteSiblingPrimaryAddressesAsync(address.ApplicantId.Value, addressId); } + address.SetProperty(AddressExtraPropertyNames.IsPrimary, innerData.IsPrimary); + await applicantAddressRepository.UpdateAsync(address); logger.LogInformation("Address {AddressId} updated successfully", addressId); return "Address updated successfully"; } + private async Task DemoteSiblingPrimaryAddressesAsync(Guid applicantId, Guid excludeAddressId) + { + var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(applicantId); + + foreach (var sibling in siblingAddresses) + { + if (sibling.Id == excludeAddressId) continue; + if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; + if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; + + var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); + trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); + await applicantAddressRepository.UpdateAsync(trackedSibling); + } + } + private static AddressType MapAddressType(string? portalAddressType) { return portalAddressType?.ToUpperInvariant() switch diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs index 17b53d0ed..368b6059a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressCreateData.cs @@ -3,35 +3,8 @@ namespace Unity.GrantManager.GrantsPortal.Messages.Commands; -public class AddressCreateData +public class AddressCreateData : AddressDataBase { - [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; } - [JsonProperty("applicantId")] public Guid ApplicantId { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressDataBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressDataBase.cs new file mode 100644 index 000000000..6316d366f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressDataBase.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public abstract class AddressDataBase +{ + [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/AddressEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs index 3d6c655fb..491fc6eab 100644 --- 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 @@ -1,33 +1,5 @@ -using Newtonsoft.Json; - namespace Unity.GrantManager.GrantsPortal.Messages.Commands; -public class AddressEditData +public class AddressEditData : AddressDataBase { - [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 index 532edf422..56ebd4a9a 100644 --- 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 @@ -1,40 +1,5 @@ -using Newtonsoft.Json; -using System; - namespace Unity.GrantManager.GrantsPortal.Messages.Commands; -public class ContactCreateData +public class ContactCreateData : ContactDataBase { - [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; } - - [JsonProperty("applicantId")] - public Guid ApplicantId { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactDataBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactDataBase.cs new file mode 100644 index 000000000..7fde20c34 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactDataBase.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; +using System; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public abstract class ContactDataBase +{ + [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; } + + [JsonProperty("applicantId")] + public Guid ApplicantId { 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 index 1f0acb265..cb653c1cf 100644 --- 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 @@ -1,40 +1,5 @@ -using Newtonsoft.Json; -using System; - namespace Unity.GrantManager.GrantsPortal.Messages.Commands; -public class ContactEditData +public class ContactEditData : ContactDataBase { - [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; } - - [JsonProperty("applicantId")] - public Guid ApplicantId { get; set; } } From 1edc72d1a9435e759326b3730969b50cade5b805 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 10 Apr 2026 13:17:09 -0700 Subject: [PATCH 09/13] AB#32491 copilot suggestions --- .../Handlers/AddressCreateHandler.cs | 7 ++++- .../Handlers/AddressSetPrimaryHandler.cs | 1 + .../GrantsPortal/AddressCreateHandlerTests.cs | 29 +++++++++++++++---- .../GrantsPortal/AddressEditHandlerTests.cs | 10 +++---- .../AddressSetPrimaryHandlerTests.cs | 26 +++++++++++++++++ .../grants-portal-rabbitmq-integration.md | 2 +- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs index 4bcfd40d0..5c97ac448 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressCreateHandler.cs @@ -26,6 +26,11 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var innerData = payload.Data?.ToObject() ?? throw new ArgumentException("Address data is required"); + if (innerData.ApplicantId == Guid.Empty) + { + throw new ArgumentException("applicantId is required"); + } + // Idempotency: if the address already exists, treat as success var existing = await applicantAddressRepository.FindAsync(addressId); if (existing != null) @@ -46,7 +51,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) Province = innerData.Province, Postal = innerData.PostalCode, Country = innerData.Country, - AddressType = MapAddressType(innerData.AddressType) + AddressType = MapAddressType(innerData.AddressType) }; EntityHelper.TrySetId(address, () => addressId); 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 index c218028b7..0496e683f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -38,6 +38,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) { if (sibling.Id == addressId) continue; if (!sibling.HasProperty(AddressExtraPropertyNames.IsPrimary)) continue; + if (!sibling.GetProperty(AddressExtraPropertyNames.IsPrimary)) continue; var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); trackedSibling.SetProperty(AddressExtraPropertyNames.IsPrimary, false); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs index 49dd61c6c..c72260853 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressCreateHandlerTests.cs @@ -189,8 +189,8 @@ public async Task HandleAsync_ShouldSetProfileIdAndIsPrimaryProperties() // Assert savedAddress.ShouldNotBeNull(); - savedAddress.GetProperty("profileId").ShouldBe(profileId.ToString()); - savedAddress.GetProperty("isPrimary").ShouldBeTrue(); + savedAddress.GetProperty(AddressExtraPropertyNames.ProfileId).ShouldBe(profileId.ToString()); + savedAddress.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); } #endregion @@ -299,6 +299,25 @@ public async Task HandleAsync_WhenDataMissing_ShouldThrow() await Should.ThrowAsync(() => _handler.HandleAsync(payload)); } + [Fact] + public async Task HandleAsync_WhenApplicantIdEmpty_ShouldThrow() + { + // Arrange + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + applicantId = Guid.Empty + }); + + var payload = CreatePayload(data: data); + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + #endregion #region Primary demotion @@ -312,7 +331,7 @@ public async Task HandleAsync_WhenIsPrimaryTrue_ShouldDemoteSiblingAddresses() var applicantId = Guid.NewGuid(); var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); - sibling.SetProperty("isPrimary", true); + sibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) .Returns(sibling); @@ -333,9 +352,9 @@ public async Task HandleAsync_WhenIsPrimaryTrue_ShouldDemoteSiblingAddresses() await _handler.HandleAsync(payload); // Assert — sibling should have isPrimary cleared - sibling.GetProperty("isPrimary").ShouldBeFalse(); + sibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); savedAddress.ShouldNotBeNull(); - savedAddress.GetProperty("isPrimary").ShouldBeTrue(); + savedAddress.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); } [Fact] 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 index 8d9e22a46..9214dd0cc 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs @@ -217,7 +217,7 @@ public async Task HandleAsync_WhenIsPrimaryTrue_ShouldPromoteAndDemoteSiblings() var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); - sibling.SetProperty("isPrimary", true); + sibling.SetProperty(AddressExtraPropertyNames.IsPrimary, true); _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) .Returns(address); @@ -232,8 +232,8 @@ public async Task HandleAsync_WhenIsPrimaryTrue_ShouldPromoteAndDemoteSiblings() await _handler.HandleAsync(payload); // Assert - address.GetProperty("isPrimary").ShouldBeTrue(); - sibling.GetProperty("isPrimary").ShouldBeFalse(); + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeTrue(); + sibling.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); } [Fact] @@ -242,7 +242,7 @@ public async Task HandleAsync_WhenIsPrimaryFalse_ShouldClearIsPrimary() // Arrange var addressId = Guid.NewGuid(); var address = WithId(new ApplicantAddress(), addressId); - address.SetProperty("isPrimary", true); + address.SetProperty(AddressExtraPropertyNames.IsPrimary, true); _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) .Returns(address); @@ -263,7 +263,7 @@ public async Task HandleAsync_WhenIsPrimaryFalse_ShouldClearIsPrimary() await _handler.HandleAsync(payload); // Assert - address.GetProperty("isPrimary").ShouldBeFalse(); + address.GetProperty(AddressExtraPropertyNames.IsPrimary).ShouldBeFalse(); } [Fact] 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 index 224f396b8..7ef41287f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs @@ -174,6 +174,32 @@ public async Task HandleAsync_ShouldSkipSiblingsWithoutIsPrimaryProperty() await _addressRepository.DidNotReceive().GetAsync(siblingWithoutProp, Arg.Any(), Arg.Any()); } + [Fact] + public async Task HandleAsync_ShouldSkipSiblingsAlreadyNotPrimary() + { + // 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", false); + + _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 since it's already not primary + await _addressRepository.DidNotReceive().GetAsync(siblingId, Arg.Any(), Arg.Any()); + } + #endregion #region Validation diff --git a/documentation/applicant-portal/grants-portal-rabbitmq-integration.md b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md index f32003a1b..fc3a37ef6 100644 --- a/documentation/applicant-portal/grants-portal-rabbitmq-integration.md +++ b/documentation/applicant-portal/grants-portal-rabbitmq-integration.md @@ -1,4 +1,4 @@ -# Grants Portal — RabbitMQ Messaging Integration +# Grants Portal — RabbitMQ Messaging Integration ## Overview From e958f5e9c271028293d5a99cd420b5a9e7c6c5e5 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 10 Apr 2026 14:33:12 -0700 Subject: [PATCH 10/13] bugfix/AB#32600-FixFilterAsterix --- .../wwwroot/themes/ux2/plugins/filterRow.js | 21 ++++++++++++------- .../Pages/GrantApplications/Index.js | 20 +++++++++--------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js index 449703e46..3217b8034 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js @@ -112,6 +112,11 @@ this._rebuildFilterRow(); }); + // Update button state whenever the global search changes + dt.on('search' + this.s.namespace, () => { + this._updateButtonState(); + }); + // Listen for destroy event to cleanup dt.on('destroy' + this.s.namespace, () => { this._destroy(); @@ -311,14 +316,16 @@ */ _updateButtonState: function () { let dt = this.s.dt; - let hasFilters = false; + let hasFilters = dt.search() !== ''; - dt.columns().every(function () { - if (this.search()) { - hasFilters = true; - return false; - } - }); + if (!hasFilters) { + dt.columns().every(function () { + if (this.search()) { + hasFilters = true; + return false; + } + }); + } if (this.dom.button) { this.dom.button.text( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 9b4004bbe..e039b477e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -193,12 +193,12 @@ $(function () { let isCustomRange = savedQuickRange === 'custom'; toggleCustomDateInputs(isCustomRange); - let range = !isCustomRange - ? getDateRange(savedQuickRange) - : { + let range = isCustomRange + ? { fromDate: savedFromDate || '', toDate: savedToDate || '' - }; + } + : getDateRange(savedQuickRange); if (!isCustomRange && !range) { savedQuickRange = defaultQuickDateRange; @@ -479,12 +479,12 @@ $(function () { let isCustomRange = filters.quickDateRange === 'custom'; toggleCustomDateInputs(isCustomRange); - let range = !isCustomRange - ? getDateRange(quickRange) - : { + let range = isCustomRange + ? { fromDate: filters.submittedFromDate || '', toDate: filters.submittedToDate || '' - }; + } + : getDateRange(quickRange); if (!isCustomRange && !range) { quickRange = defaultQuickDateRange; @@ -705,9 +705,9 @@ $(function () { render: function (data, type, row) { let displayText = ' '; - if (data != null && data.length == 1) { + if (data?.length === 1) { displayText = type === 'fullName' ? getNames(data) : (data[0].fullName + getDutyText(data[0])); - } else if (data.length > 1) { + } else if (data?.length > 1) { displayText = getNames(data); } From cf32934c3a8ec914b9e39ec5ed5e666d9e76e699 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 10 Apr 2026 16:49:53 -0700 Subject: [PATCH 11/13] bugfix/AB#31304-HideTinyButtonPanelonScroll --- .../Pages/GrantApplications/Details.css | 653 +++++++++--------- .../Shared/Components/EmailsWidget/Default.js | 6 + 2 files changed, 333 insertions(+), 326 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 20cfb5b52..bce040a95 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,104 +60,104 @@ border-bottom: 3px solid #003366; } -.spinner-loader { - display: flex; - align-items: center; - justify-content: center; -} - -.ai-button-content { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.ai-dev-output { - min-height: 10rem; - max-height: 24rem; - overflow: auto; - resize: vertical; - white-space: pre; - font-family: Consolas, "Courier New", monospace; - line-height: 1.35; - padding-top: 0.75rem; - padding-right: 2.5rem; -} - -.ai-dev-output-container { - position: relative; -} - -.ai-dev-output-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-dev-output-actions .btn-icon { - width: 2rem; - height: 2rem; - padding: 0; - color: #5c6b7a; -} - -.ai-dev-output-timestamp { - font-weight: 400; - 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; -} +.spinner-loader { + display: flex; + align-items: center; + justify-content: center; +} + +.ai-button-content { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.ai-dev-output { + min-height: 10rem; + max-height: 24rem; + overflow: auto; + resize: vertical; + white-space: pre; + font-family: Consolas, "Courier New", monospace; + line-height: 1.35; + padding-top: 0.75rem; + padding-right: 2.5rem; +} + +.ai-dev-output-container { + position: relative; +} + +.ai-dev-output-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-dev-output-actions .btn-icon { + width: 2rem; + height: 2rem; + padding: 0; + color: #5c6b7a; +} + +.ai-dev-output-timestamp { + font-weight: 400; + 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; +} .left-card { border-right: 1px solid #dddddd; @@ -374,6 +374,7 @@ select.selected { overflow-x: hidden; height: calc(100vh - 276px); margin-right: -6px; + position: relative; } #detailsTabContent { @@ -484,142 +485,142 @@ form label.error { } /* AI Analysis Tab Styles */ -.ai-analysis-container { - padding: 0; -} - -.ai-analysis-status-badge { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - padding: 4px 10px; - font-size: 12px; - font-weight: 700; - width: auto; - min-width: 0; - border: 1px solid transparent; - background-image: none; -} - -.ai-analysis-status-badge.proceed { - background-color: #d1e7dd; - color: #0f5132; -} - -.ai-analysis-status-badge.hold { - background-color: #f8d7da; - color: #842029; -} - -.ai-analysis-recommendation-rationale { - color: #495057; - font-size: 14px; - line-height: 1.6; -} - -.ai-analysis-sections { - margin-top: 16px; -} - -.ai-analysis-section { - border: 1px solid #dce3ec; - border-radius: 8px; - overflow: hidden; - margin-bottom: 16px; -} - -.ai-analysis-section-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 12px; - border-left: 4px solid; - min-height: 52px; -} - -.ai-analysis-section.error .ai-analysis-section-header { - border-left-color: #dc3545; - background-color: #f8d7da; -} - -.ai-analysis-section.warning .ai-analysis-section-header { - border-left-color: #ffc107; - background-color: #fff3cd; -} - -.ai-analysis-section.summary .ai-analysis-section-header { - border-left-color: #0dcaf0; - background-color: #cff4fc; -} - -.ai-analysis-section.next-steps .ai-analysis-section-header { - border-left-color: #198754; - background-color: #d1e7dd; -} - -.ai-analysis-section.recommendation .ai-analysis-section-header { - border-left-color: #6c757d; - background-color: #f8f9fa; -} - -.ai-analysis-section-title-row { - display: flex; - align-items: center; - gap: 8px; -} - -.ai-analysis-section-icon { - margin-right: 0; - font-size: 16px; - flex-shrink: 0; -} - -.ai-analysis-section.error .ai-analysis-section-icon { - color: #721c24; -} - -.ai-analysis-section.warning .ai-analysis-section-icon { - color: #856404; -} - -.ai-analysis-section.summary .ai-analysis-section-icon { - color: #055160; -} - -.ai-analysis-section.next-steps .ai-analysis-section-icon { - color: #0f5132; -} - -.ai-analysis-section.recommendation .ai-analysis-section-icon { - color: #5c6b7a; -} - -.ai-analysis-section-title { - font-weight: 650; - font-size: 16px; - color: #212529; -} - -.ai-analysis-section-body { - padding: 12px 14px; - background-color: #ffffff; - border-top: 1px solid #dee2e6; - color: #495057; - font-size: 14px; - line-height: 1.6; -} - -.ai-analysis-section.compact .ai-analysis-section-body { - padding-top: 10px; - padding-bottom: 10px; -} - -.ai-analysis-section.header-only .ai-analysis-section-body { - display: none; -} - -.ai-analysis-detail-item { +.ai-analysis-container { + padding: 0; +} + +.ai-analysis-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 700; + width: auto; + min-width: 0; + border: 1px solid transparent; + background-image: none; +} + +.ai-analysis-status-badge.proceed { + background-color: #d1e7dd; + color: #0f5132; +} + +.ai-analysis-status-badge.hold { + background-color: #f8d7da; + color: #842029; +} + +.ai-analysis-recommendation-rationale { + color: #495057; + font-size: 14px; + line-height: 1.6; +} + +.ai-analysis-sections { + margin-top: 16px; +} + +.ai-analysis-section { + border: 1px solid #dce3ec; + border-radius: 8px; + overflow: hidden; + margin-bottom: 16px; +} + +.ai-analysis-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 12px; + border-left: 4px solid; + min-height: 52px; +} + +.ai-analysis-section.error .ai-analysis-section-header { + border-left-color: #dc3545; + background-color: #f8d7da; +} + +.ai-analysis-section.warning .ai-analysis-section-header { + border-left-color: #ffc107; + background-color: #fff3cd; +} + +.ai-analysis-section.summary .ai-analysis-section-header { + border-left-color: #0dcaf0; + background-color: #cff4fc; +} + +.ai-analysis-section.next-steps .ai-analysis-section-header { + border-left-color: #198754; + background-color: #d1e7dd; +} + +.ai-analysis-section.recommendation .ai-analysis-section-header { + border-left-color: #6c757d; + background-color: #f8f9fa; +} + +.ai-analysis-section-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-analysis-section-icon { + margin-right: 0; + font-size: 16px; + flex-shrink: 0; +} + +.ai-analysis-section.error .ai-analysis-section-icon { + color: #721c24; +} + +.ai-analysis-section.warning .ai-analysis-section-icon { + color: #856404; +} + +.ai-analysis-section.summary .ai-analysis-section-icon { + color: #055160; +} + +.ai-analysis-section.next-steps .ai-analysis-section-icon { + color: #0f5132; +} + +.ai-analysis-section.recommendation .ai-analysis-section-icon { + color: #5c6b7a; +} + +.ai-analysis-section-title { + font-weight: 650; + font-size: 16px; + color: #212529; +} + +.ai-analysis-section-body { + padding: 12px 14px; + background-color: #ffffff; + border-top: 1px solid #dee2e6; + color: #495057; + font-size: 14px; + line-height: 1.6; +} + +.ai-analysis-section.compact .ai-analysis-section-body { + padding-top: 10px; + padding-bottom: 10px; +} + +.ai-analysis-section.header-only .ai-analysis-section-body { + display: none; +} + +.ai-analysis-detail-item { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #e9ecef; @@ -656,95 +657,95 @@ form label.error { margin-bottom: 6px; } -.ai-analysis-detail-item.hidden-item { - opacity: 0.6; -} - -.ai-analysis-section-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 132px; - padding: 4px 10px; - font-size: 13px; - font-weight: 600; - color: #4b5563; - background-color: #ffffff; - border: 1px solid #d1d5db; - border-radius: 6px; - text-decoration: none; -} - -.ai-analysis-section-toggle:disabled { - cursor: default; - pointer-events: none; - opacity: 1; -} - -.ai-analysis-section-toggle:hover { - color: #1f2937; - background-color: #f9fafb; - border-color: #9ca3af; -} - -.ai-analysis-status-chip { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px 10px; - font-size: 12px; - font-weight: 700; - color: #5c6b7a; - background-color: #eef2f6; - border: 1px solid #dce3ec; - border-radius: 999px; -} - -#ai-analysis-tab { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.ai-analysis-tab-icon-wrap { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.ai-analysis-tab-indicator { - width: 8px; - height: 8px; - border-radius: 999px; - background-color: #adb5bd; - flex-shrink: 0; -} - -.ai-analysis-tab-indicator.proceed { - background-color: #198754; -} - -.ai-analysis-tab-indicator.hold { - background-color: #dc3545; -} - -.ai-analysis-action-btn { - background: #ffffff; - border: 1px solid #d1d5db; - padding: 4px 10px; - cursor: pointer; - color: #4b5563; - font-size: 13px; - font-weight: 600; - transition: color 0.2s, background-color 0.2s, border-color 0.2s; - border-radius: 6px; - flex-shrink: 0; - line-height: 1.2; -} - -.ai-analysis-action-btn:hover { - color: #1f2937; - background-color: #f9fafb; - border-color: #9ca3af; -} +.ai-analysis-detail-item.hidden-item { + opacity: 0.6; +} + +.ai-analysis-section-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 132px; + padding: 4px 10px; + font-size: 13px; + font-weight: 600; + color: #4b5563; + background-color: #ffffff; + border: 1px solid #d1d5db; + border-radius: 6px; + text-decoration: none; +} + +.ai-analysis-section-toggle:disabled { + cursor: default; + pointer-events: none; + opacity: 1; +} + +.ai-analysis-section-toggle:hover { + color: #1f2937; + background-color: #f9fafb; + border-color: #9ca3af; +} + +.ai-analysis-status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 700; + color: #5c6b7a; + background-color: #eef2f6; + border: 1px solid #dce3ec; + border-radius: 999px; +} + +#ai-analysis-tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.ai-analysis-tab-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-analysis-tab-indicator { + width: 8px; + height: 8px; + border-radius: 999px; + background-color: #adb5bd; + flex-shrink: 0; +} + +.ai-analysis-tab-indicator.proceed { + background-color: #198754; +} + +.ai-analysis-tab-indicator.hold { + background-color: #dc3545; +} + +.ai-analysis-action-btn { + background: #ffffff; + border: 1px solid #d1d5db; + padding: 4px 10px; + cursor: pointer; + color: #4b5563; + font-size: 13px; + font-weight: 600; + transition: color 0.2s, background-color 0.2s, border-color 0.2s; + border-radius: 6px; + flex-shrink: 0; + line-height: 1.2; +} + +.ai-analysis-action-btn:hover { + color: #1f2937; + background-color: #f9fafb; + border-color: #9ca3af; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 3345885f0..fadcee301 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -64,6 +64,10 @@ UIElements.inputEmailFrom.on('input', handleDraftChange); UIElements.inputEmailSubject.on('input', handleDraftChange); UIElements.inputEmailBody.on('input', handleDraftChange); + + $('.details-scrollable').on('scroll.emailWidget', function () { + $('.tox-toolbar__overflow').hide(); + }); } init(); @@ -224,6 +228,7 @@ promotion: false, content_css: false, skin: false, + ui_container: '.details-scrollable', setup: function (editor) { editor.on("input", (e) => { UIElements.inputEmailBody.val(editor.getContent()); @@ -676,6 +681,7 @@ promotion: false, content_css: false, skin: false, + ui_container: '.details-scrollable', setup: function (editor) { editor.on("input", (e) => { UIElements.inputEmailBody.val(editor.getContent()) From b33e165969e19ff3745eb7a55cf17b01c49b57f4 Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl" Date: Mon, 13 Apr 2026 08:42:50 -0700 Subject: [PATCH 12/13] AB#32613 enable workflow_dispatch on main branch --- .github/workflows/sonarsource-scan.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sonarsource-scan.yml b/.github/workflows/sonarsource-scan.yml index defcb6682..588a0483a 100644 --- a/.github/workflows/sonarsource-scan.yml +++ b/.github/workflows/sonarsource-scan.yml @@ -4,12 +4,12 @@ on: push: branches: - dev2 -# - dev -# - test -# - main + - dev + - test + - main # pull_request: # types: [opened, synchronize, reopened] -# workflow_dispatch: + workflow_dispatch: permissions: contents: read From 60ae0c9aa1a3406dd900f11f92d60bb2e186f568 Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl" Date: Mon, 13 Apr 2026 09:37:31 -0700 Subject: [PATCH 13/13] AB32613 Move read permissions from workflow level to individual job level addresses the SonarCloud security issue --- .github/workflows/docker-build-dev.yml | 8 ++++++-- .github/workflows/docker-build-main.yml | 8 ++++++-- .github/workflows/docker-build-test.yml | 11 +++++++++-- .github/workflows/manual-trigger.yml | 9 +++++++-- .github/workflows/pr-check-dev-branch.yml | 13 +++++++++---- .github/workflows/pr-check-main-branch.yml | 14 ++++++++++---- .github/workflows/pr-check-test-branch.yml | 14 ++++++++++---- 7 files changed, 57 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 22096b09d..be32d7f22 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -1,6 +1,4 @@ name: Dev - Build & Push docker images -permissions: - contents: read on: push: @@ -42,6 +40,8 @@ jobs: Setup: runs-on: ubuntu-latest environment: dev + permissions: + contents: read steps: - name: Get variables run: | @@ -61,6 +61,8 @@ jobs: needs: [Setup] runs-on: ubuntu-latest environment: dev + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -111,6 +113,8 @@ jobs: needs: [Setup,Branch,PushVariables] runs-on: ubuntu-latest environment: dev + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Build Docker images diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index 892ddbf95..b146da454 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -1,6 +1,4 @@ name: Main - Build & Push docker images -permissions: - contents: read on: push: @@ -42,6 +40,8 @@ jobs: Setup: runs-on: ubuntu-latest environment: main + permissions: + contents: read steps: - name: Get variables run: | @@ -61,6 +61,8 @@ jobs: needs: [Setup] runs-on: ubuntu-latest environment: main + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -168,6 +170,8 @@ jobs: needs: [Setup,Branch,GenerateTag,PushVariables] runs-on: ubuntu-latest environment: main + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Build Docker images diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index f6a35b804..9728ee15d 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -1,6 +1,4 @@ name: Test - Build & Push docker images -permissions: - contents: read on: push: @@ -42,6 +40,8 @@ jobs: Setup: runs-on: ubuntu-latest environment: test + permissions: + contents: read steps: - name: Get variables run: | @@ -61,6 +61,8 @@ jobs: needs: [Setup] runs-on: ubuntu-latest environment: test + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -89,6 +91,8 @@ jobs: needs: [Setup,Branch] runs-on: ubuntu-latest environment: test + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -114,6 +118,7 @@ jobs: needs: [Setup,Branch,GenerateTag] permissions: actions: write + contents: read runs-on: ubuntu-latest environment: test steps: @@ -144,6 +149,8 @@ jobs: needs: [Setup,Branch,GenerateTag,PushVariables] runs-on: ubuntu-latest environment: test + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Build Docker images diff --git a/.github/workflows/manual-trigger.yml b/.github/workflows/manual-trigger.yml index 8737a62c8..c34b9e697 100644 --- a/.github/workflows/manual-trigger.yml +++ b/.github/workflows/manual-trigger.yml @@ -1,8 +1,6 @@ # This is a basic workflow that is manually triggered name: Workflow - Run manual trigger -permissions: - contents: read # Controls when the action will run. Workflow runs when manually triggered on: @@ -39,6 +37,8 @@ jobs: Setup: runs-on: ubuntu-latest environment: ${{ inputs.name }} + permissions: + contents: read steps: - name: Get variables run: | @@ -57,6 +57,8 @@ jobs: needs: [Setup] runs-on: ubuntu-latest environment: ${{ inputs.name }} + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -86,6 +88,7 @@ jobs: environment: ${{ inputs.name }} permissions: actions: write + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -106,6 +109,8 @@ jobs: needs: [Setup,Branch,PushVariables] runs-on: ubuntu-latest environment: ${{ inputs.name }} + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Build Docker images diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index 6e5b709ff..04ded4919 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -1,9 +1,5 @@ name: Dev - Branch Protection - CI & Unit Tests -permissions: - contents: read - pull-requests: write - on: pull_request: branches: @@ -15,6 +11,8 @@ jobs: # --------------------------------------------------------------------- check-dev-branch: runs-on: ubuntu-latest + permissions: + contents: read outputs: branch-allowed: ${{ steps.branch-check.outputs.allowed }} steps: @@ -41,6 +39,8 @@ jobs: needs: check-dev-branch if: needs.check-dev-branch.outputs.branch-allowed == 'true' runs-on: ubuntu-latest + permissions: + contents: read outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: @@ -60,6 +60,8 @@ jobs: test-project: needs: discover-test-projects runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false @@ -96,6 +98,9 @@ jobs: aggregate-results: needs: test-project runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index d81966efe..4b7d14bce 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -1,8 +1,4 @@ name: Main - Branch Protection - CI & Unit Tests -permissions: - contents: read - pull-requests: write - issues: write on: pull_request: @@ -15,6 +11,8 @@ jobs: # --------------------------------------------------------------------- check-main-branch: runs-on: ubuntu-latest + permissions: + contents: read outputs: branch-allowed: ${{ steps.branch-check.outputs.allowed }} steps: @@ -37,6 +35,8 @@ jobs: needs: check-main-branch if: needs.check-main-branch.outputs.branch-allowed == 'true' runs-on: ubuntu-latest + permissions: + contents: read outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: @@ -56,6 +56,8 @@ jobs: test-project: needs: discover-test-projects runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false @@ -92,6 +94,10 @@ jobs: aggregate-results: needs: test-project runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write steps: - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/pr-check-test-branch.yml b/.github/workflows/pr-check-test-branch.yml index 9fea720bb..d823787d9 100644 --- a/.github/workflows/pr-check-test-branch.yml +++ b/.github/workflows/pr-check-test-branch.yml @@ -1,8 +1,4 @@ name: Test - Branch Protection - CI & Unit Tests -permissions: - contents: read - pull-requests: write - issues: write on: pull_request: @@ -15,6 +11,8 @@ jobs: # --------------------------------------------------------------------- check-test-branch: runs-on: ubuntu-latest + permissions: + contents: read outputs: branch-allowed: ${{ steps.branch-check.outputs.allowed }} steps: @@ -39,6 +37,8 @@ jobs: needs: check-test-branch if: needs.check-test-branch.outputs.branch-allowed == 'true' runs-on: ubuntu-latest + permissions: + contents: read outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: @@ -58,6 +58,8 @@ jobs: test-project: needs: discover-test-projects runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false @@ -94,6 +96,10 @@ jobs: aggregate-results: needs: test-project runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write steps: - uses: actions/download-artifact@v4 with: