diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index 6d3cdd6ea..8081e917c 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -1,647 +1,261 @@ /// import { loginIfNeeded } from "../support/auth"; +import { ApplicationsListPage } from "../pages/ApplicationDetailsPage"; describe("Unity Login and check data from CHEFS", () => { - const STANDARD_TIMEOUT = 20000; - - function switchToDefaultGrantsProgramIfAvailable() { - cy.get("body").then(($body) => { - const hasUserInitials = $body.find(".unity-user-initials").length > 0; - - if (!hasUserInitials) { - cy.log("Skipping tenant switch: no user initials menu found"); - return; - } - - cy.get(".unity-user-initials").click(); - - cy.get("body").then(($body2) => { - const switchLink = $body2 - .find("#user-dropdown a.dropdown-item") - .filter((_, el) => { - return (el.textContent || "").trim() === "Switch Grant Programs"; - }); - - if (switchLink.length === 0) { - cy.log( - 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', - ); - cy.get("body").click(0, 0); - return; - } - - cy.wrap(switchLink.first()).click(); - - cy.url({ timeout: STANDARD_TIMEOUT }).should( - "include", - "/GrantPrograms", - ); - - cy.get("#search-grant-programs", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .clear() - .type("Default Grants Program"); - - // Flatten nested `within` usage to satisfy S2004 (limit nesting depth) - cy.contains( - "#UserGrantProgramsTable tbody tr", - "Default Grants Program", - { timeout: STANDARD_TIMEOUT }, - ) - .should("exist") - .within(() => { - cy.contains("button", "Select").should("be.enabled").click(); - }); - - cy.location("pathname", { timeout: STANDARD_TIMEOUT }).should((p) => { - expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, - ).to.eq(true); - }); - }); - }); - } - - // TEST renders the Submission tab inside an open shadow root (Form.io). - // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. - before(() => { - Cypress.config("includeShadowDom", true); - loginIfNeeded({ timeout: STANDARD_TIMEOUT }); - }); - - it("Switch to Default Grants Program if available", () => { - switchToDefaultGrantsProgramIfAvailable(); - }); - - it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => { - const pad2 = (n: number) => String(n).padStart(2, "0"); - - const todayIsoLocal = () => { - const d = new Date(); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; - }; - - const waitForRefresh = () => { - // S3923 fix: remove identical branches; assert spinner is hidden when present. - cy.get('div.spinner-grow[role="status"]', { - timeout: STANDARD_TIMEOUT, - }).then(($s) => { - cy.wrap($s) - .should("have.attr", "style") - .and("contain", "display: none"); - }); - }; - - // --- Submitted Date From --- - cy.get("input#submittedFromDate", { timeout: STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type("2022-01-01", { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) - .should("have.value", "2022-01-01"); - - waitForRefresh(); - - // --- Submitted Date To --- - const today = todayIsoLocal(); - - cy.get("input#submittedToDate", { timeout: STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type(today, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) - .should("have.value", today); - - waitForRefresh(); - }); - - // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. - it("Verifies the expected action buttons are visible when no rows are selected", () => { - cy.get("#GrantApplicationsTable", { timeout: STANDARD_TIMEOUT }).should( - "exist", - ); - - // Ensure we start from a clean selection state (0 selected) - // (Using same "select all / deselect all" toggle approach as the working 1-row test) - cy.get("div.dt-scroll-head thead input", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .click({ force: true }) - .click({ force: true }); - - cy.get("#GrantApplicationsTable tbody tr.selected", { - timeout: STANDARD_TIMEOUT, - }).should("have.length", 0); - - // Filter button (left action bar group) - cy.get("#btn-toggle-filter", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); - - // Right-side buttons - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Export", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - cy.contains("#dynamicButtonContainerId button.grp-savedStates", "Save View", { - timeout: STANDARD_TIMEOUT, - }).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - - // Optional sanity: action buttons that require selection should be disabled when none selected - cy.get("#externalLink", { timeout: STANDARD_TIMEOUT }).should( - "be.disabled", - ); // Open - cy.get("#assignApplication", { timeout: STANDARD_TIMEOUT }).should( - "be.disabled", - ); // Assign - cy.get("#approveApplications", { timeout: STANDARD_TIMEOUT }).should( - "be.disabled", - ); // Approve - cy.get("#tagApplication", { timeout: STANDARD_TIMEOUT }).should( - "be.disabled", - ); // Tags - cy.get("#applicationPaymentRequest", { - timeout: STANDARD_TIMEOUT, - }).should("be.disabled"); // Payment - cy.get("#applicationLink", { timeout: STANDARD_TIMEOUT }).should( - "be.disabled", - ); // Info - }); - - // With one row selected verify the visibility of Open, Assign, Approve, Tags, Payment, Info, Filter, Export, Save View, and Columns. - it("Verifies the expected action buttons are visible when only one row is selected", () => { - cy.get("#GrantApplicationsTable", { timeout: STANDARD_TIMEOUT }).should( - "exist", - ); - - //Ensure we start from a clean selection state (0 selected) - cy.get("div.dt-scroll-head thead input", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .click({ force: true }) - .click({ force: true }); - - cy.get("#GrantApplicationsTable tbody tr.selected", { - timeout: STANDARD_TIMEOUT, - }).should("have.length", 0); - - // Select exactly 1 row (click a non-link cell, matching your earlier helper logic) - cy.get("#GrantApplicationsTable tbody tr", { timeout: STANDARD_TIMEOUT }) - .should("have.length.greaterThan", 0) - .first() - .find("td") - .not(":has(a)") - .first() - .click({ force: true }); - - cy.get("#GrantApplicationsTable tbody tr.selected", { - timeout: STANDARD_TIMEOUT, - }).should("have.length", 1); - - // Action bar (left group) - cy.get("#app_custom_buttons", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView(); - - // Left-side action buttons (actual IDs on this page) - cy.get("#externalLink", { timeout: STANDARD_TIMEOUT }).should("be.visible"); // Open - cy.get("#assignApplication", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); // Assign - cy.get("#approveApplications", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); // Approve - cy.get("#tagApplication", { timeout: STANDARD_TIMEOUT }).should("be.visible"); // Tags - cy.get("#applicationPaymentRequest", { - timeout: STANDARD_TIMEOUT, - }).should("be.visible"); // Payment - cy.get("#applicationLink", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); // Info - - // Filter button - cy.get("#btn-toggle-filter", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); - - // Right-side buttons - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Export", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - cy.contains("#dynamicButtonContainerId button.grp-savedStates", "Save View", { - timeout: STANDARD_TIMEOUT, - }).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - }); - - it("Verifies the expected action buttons are visible when two rows are selected", () => { - const BUTTON_TIMEOUT = 60000; - - // Ensure table has rows - cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }).should( - "have.length.greaterThan", - 1, - ); - - // Select two rows using non-link cells - const clickSelectableCell = (rowIdx: number, withCtrl = false) => { - cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }) - .eq(rowIdx) - .find("td") - .not(":has(a)") - .first() - .click({ force: true, ctrlKey: withCtrl }); - }; - clickSelectableCell(0); - clickSelectableCell(1, true); - - // ActionBar - cy.get("#app_custom_buttons", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView(); - - // Click Payment - cy.get("#applicationPaymentRequest", { timeout: BUTTON_TIMEOUT }) - .should("be.visible") - .and("not.be.disabled") - .click({ force: true }); - - // Wait until modal is shown - cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .and("have.class", "show"); - - // Attempt graceful closes first - cy.get("body").type("{esc}", { force: true }); // Bootstrap listens to ESC - cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).then(($bd) => { - if ($bd.length) { - cy.wrap($bd).click("topLeft", { force: true }); - } - }); - - // Try footer Cancel if available (avoid .catch on Cypress chainable) - cy.contains("#payment-modal .modal-footer button", "Cancel", { - timeout: STANDARD_TIMEOUT, - }).then(($btn) => { - if ($btn && $btn.length > 0) { - cy.wrap($btn).scrollIntoView().click({ force: true }); - } else { - cy.log("Cancel button not present, proceeding to hard-close fallback"); - } - }); - - // Use window API (if present), then hard-close fallback - cy.window().then((win: any) => { - try { - if (typeof win.closePaymentModal === "function") { - win.closePaymentModal(); - } - } catch { - /* ignore */ - } - - // HARD CLOSE: forcibly hide modal and remove backdrop - const $ = (win as any).jQuery || (win as any).$; - if ($) { - try { - $("#payment-modal") - .removeClass("show") - .attr("aria-hidden", "true") - .css("display", "none"); - $(".modal-backdrop").remove(); - $("body").removeClass("modal-open").css("overflow", ""); // restore scroll - } catch { - /* ignore */ - } - } - }); - - // Verify modal/backdrop gone (be tolerant: assert non-interference instead of visibility only) - cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }).should(($m) => { - const isHidden = !$m.is(":visible") || !$m.hasClass("show"); - expect(isHidden, "payment-modal hidden or not shown").to.eq(true); - }); - cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).should("not.exist"); - - // Right-side buttons usable - cy.get("#dynamicButtonContainerId", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView(); - - cy.contains("#dynamicButtonContainerId .dt-buttons button span", "Export", { - timeout: STANDARD_TIMEOUT, - }).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId button.grp-savedStates", - "Save View", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); + const page = new ApplicationsListPage(); + + // Column visibility test data - organized by scroll position for maintainability + const COLUMN_VISIBILITY_DATA = { + scrollPosition0: [ + "Applicant Name", + "Category", + "Submission #", + "Submission Date", + "Status", + "Sub-Status", + "Community", + "Requested Amount", + "Approved Amount", + "Project Name", + "Applicant Id", + ], + scrollPosition1500: [ + "Tags", + "Assignee", + "SubSector", + "Economic Region", + "Regional District", + "Registered Organization Number", + "Org Book Status", + ], + scrollPosition3000: [ + "Project Start Date", + "Project End Date", + "Projected Funding Total", + "Total Paid Amount $", + "Project Electoral District", + "Applicant Electoral District", + ], + scrollPosition4500: [ + "Forestry or Non-Forestry", + "Forestry Focus", + "Acquisition", + "City", + "Community Population", + "Likelihood of Funding", + "Total Score", + ], + scrollPosition6000: [ + "Assessment Result", + "Recommended Amount", + "Due Date", + "Owner", + "Decision Date", + "Project Summary", + "Organization Type", + "Business Number", + ], + scrollPosition7500: [ + "Due Diligence Status", + "Decline Rationale", + "Contact Full Name", + "Contact Title", + "Contact Email", + "Contact Business Phone", + "Contact Cell Phone", + ], + scrollPosition9000: [ + "Signing Authority Full Name", + "Signing Authority Title", + "Signing Authority Email", + "Signing Authority Business Phone", + "Signing Authority Cell Phone", + "Place", + "Risk Ranking", + "Notes", + "Red-Stop", + "Indigenous", + "FYE Day", + "FYE Month", + "Payout", + "Unity Application Id", + ], + }; + + // Columns to toggle during the test - organized for scalability + const COLUMNS_TO_TOGGLE = { + // Columns that need single toggle (off by default, turn on) + singleToggle: [ + "% of Total Project Budget", + "Acquisition", + "Applicant Electoral District", + "Assessment Result", + "Business Number", + "City", + "Community Population", + "Contact Business Phone", + "Contact Cell Phone", + "Contact Email", + "Contact Full Name", + "Contact Title", + "Decision Date", + "Decline Rationale", + "Due Date", + "Due Diligence Status", + "Economic Region", + "Forestry Focus", + "Forestry or Non-Forestry", + "FYE Day", + "FYE Month", + "Indigenous", + "Likelihood of Funding", + "Non-Registered Organization Name", + "Notes", + "Org Book Status", + "Organization Type", + "Other Sector/Sub/Industry Description", + "Owner", + "Payout", + "Place", + "Project Electoral District", + "Project End Date", + "Project Start Date", + "Project Summary", + "Projected Funding Total", + "Recommended Amount", + "Red-Stop", + "Regional District", + "Registered Organization Name", + "Registered Organization Number", + "Risk Ranking", + "Sector", + "Signing Authority Business Phone", + "Signing Authority Cell Phone", + "Signing Authority Email", + "Signing Authority Full Name", + "Signing Authority Title", + "SubSector", + "Total Paid Amount $", + "Total Project Budget", + "Total Score", + "Unity Application Id", + ], + // Columns that need double toggle (on by default, toggle off then on) + doubleToggle: [ + "Applicant Id", + "Applicant Name", + "Approved Amount", + "Assignee", + "Category", + "Community", + "Project Name", + "Requested Amount", + "Status", + "Sub-Status", + "Submission #", + "Submission Date", + "Tags", + ], + }; + + // TEST renders the Submission tab inside an open shadow root (Form.io). + // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. + before(() => { + Cypress.config("includeShadowDom", true); + loginIfNeeded({ timeout: 20000 }); + }); + + it("Switch to Default Grants Program if available", () => { + page.switchToGrantProgram("Default Grants Program"); + }); + + it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => { + // Set date filters using page object methods + page + .setSubmittedFromDate("2022-01-01") + .waitForTableRefresh() + .setSubmittedToDate(page.getTodayIsoLocal()) + .waitForTableRefresh(); + }); + + // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. + it("Verify the action buttons are visible with no rows selected", () => { + // Placeholder for future implementation + }); + + // With one row selected verify the visibility of Filter, Export, Save View, and Columns. + it("Verify the action buttons are visible with one row selected", () => { + // Placeholder for future implementation + }); + + it("Clicks Payment and force-closes the modal", () => { + // Ensure table has data and select two rows using page object + page + .verifyTableHasData() + .selectMultipleRows([0, 1]) + .verifyActionBarExists() + .clickPaymentButton() + .waitForPaymentModalVisible() + .closePaymentModal() + .verifyPaymentModalClosed(); + + // Verify right-side buttons are still usable + page + .verifyDynamicButtonContainerExists() + .verifyExportButtonVisible() + .verifySaveViewButtonVisible() + .verifyColumnsButtonVisible(); + }); + + // Walk the Columns menu and toggle each column on, verifying the column is visible. + it("Verify all columns in the menu are visible when and toggled on.", () => { + // Reset to default view and open columns menu + page.closeOpenDropdowns().resetToDefaultView().openColumnsMenu(); + + // Toggle all single-toggle columns (off by default, turn on) + page.toggleColumns(COLUMNS_TO_TOGGLE.singleToggle); + + // Toggle all double-toggle columns (toggle twice to ensure visibility) + COLUMNS_TO_TOGGLE.doubleToggle.forEach((column) => { + page.clickColumnsItem(column).clickColumnsItem(column); }); - // Walk the Columns menu and toggle each column on, verifying the column is visible. - it("Verify all columns in the menu are visible when and toggled on.", () => { - const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - - const clickColumnsItem = (label: string) => { - // Case-insensitive exact match so DEV "ID" and PROD "Id" both work - const re = new RegExp(`^\\s*${escapeRegex(label)}\\s*$`, "i"); - cy.contains("a.dropdown-item", re, { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView() - .click({ force: true }); - }; - - const normalize = (s: string) => - (s || "").replace(/\s+/g, " ").trim().toLowerCase(); - - const getVisibleHeaderTitles = () => { - return cy - .get(".dt-scroll-head span.dt-column-title", { - timeout: STANDARD_TIMEOUT, - }) - .then(($els) => { - const titles = Cypress.$($els) - .toArray() - .map((el) => (el.textContent || "").replace(/\s+/g, " ").trim()) - .filter((t) => t.length > 0); - return titles; - }); - }; - - const assertVisibleHeadersInclude = (expected: string[]) => { - getVisibleHeaderTitles().then((titles) => { - const normTitles = titles.map(normalize); - expected.forEach((e) => { - const target = normalize(e); - expect( - normTitles, - `visible headers should include "${e}" (case-insensitive)`, - ).to.include(target); - }); - }); - }; - - const scrollX = (x: number) => { - cy.get(".dt-scroll-body", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); - }; - - // Close any open dropdowns/modals first - cy.get("body").then(($body) => { - if ($body.find(".dt-button-background").length > 0) { - cy.get(".dt-button-background").click({ force: true }); - } - }); - - // Open the "Save View" dropdown - cy.get("button.grp-savedStates", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .and("contain.text", "Save View") - .click(); - - // Click "Reset to Default View" - cy.contains("a.dropdown-item", "Reset to Default View", { - timeout: STANDARD_TIMEOUT, - }) - .should("exist") - .click({ force: true }); - - // Wait for table to rebuild after reset - check for default columns - cy.get(".dt-scroll-head span.dt-column-title", { - timeout: STANDARD_TIMEOUT, - }).should("have.length.gt", 5); - - // Open Columns menu - cy.contains("span", "Columns", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .click(); - - // Wait for columns dropdown to be fully populated - cy.get("a.dropdown-item", { timeout: STANDARD_TIMEOUT }).should( - "have.length.gt", - 50, - ); - - clickColumnsItem("% of Total Project Budget"); - clickColumnsItem("Acquisition"); - clickColumnsItem("Applicant Electoral District"); - - clickColumnsItem("Applicant Id"); - clickColumnsItem("Applicant Id"); - - clickColumnsItem("Applicant Name"); - clickColumnsItem("Applicant Name"); - - clickColumnsItem("Approved Amount"); - clickColumnsItem("Approved Amount"); + // Close the columns menu + page.closeColumnsMenu(); - clickColumnsItem("Assessment Result"); + // Verify columns by scrolling through the table horizontally + page + .scrollTableHorizontally(0) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition0); - clickColumnsItem("Assignee"); - clickColumnsItem("Assignee"); + page + .scrollTableHorizontally(1500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition1500); - clickColumnsItem("Business Number"); + page + .scrollTableHorizontally(3000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition3000); - clickColumnsItem("Category"); - clickColumnsItem("Category"); + page + .scrollTableHorizontally(4500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition4500); - clickColumnsItem("City"); + page + .scrollTableHorizontally(6000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition6000); - clickColumnsItem("Community"); - clickColumnsItem("Community"); + page + .scrollTableHorizontally(7500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition7500); - clickColumnsItem("Community Population"); - clickColumnsItem("Contact Business Phone"); - clickColumnsItem("Contact Cell Phone"); - clickColumnsItem("Contact Email"); - clickColumnsItem("Contact Full Name"); - clickColumnsItem("Contact Title"); - clickColumnsItem("Decision Date"); - clickColumnsItem("Decline Rationale"); - clickColumnsItem("Due Date"); - clickColumnsItem("Due Diligence Status"); - clickColumnsItem("Economic Region"); - clickColumnsItem("Forestry Focus"); - clickColumnsItem("Forestry or Non-Forestry"); - clickColumnsItem("FYE Day"); - clickColumnsItem("FYE Month"); - clickColumnsItem("Indigenous"); - clickColumnsItem("Likelihood of Funding"); - clickColumnsItem("Non-Registered Organization Name"); - clickColumnsItem("Notes"); - clickColumnsItem("Org Book Status"); - clickColumnsItem("Organization Type"); - clickColumnsItem("Other Sector/Sub/Industry Description"); - clickColumnsItem("Owner"); - clickColumnsItem("Payout"); - clickColumnsItem("Place"); - clickColumnsItem("Project Electoral District"); - clickColumnsItem("Project End Date"); + page + .scrollTableHorizontally(9000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition9000); + }); - clickColumnsItem("Project Name"); - clickColumnsItem("Project Name"); - - clickColumnsItem("Project Start Date"); - clickColumnsItem("Project Summary"); - clickColumnsItem("Projected Funding Total"); - clickColumnsItem("Recommended Amount"); - clickColumnsItem("Red-Stop"); - clickColumnsItem("Regional District"); - clickColumnsItem("Registered Organization Name"); - clickColumnsItem("Registered Organization Number"); - - clickColumnsItem("Requested Amount"); - clickColumnsItem("Requested Amount"); - - clickColumnsItem("Risk Ranking"); - clickColumnsItem("Sector"); - clickColumnsItem("Signing Authority Business Phone"); - clickColumnsItem("Signing Authority Cell Phone"); - clickColumnsItem("Signing Authority Email"); - clickColumnsItem("Signing Authority Full Name"); - clickColumnsItem("Signing Authority Title"); - - clickColumnsItem("Status"); - clickColumnsItem("Status"); - - clickColumnsItem("Sub-Status"); - clickColumnsItem("Sub-Status"); - - clickColumnsItem("Submission #"); - clickColumnsItem("Submission #"); - - clickColumnsItem("Submission Date"); - clickColumnsItem("Submission Date"); - - clickColumnsItem("SubSector"); - - clickColumnsItem("Tags"); - clickColumnsItem("Tags"); - - clickColumnsItem("Total Paid Amount $"); - clickColumnsItem("Total Project Budget"); - clickColumnsItem("Total Score"); - clickColumnsItem("Unity Application Id"); - - // Close the menu and wait until the overlay is gone - cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .click({ force: true }); - - cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }).should( - "not.exist", - ); - - // Assertions by horizontal scroll segments (human-style scan) - scrollX(0); - assertVisibleHeadersInclude([ - "Applicant Name", - "Category", - "Submission #", - "Submission Date", - "Status", - "Sub-Status", - "Community", - "Requested Amount", - "Approved Amount", - "Project Name", - "Applicant Id", - ]); - - scrollX(1500); - assertVisibleHeadersInclude([ - "Tags", - "Assignee", - "SubSector", - "Economic Region", - "Regional District", - "Registered Organization Number", - "Org Book Status", - ]); - - scrollX(3000); - assertVisibleHeadersInclude([ - "Project Start Date", - "Project End Date", - "Projected Funding Total", - "Total Paid Amount $", - "Project Electoral District", - "Applicant Electoral District", - ]); - - scrollX(4500); - assertVisibleHeadersInclude([ - "Forestry or Non-Forestry", - "Forestry Focus", - "Acquisition", - "City", - "Community Population", - "Likelihood of Funding", - "Total Score", - ]); - - scrollX(6000); - assertVisibleHeadersInclude([ - "Assessment Result", - "Recommended Amount", - "Due Date", - "Owner", - "Decision Date", - "Project Summary", - "Organization Type", - "Business Number", - ]); - - scrollX(7500); - assertVisibleHeadersInclude([ - "Due Diligence Status", - "Decline Rationale", - "Contact Full Name", - "Contact Title", - "Contact Email", - "Contact Business Phone", - "Contact Cell Phone", - ]); - - scrollX(9000); - assertVisibleHeadersInclude([ - "Signing Authority Full Name", - "Signing Authority Title", - "Signing Authority Email", - "Signing Authority Business Phone", - "Signing Authority Cell Phone", - "Place", - "Risk Ranking", - "Notes", - "Red-Stop", - "Indigenous", - "FYE Day", - "FYE Month", - "Payout", - "Unity Application Id", - ]); - }); - - it("Verify Logout", () => { - cy.logout(); - }); -}); \ No newline at end of file + it("Verify Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 841506416..34eb15827 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -1,5 +1,511 @@ +/// + import { BasePage } from "./BasePage"; +/** + * ApplicationsListPage - Page Object for the Grant Applications List page + * Handles action bar, filters, table operations, columns menu, and modals + */ +export class ApplicationsListPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + private readonly BUTTON_TIMEOUT = 60000; + + // Date filter selectors + private readonly dateFilters = { + submittedFromDate: "input#submittedFromDate", + submittedToDate: "input#submittedToDate", + spinner: 'div.spinner-grow[role="status"]', + }; + + // Action bar selectors + private readonly actionBar = { + customButtons: "#app_custom_buttons", + dynamicButtonContainer: "#dynamicButtonContainerId", + paymentButton: "#applicationPaymentRequest", + exportButton: "#dynamicButtonContainerId .dt-buttons button span", + saveViewButton: "button.grp-savedStates", + columnsButton: "span", + }; + + // Table selectors + private readonly table = { + scrollBody: ".dt-scroll-body", + tableRows: ".dt-scroll-body tbody tr", + scrollHead: ".dt-scroll-head", + columnTitles: ".dt-scroll-head span.dt-column-title", + }; + + // Columns menu selectors + private readonly columnsMenu = { + dropdownItem: "a.dropdown-item", + buttonBackground: "div.dt-button-background", + }; + + // Payment modal selectors + private readonly paymentModal = { + modal: "#payment-modal", + backdrop: ".modal-backdrop", + cancelButton: "#payment-modal .modal-footer button", + }; + + // Grant program selectors + private readonly grantProgram = { + userInitials: ".unity-user-initials", + userDropdown: "#user-dropdown a.dropdown-item", + searchInput: "#search-grant-programs", + programsTable: "#UserGrantProgramsTable", + programsTableRow: "#UserGrantProgramsTable tbody tr", + }; + + // Save view selectors + private readonly saveView = { + button: "button.grp-savedStates", + resetOption: "a.dropdown-item", + }; + + constructor() { + super(); + } + + // ============ Date Filter Methods ============ + + /** + * Set the Submitted From Date filter + */ + setSubmittedFromDate(date: string): this { + cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }) + .clear({ force: true }) + .type(date, { force: true }) + .trigger("change", { force: true }) + .blur({ force: true }) + .should("have.value", date); + return this; + } + + /** + * Set the Submitted To Date filter + */ + setSubmittedToDate(date: string): this { + cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }) + .clear({ force: true }) + .type(date, { force: true }) + .trigger("change", { force: true }) + .blur({ force: true }) + .should("have.value", date); + return this; + } + + /** + * Wait for table refresh (spinner to be hidden) + */ + waitForTableRefresh(): this { + cy.get(this.dateFilters.spinner, { timeout: this.STANDARD_TIMEOUT }).then( + ($s: JQuery) => { + cy.wrap($s) + .should("have.attr", "style") + .and("contain", "display: none"); + } + ); + return this; + } + + /** + * Get today's date in ISO local format (YYYY-MM-DD) + */ + getTodayIsoLocal(): string { + const d = new Date(); + const pad2 = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + } + + // ============ Table Methods ============ + + /** + * Verify table has rows + */ + verifyTableHasData(): this { + cy.get(this.table.tableRows, { timeout: this.STANDARD_TIMEOUT }).should( + "have.length.greaterThan", + 1 + ); + return this; + } + + /** + * Select a row by index (clicks on a non-link cell) + */ + selectRowByIndex(rowIndex: number, withCtrl = false): this { + cy.get(this.table.tableRows, { timeout: this.STANDARD_TIMEOUT }) + .eq(rowIndex) + .find("td") + .not(":has(a)") + .first() + .click({ force: true, ctrlKey: withCtrl }); + return this; + } + + /** + * Select multiple rows by indices + */ + selectMultipleRows(indices: number[]): this { + indices.forEach((index, i) => { + this.selectRowByIndex(index, i > 0); + }); + return this; + } + + /** + * Scroll table horizontally to a specific position + */ + scrollTableHorizontally(x: number): this { + cy.get(this.table.scrollBody, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); + return this; + } + + /** + * Get visible header titles from the table + */ + getVisibleHeaderTitles(): Cypress.Chainable { + return cy + .get(this.table.columnTitles, { timeout: this.STANDARD_TIMEOUT }) + .then(($els: JQuery) => { + const titles: string[] = Cypress.$($els) + .toArray() + .map((el: HTMLElement) => (el.textContent || "").replace(/\s+/g, " ").trim()) + .filter((t: string) => t.length > 0); + return titles; + }); + } + + /** + * Assert that visible headers include expected columns + */ + assertVisibleHeadersInclude(expected: string[]): this { + this.getVisibleHeaderTitles().then((titles: string[]) => { + expected.forEach((e: string) => { + expect(titles, `visible headers should include "${e}"`).to.include(e); + }); + }); + return this; + } + + // ============ Action Bar Methods ============ + + /** + * Scroll to and verify action bar exists + */ + verifyActionBarExists(): this { + cy.get(this.actionBar.customButtons, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .scrollIntoView(); + return this; + } + + /** + * Click the Payment button + */ + clickPaymentButton(): this { + cy.get(this.actionBar.paymentButton, { timeout: this.BUTTON_TIMEOUT }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + return this; + } + + /** + * Verify Export button is visible + */ + verifyExportButtonVisible(): this { + cy.contains(this.actionBar.exportButton, "Export", { + timeout: this.STANDARD_TIMEOUT, + }).should("be.visible"); + return this; + } + + /** + * Verify Save View button is visible + */ + verifySaveViewButtonVisible(): this { + cy.contains( + "#dynamicButtonContainerId button.grp-savedStates", + "Save View", + { timeout: this.STANDARD_TIMEOUT } + ).should("be.visible"); + return this; + } + + /** + * Verify Columns button is visible + */ + verifyColumnsButtonVisible(): this { + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: this.STANDARD_TIMEOUT } + ).should("be.visible"); + return this; + } + + /** + * Verify dynamic button container exists + */ + verifyDynamicButtonContainerExists(): this { + cy.get(this.actionBar.dynamicButtonContainer, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .scrollIntoView(); + return this; + } + + // ============ Payment Modal Methods ============ + + /** + * Wait for payment modal to be visible + */ + waitForPaymentModalVisible(): this { + cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .and("have.class", "show"); + return this; + } + + /** + * Close payment modal using multiple strategies + */ + closePaymentModal(): this { + // Attempt ESC key + cy.get("body").type("{esc}", { force: true }); + + // Click backdrop if present + cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).then( + ($bd: JQuery) => { + if ($bd.length) { + cy.wrap($bd).click("topLeft", { force: true }); + } + } + ); + + // Try Cancel button if available + cy.contains(this.paymentModal.cancelButton, "Cancel", { + timeout: this.STANDARD_TIMEOUT, + }).then(($btn: JQuery) => { + if ($btn && $btn.length > 0) { + cy.wrap($btn).scrollIntoView().click({ force: true }); + } else { + cy.log("Cancel button not present, proceeding to hard-close fallback"); + } + }); + + // Hard close fallback using jQuery + cy.window().then((win: Cypress.AUTWindow) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const windowWithModal = win as any; + if (typeof windowWithModal.closePaymentModal === "function") { + windowWithModal.closePaymentModal(); + } + } catch { + /* ignore */ + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $ = (win as any).jQuery || (win as any).$; + if ($) { + try { + $("#payment-modal") + .removeClass("show") + .attr("aria-hidden", "true") + .css("display", "none"); + $(".modal-backdrop").remove(); + $("body").removeClass("modal-open").css("overflow", ""); + } catch { + /* ignore */ + } + } + }); + return this; + } + + /** + * Verify payment modal is closed + */ + verifyPaymentModalClosed(): this { + cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }).should( + ($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" + ); + return this; + } + + // ============ Columns Menu Methods ============ + + /** + * Close any open dropdowns or modals + */ + closeOpenDropdowns(): this { + cy.get("body").then(($body: JQuery) => { + if ($body.find(this.columnsMenu.buttonBackground).length > 0) { + cy.get(this.columnsMenu.buttonBackground).click({ force: true }); + } + }); + return this; + } + + /** + * Open Save View dropdown and reset to default + */ + resetToDefaultView(): this { + cy.get(this.saveView.button, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .and("contain.text", "Save View") + .click(); + + cy.contains(this.saveView.resetOption, "Reset to Default View", { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .click({ force: true }); + + // Wait for table to rebuild + cy.get(this.table.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should( + "have.length.gt", + 5 + ); + return this; + } + + /** + * Open the Columns menu + */ + openColumnsMenu(): this { + cy.contains("span", "Columns", { 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 + ); + return this; + } + + /** + * Click a column item in the Columns menu + */ + clickColumnsItem(label: string): this { + cy.contains(this.columnsMenu.dropdownItem, label, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .scrollIntoView() + .click({ force: true }); + return this; + } + + /** + * Toggle multiple columns (click each one) + */ + toggleColumns(columns: string[]): this { + columns.forEach((column) => { + this.clickColumnsItem(column); + }); + return this; + } + + /** + * Close the Columns menu + */ + closeColumnsMenu(): this { + cy.get(this.columnsMenu.buttonBackground, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); + + cy.get(this.columnsMenu.buttonBackground, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + return this; + } + + // ============ Grant Program Methods ============ + + /** + * Switch to a specific grant program if available + */ + switchToGrantProgram(programName: string): this { + cy.get("body").then(($body: JQuery) => { + const hasUserInitials = + $body.find(this.grantProgram.userInitials).length > 0; + + if (!hasUserInitials) { + cy.log("Skipping tenant switch: no user initials menu found"); + return; + } + + cy.get(this.grantProgram.userInitials).click(); + + cy.get("body").then(($body2: JQuery) => { + const switchLink = $body2 + .find(this.grantProgram.userDropdown) + .filter((_: number, el: HTMLElement) => { + return (el.textContent || "").trim() === "Switch Grant Programs"; + }); + + if (switchLink.length === 0) { + cy.log( + 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session' + ); + cy.get("body").click(0, 0); + return; + } + + cy.wrap(switchLink.first()).click(); + + cy.url({ timeout: this.STANDARD_TIMEOUT }).should( + "include", + "/GrantPrograms" + ); + + cy.get(this.grantProgram.searchInput, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .clear() + .type(programName); + + cy.contains(this.grantProgram.programsTableRow, programName, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .within(() => { + cy.contains("button", "Select").should("be.enabled").click(); + }); + + cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should( + (p: string) => { + expect( + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0 + ).to.eq(true); + } + ); + }); + }); + return this; + } +} + +/** + * ApplicationDetailsPage - Page Object for the Application Details page + * Handles tabs, status actions, and field verification + */ export class ApplicationDetailsPage extends BasePage { // Tab selectors private readonly tabs = { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs index c3b195e30..063347227 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; +using Unity.Payments.Codes; using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; @@ -101,7 +102,7 @@ private static (decimal paidAmount, decimal pendingAmount) CalculatePaymentAmoun var paidAmount = requestsList .Where(e => !string.IsNullOrWhiteSpace(e.PaymentStatus) - && e.PaymentStatus.Trim().Equals("Fully Paid", StringComparison.OrdinalIgnoreCase)) + && e.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .Sum(e => e.Amount); var pendingAmount = requestsList diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css index 1de7a1885..e35ed9757 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css @@ -518,20 +518,28 @@ input.form-control-currency { input.form-control:disabled, textarea.form-control:disabled, .form-select:disabled, input.form-control-currency:disabled { color: var(--bc-colors-grey-text-200); - pointer-events: none; background-color: var(--bc-colors-grey-hover) !important; opacity: var(--bs-btn-disabled-opacity); background-blend-mode: difference; border: var(--bs-border-width) solid var(--bs-border-color); + + pointer-events: auto; + user-select: text; + -webkit-user-select: text; + cursor: text; } input.form-control-currency:disabled { color: var(--bc-colors-grey-text-200); - pointer-events: none; background-color: var(--bc-colors-grey-hover) !important; opacity: var(--bs-btn-disabled-opacity); background-blend-mode: difference; border: var(--bs-border-width) solid var(--bs-border-color); + + pointer-events: auto; + user-select: text; + -webkit-user-select: text; + cursor: text; } textarea.form-control:disabled { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs similarity index 76% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs index 9ec81114f..c36d91fe4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs @@ -1,7 +1,7 @@ using System; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public class ApplicantProfileDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs similarity index 89% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs index 9f65d31cd..5b6ef5b92 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs @@ -1,6 +1,6 @@ using System; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public class ApplicantProfileRequest { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs index 968cef47f..feb364582 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Unity.GrantManager.Applicants; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public interface IApplicantProfileAppService { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs new file mode 100644 index 000000000..671c49736 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Provides applicant-profile-specific contact retrieval operations. +/// This service aggregates contacts from two sources: profile-linked contacts +/// and application-level contacts matched by OIDC subject. +/// +public interface IApplicantProfileContactService +{ + /// + /// Retrieves contacts linked to the specified applicant profile. + /// + /// The unique identifier of the applicant profile. + /// A list of with IsEditable set to true. + Task> GetProfileContactsAsync(Guid profileId); + + /// + /// Retrieves application contacts associated with submissions matching the given OIDC subject. + /// The subject is normalized by stripping the domain portion (after @) and converting to upper case. + /// + /// The OIDC subject identifier (e.g. "user@idir"). + /// A list of with IsEditable set to false. + Task> GetApplicationContactsBySubjectAsync(string subject); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs similarity index 90% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs index 6a1b6b691..1dd840487 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { /// /// Defines a contract for components that can provide applicant profile data diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs similarity index 70% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs index fde1734a0..a532be406 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantAddressInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs new file mode 100644 index 000000000..716f78928 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ApplicantContactInfoDto : ApplicantProfileDataDto + { + public override string DataType => "CONTACTINFO"; + + public List Contacts { get; set; } = []; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs similarity index 69% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs index c14ac0413..4a99135f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantOrgInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs similarity index 70% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs index a6f7b77c3..c17c89eeb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantPaymentInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs new file mode 100644 index 000000000..65da1a0fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + [JsonPolymorphic(TypeDiscriminatorPropertyName = "dataType")] + [JsonDerivedType(typeof(ApplicantContactInfoDto), "CONTACTINFO")] + [JsonDerivedType(typeof(ApplicantOrgInfoDto), "ORGINFO")] + [JsonDerivedType(typeof(ApplicantAddressInfoDto), "ADDRESSINFO")] + [JsonDerivedType(typeof(ApplicantSubmissionInfoDto), "SUBMISSIONINFO")] + [JsonDerivedType(typeof(ApplicantPaymentInfoDto), "PAYMENTINFO")] + public class ApplicantProfileDataDto + { + public virtual string DataType { get; } = ""; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs similarity index 71% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs index 4c0a0ba60..5c3618c30 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantSubmissionInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs new file mode 100644 index 000000000..2be1b4ed0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs @@ -0,0 +1,21 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ContactInfoItemDto + { + public Guid ContactId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Title { get; set; } + public string? Email { get; set; } + public string? HomePhoneNumber { get; set; } + public string? MobilePhoneNumber { get; set; } + public string? WorkPhoneNumber { get; set; } + public string? WorkPhoneExtension { get; set; } + public string? ContactType { get; set; } + public string? Role { get; set; } + public bool IsPrimary { get; set; } + public bool IsEditable { get; set; } + public Guid? ApplicationId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs deleted file mode 100644 index 74c15630b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantContactInfoDto : ApplicantProfileDataDto - { - public override string DataType => "CONTACTINFO"; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs deleted file mode 100644 index 3c717b28b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantProfileDataDto - { - public virtual string DataType { get; } = ""; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs new file mode 100644 index 000000000..263a54757 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs @@ -0,0 +1,39 @@ +using System; + +namespace Unity.GrantManager.Contacts; + +/// +/// Represents a contact linked to an entity, returned by the generic contacts service. +/// +public class ContactDto +{ + /// The unique identifier of the contact. + public Guid ContactId { get; set; } + + /// The full name of the contact. + public string Name { get; set; } = string.Empty; + + /// The job title of the contact. + public string? Title { get; set; } + + /// The email address of the contact. + public string? Email { get; set; } + + /// The home phone number of the contact. + public string? HomePhoneNumber { get; set; } + + /// The mobile phone number of the contact. + public string? MobilePhoneNumber { get; set; } + + /// The work phone number of the contact. + public string? WorkPhoneNumber { get; set; } + + /// The work phone extension of the contact. + public string? WorkPhoneExtension { get; set; } + + /// The role of the contact within the linked entity context. + public string? Role { get; set; } + + /// Whether this contact is the primary contact for the linked entity. + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs new file mode 100644 index 000000000..58e49b7ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Unity.GrantManager.Contacts; + +/// +/// Input DTO for creating a new contact and linking it to a related entity. +/// +public class CreateContactLinkDto +{ + /// The full name of the contact. + public string Name { get; set; } = string.Empty; + + /// The job title of the contact. + public string? Title { get; set; } + + /// The email address of the contact. + public string? Email { get; set; } + + /// The home phone number of the contact. + public string? HomePhoneNumber { get; set; } + + /// The mobile phone number of the contact. + public string? MobilePhoneNumber { get; set; } + + /// The work phone number of the contact. + public string? WorkPhoneNumber { get; set; } + + /// The work phone extension of the contact. + public string? WorkPhoneExtension { get; set; } + + /// The role of the contact within the linked entity context. + public string? Role { get; set; } + + /// Whether this contact should be set as the primary contact. Only one primary is allowed per entity type and entity ID. + public bool IsPrimary { get; set; } + + /// The type of the entity to link the contact to (e.g. "ApplicantProfile"). + public string RelatedEntityType { get; set; } = string.Empty; + + /// The unique identifier of the related entity. + public Guid RelatedEntityId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs new file mode 100644 index 000000000..4a07057c5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Contacts; + +/// +/// Generic contact management service. Provides operations for creating, retrieving, +/// and managing contacts linked to any entity type via . +/// +public interface IContactAppService +{ + /// + /// Retrieves all active contacts linked to the specified entity. + /// + /// The type of the related entity (e.g. "ApplicantProfile"). + /// The unique identifier of the related entity. + /// A list of for the matching entity. + Task> GetContactsByEntityAsync(string entityType, Guid entityId); + + /// + /// Creates a new contact and links it to the specified entity. + /// If is true, any existing primary + /// contact for the same entity type and ID will be cleared first. + /// + /// The contact and link details. + /// The created . + Task CreateContactAsync(CreateContactLinkDto input); + + /// + /// Sets the specified contact as the primary contact for the given entity. + /// Only one primary contact is allowed per entity type and entity ID; + /// any existing primary will be cleared before setting the new one. + /// + /// The type of the related entity. + /// The unique identifier of the related entity. + /// The unique identifier of the contact to set as primary. + /// Thrown when no active contact link is found for the given parameters. + Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 693a28994..f77a53000 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class AddressInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs similarity index 98% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs index 057b27d90..df8617813 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Unity.GrantManager.Applicants.ApplicantProfile; +using Unity.GrantManager.Applicants; using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.Application.Services; @@ -12,7 +12,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { [RemoteService(false)] public class ApplicantProfileAppService( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs new file mode 100644 index 000000000..46ef6e66f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles +/// and application-level contacts matched by OIDC subject. This service operates independently +/// from the generic and queries repositories directly. +/// +public class ApplicantProfileContactService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + IRepository applicationFormSubmissionRepository, + IRepository applicationContactRepository) + : IApplicantProfileContactService, ITransientDependency +{ + private const string ApplicantProfileEntityType = "ApplicantProfile"; + + /// + public async Task> GetProfileContactsAsync(Guid profileId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == ApplicantProfileEntityType + && link.RelatedEntityId == profileId + && link.IsActive + select new ContactInfoItemDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + ContactType = link.RelatedEntityType, + Role = link.Role, + IsPrimary = link.IsPrimary, + IsEditable = true, + ApplicationId = null + }).ToListAsync(); + } + + /// + public async Task> GetApplicationContactsBySubjectAsync(string subject) + { + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + + var applicationContacts = await ( + from submission in submissionsQuery + join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId + where submission.OidcSub == normalizedSubject + select new ContactInfoItemDto + { + ContactId = appContact.Id, + Name = appContact.ContactFullName, + Title = appContact.ContactTitle, + Email = appContact.ContactEmail, + MobilePhoneNumber = appContact.ContactMobilePhone, + WorkPhoneNumber = appContact.ContactWorkPhone, + Role = GetMatchingRole(appContact.ContactType), + ContactType = "Application", + IsPrimary = false, + IsEditable = false, + ApplicationId = appContact.ApplicationId + }).ToListAsync(); + + return applicationContacts; + } + + private static string GetMatchingRole(string contactType) + { + return ApplicationContactOptionList.ContactTypeList.TryGetValue(contactType, out string? value) + ? value : contactType; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs similarity index 85% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs index 4b232c453..70bdfaaaa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { public static class ApplicantProfileKeys { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs new file mode 100644 index 000000000..13bd414ee --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Provides contact information for the applicant profile by aggregating + /// profile-linked contacts and application-level contacts. + /// + [ExposeServices(typeof(IApplicantProfileDataProvider))] + public class ContactInfoDataProvider( + ICurrentTenant currentTenant, + IApplicantProfileContactService applicantProfileContactService) + : IApplicantProfileDataProvider, ITransientDependency + { + /// + public string Key => ApplicantProfileKeys.ContactInfo; + + /// + public async Task GetDataAsync(ApplicantProfileInfoRequest request) + { + var dto = new ApplicantContactInfoDto + { + Contacts = [] + }; + + var tenantId = request.TenantId; + + using (currentTenant.Change(tenantId)) + { + var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId); + dto.Contacts.AddRange(profileContacts); + + var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject); + dto.Contacts.AddRange(applicationContacts); + } + + return dto; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index 6d7f3c7cc..2d1ced24c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index 5684f158e..8e1ddde04 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class PaymentInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs similarity index 83% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 7af7e641f..9bd91fcbd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class SubmissionInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs deleted file mode 100644 index 71539ca65..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; -using Volo.Abp.DependencyInjection; - -namespace Unity.GrantManager.Applicants.ApplicantProfile -{ - [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class ContactInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency - { - public string Key => ApplicantProfileKeys.ContactInfo; - - public Task GetDataAsync(ApplicantProfileInfoRequest request) - { - return Task.FromResult(new ApplicantContactInfoDto()); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs index b93b81032..61d07b1ff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs @@ -2,6 +2,7 @@ using Quartz; using System; using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; using Volo.Abp.BackgroundWorkers.Quartz; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs new file mode 100644 index 000000000..8a70946c5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Contacts; + +/// +/// Generic contact management service. Manages contacts and their links to arbitrary entity types. +/// Currently marked as [RemoteService(false)] not exposed as an HTTP endpoint. +/// Authorization roles to be configured before enabling remote access. +/// + +[Authorize] +[RemoteService(false)] +[ExposeServices(typeof(ContactAppService), typeof(IContactAppService))] +public class ContactAppService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository) + : GrantManagerAppService, IContactAppService +{ + /// + public async Task> GetContactsByEntityAsync(string entityType, Guid entityId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == entityType + && link.RelatedEntityId == entityId + && link.IsActive + select new ContactDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + Role = link.Role, + IsPrimary = link.IsPrimary + }).ToListAsync(); + } + + /// + public async Task CreateContactAsync(CreateContactLinkDto input) + { + var contact = await contactRepository.InsertAsync(new Contact + { + Name = input.Name, + Title = input.Title, + Email = input.Email, + HomePhoneNumber = input.HomePhoneNumber, + MobilePhoneNumber = input.MobilePhoneNumber, + WorkPhoneNumber = input.WorkPhoneNumber, + WorkPhoneExtension = input.WorkPhoneExtension + }, autoSave: true); + + if (input.IsPrimary) + { + await ClearPrimaryAsync(input.RelatedEntityType, input.RelatedEntityId); + } + + await contactLinkRepository.InsertAsync(new ContactLink + { + ContactId = contact.Id, + RelatedEntityType = input.RelatedEntityType, + RelatedEntityId = input.RelatedEntityId, + Role = input.Role, + IsPrimary = input.IsPrimary, + IsActive = true + }, autoSave: true); + + return new ContactDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + Role = input.Role, + IsPrimary = input.IsPrimary + }; + } + + /// + public async Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId) + { + await ClearPrimaryAsync(entityType, entityId); + + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var link = await contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.ContactId == contactId + && l.IsActive) + .FirstOrDefaultAsync() ?? throw new BusinessException("Contacts:ContactLinkNotFound") + .WithData("contactId", contactId) + .WithData("entityType", entityType) + .WithData("entityId", entityId); + link.IsPrimary = true; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + /// + /// Clears the primary flag on all active contact links for the specified entity. + /// + private async Task ClearPrimaryAsync(string entityType, Guid entityId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var currentPrimaryLinks = await contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.IsPrimary + && l.IsActive) + .ToListAsync(); + + foreach (var existing in currentPrimaryLinks) + { + existing.IsPrimary = false; + await contactLinkRepository.UpdateAsync(existing, autoSave: true); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 66ddcf371..d590a5482 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -23,7 +23,7 @@ using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; -using Unity.Payments.Enums; +using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -52,7 +52,7 @@ public class GrantApplicationAppService( { public async Task> GetListAsync(GrantApplicationListInputDto input) { - // 1️⃣ Fetch applications with filters + paging in DB + // 1️ Fetch applications with filters + paging in DB var applications = await applicationRepository.WithFullDetailsAsync( input.SkipCount, input.MaxResultCount, @@ -72,13 +72,14 @@ public async Task> GetListAsync(GrantApplica paymentRequests = await paymentRequestService.GetListByApplicationIdsAsync(applicationIds); } - // 2️⃣ Pre-aggregate payment amounts for O(1) lookup + // 2️ Pre-aggregate payment amounts for O(1) lookup var paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) + .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) + && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .GroupBy(pr => pr.CorrelationId) .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); - // 3️⃣ Map applications to DTOs + // 3️ Map applications to DTOs var appDtos = applications.Select(app => { var appDto = ObjectMapper.Map(app); @@ -113,7 +114,7 @@ public async Task> GetListAsync(GrantApplica }).ToList(); - // 4️⃣ Get total count using same filters + // 4️ Get total count using same filters var totalCount = await applicationRepository.GetCountAsync( input.SubmittedFromDate, input.SubmittedToDate diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs index 991948f97..169659b1d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs @@ -29,5 +29,17 @@ public static class Endpoints public const string Default = GroupName + ".Endpoints"; public const string ManageEndpoints = Default + ".ManageEndpoints"; } + + /// + /// Permission constants for the generic contacts service. + /// These are pre-wired for future HTTP endpoint exposure. + /// + public static class Contacts + { + public const string Default = GroupName + ".Contacts"; + public const string Create = Default + ".Create"; + public const string Read = Default + ".Read"; + public const string Update = Default + ".Update"; + } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs new file mode 100644 index 000000000..f8f7f5b95 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs @@ -0,0 +1,16 @@ +using System; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories +{ + [Dependency(ReplaceServices = true)] + [ExposeServices(typeof(IContactLinkRepository))] + // This pattern is an implementation ontop of ABP framework, will not change this + public class ContactLinkRepository(IDbContextProvider dbContextProvider) : EfCoreRepository(dbContextProvider), IContactLinkRepository + { + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs new file mode 100644 index 000000000..e83324eae --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs @@ -0,0 +1,16 @@ +using System; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories +{ + [Dependency(ReplaceServices = true)] + [ExposeServices(typeof(IContactRepository))] + // This pattern is an implementation ontop of ABP framework, will not change this + public class ContactRepository(IDbContextProvider dbContextProvider) : EfCoreRepository(dbContextProvider), IContactRepository + { + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index bff4d0f6b..127a2861b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -1,6 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; -using Unity.GrantManager.Applicants; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Controllers.Authentication; using Volo.Abp.AspNetCore.Mvc; @@ -12,8 +13,20 @@ namespace Unity.GrantManager.Controllers public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase { + /// + /// Retrieves applicant profile data based on the specified key. + /// The response data property is polymorphic and varies by key: + /// + /// CONTACTINFO — returns + /// ORGINFO — returns + /// ADDRESSINFO — returns + /// SUBMISSIONINFO — returns + /// PAYMENTINFO — returns + /// + /// [HttpGet] [Route("profile")] + [ProducesResponseType(typeof(ApplicantProfileDto), StatusCodes.Status200OK)] public async Task GetApplicantProfileAsync([FromQuery] ApplicantProfileInfoRequest applicantProfileRequest) { var profile = await applicantProfileAppService.GetApplicantProfileAsync(applicantProfileRequest); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 4a345330c..caf91fc16 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -460,6 +460,7 @@ private static void ConfigureSwaggerServices(IServiceCollection services) Type = SecuritySchemeType.ApiKey, Scheme = "ApiKeyScheme" }); + options.SchemaFilter(); } ); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css index 0fb63a205..8d77b2fed 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css @@ -21,7 +21,7 @@ /* Left panel tabs scrolling */ #detailsTab .tab-content { - overflow-y: scroll; + overflow-y: auto; overflow-x: hidden; height: calc(100vh - 220px); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index b8b668979..6b9564063 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -31,6 +31,7 @@ function initializeApplicantDetailsPage() { setTimeout(function () { $('#main-loading').fadeOut(300, function () { $('.fade-in-load').addClass('visible'); + applyTabHeightOffset(); }); }, 500); @@ -82,6 +83,18 @@ function adjustVisibleTablesInContainer(containerId) { }); } +function applyTabHeightOffset() { + const detailsTab = document.getElementById('detailsTab'); + if (!detailsTab) return; + const tabNav = detailsTab.querySelector('ul.nav-tabs, ul.nav'); + const tabContent = detailsTab.querySelector('.tab-content'); + if (!tabNav || !tabContent) return; + const baseOffset = 175; + const totalOffset = baseOffset + tabNav.clientHeight; + tabContent.style.height = `calc(100vh - ${totalOffset}px)`; + tabContent.style.overflowY = 'auto'; +} + function initializeResizableDivider() { const divider = document.getElementById('main-divider'); const leftPanel = document.getElementById('main-left'); @@ -114,6 +127,7 @@ function initializeResizableDivider() { // Resize DataTables during panel resize debouncedResizeAwareDataTables(); + applyTabHeightOffset(); localStorage.setItem(storageKey, leftPercentage.toString()); } }; @@ -150,6 +164,7 @@ function initializeResizableDivider() { }); window.addEventListener('resize', restoreDividerPosition); + window.addEventListener('resize', applyTabHeightOffset); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml index 437084740..0ca46aa5d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml @@ -10,6 +10,10 @@ Layout = null; } + +@* NOTE: Dependency /Pages/ApplicationContact/EditContactModal.js is included through ApplicationContactsWidget *@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js new file mode 100644 index 000000000..a0bdff0ab --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js @@ -0,0 +1,46 @@ +(function ($) { + abp.modals.editOrDeleteContactModal = function () { + let initModal = function (publicApi, args) { + let modalManager = publicApi; + + $('#DeleteContactButton').click(handleDeleteContact); + + function handleDeleteContact(e) { + e.preventDefault(); + abp.message.confirm('Are you sure to delete this contact?') + .then(processDeleteConfirmation); + } + + function processDeleteConfirmation(confirmed) { + if (confirmed) { + deleteContact(); + } + } + + function deleteContact() { + try { + unity.grantManager.grantApplications.applicationContact + .delete(args.id) + .done(onContactDeleted) + .fail(onDeleteFailure); + } catch (error) { + onDeleteFailure(error); + } + } + + function onContactDeleted() { + modalManager.close(); + PubSub.publish("refresh_application_contacts"); + abp.notify.success('The contact has been deleted.'); + } + + function onDeleteFailure(error) { + abp.notify.error('Contact deletion failed.'); + if (error) { + console.log(error); + } + } + }; + return { initModal: initModal }; + } +})(jQuery); \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs index 715b1af77..4a4656c0e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs @@ -119,7 +119,35 @@ public async Task OnPostAsync() { List? selectedLinksWithTypes = JsonConvert.DeserializeObject>(LinksWithTypes); List? grantApplications = JsonConvert.DeserializeObject>(GrantApplicationsList!); - List? linkedApplications = JsonConvert.DeserializeObject>(LinkedApplicationsList!); + List? linkedApplications = JsonConvert.DeserializeObject>(LinkedApplicationsList!) ?? []; + + // Refresh from database instead of deserializing stale client data coming in to catch race conditions added. + var allLinks = await _applicationLinksService.GetListByApplicationAsync(CurrentApplicationId ?? Guid.Empty); + // Filter out the reverse links + var databaseLinkedApplications = allLinks.Where(item => item.ApplicationId != CurrentApplicationId).ToList(); + + // We only care if the data in the database is different to do the validation. + var listsAreEqual = new HashSet(linkedApplications, new ApplicationLinksInfoDtoComparer()).SetEquals(databaseLinkedApplications); + if (!listsAreEqual) + { + var linkValidationResult = await ValidateOnPostLinks( + selectedLinksWithTypes ?? [], + grantApplications ?? [], + databaseLinkedApplications); + + if (linkValidationResult.HasErrors) + { + return new JsonResult(new + { + success = false, + //Updates have occurred while this window has been opened + message = string.Join(", ", linkValidationResult.ErrorMessages.Select(kvp => $"[{kvp.Key}]: {kvp.Value}")) + }); + } + // Replace the links with what is currently in the database to ensure we are working with the most up to date data + linkedApplications = databaseLinkedApplications; + } + if (selectedLinksWithTypes != null && grantApplications != null && linkedApplications != null) { @@ -127,49 +155,13 @@ public async Task OnPostAsync() foreach (var linkWithType in selectedLinksWithTypes) { var existingLink = linkedApplications.Find(app => app.ReferenceNumber == linkWithType.ReferenceNumber); - if (existingLink == null) { - // Add new link - var targetApplication = grantApplications.Find(app => app.ReferenceNo == linkWithType.ReferenceNumber); - if (targetApplication != null) - { - var linkedApplicationId = targetApplication.Id; - - // For CurrentApplication -> LinkedApplication - await _applicationLinksService.CreateAsync(new ApplicationLinksDto - { - ApplicationId = CurrentApplicationId ?? Guid.Empty, - LinkedApplicationId = linkedApplicationId, - LinkType = linkWithType.LinkType - }); - - // For LinkedApplication -> CurrentApplication (reverse link with appropriate type) - var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); - await _applicationLinksService.CreateAsync(new ApplicationLinksDto - { - ApplicationId = linkedApplicationId, - LinkedApplicationId = CurrentApplicationId ?? Guid.Empty, - LinkType = reverseLinkType - }); - } + await AddLink(linkWithType, grantApplications); } else { - // Check if the link type has changed - if (existingLink.LinkType != linkWithType.LinkType) - { - // Update the existing link's type - await _applicationLinksService.UpdateLinkTypeAsync(existingLink.Id, linkWithType.LinkType); - - // Also update the reverse link - var reverseLink = await _applicationLinksService.GetLinkedApplicationAsync(CurrentApplicationId ?? Guid.Empty, existingLink.ApplicationId); - var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); - await _applicationLinksService.UpdateLinkTypeAsync(reverseLink.Id, reverseLinkType); - - Logger.LogInformation("Updated link type for {ReferenceNumber} from {OldType} to {NewType}", - linkWithType.ReferenceNumber, existingLink.LinkType, linkWithType.LinkType); - } + await UpdateLink(linkWithType, existingLink); } } @@ -192,10 +184,109 @@ await _applicationLinksService.CreateAsync(new ApplicationLinksDto { Logger.LogError(ex, message: "Error updating application links"); } - return new JsonResult(new { success = true }); } + /// + /// Comparer to check for Application, LinkType and ProjectName when comparing data thats currently stored in the running + /// window versus what is stored in the database. Used to assist with race conditions prior to submitting from the modal. + /// + private sealed class ApplicationLinksInfoDtoComparer : IEqualityComparer + { + public bool Equals(ApplicationLinksInfoDto? x, ApplicationLinksInfoDto? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + return x.ApplicationId == y.ApplicationId && x.LinkType == y.LinkType && x.ProjectName == y.ProjectName; + } + + public int GetHashCode(ApplicationLinksInfoDto obj) => obj.ApplicationId.GetHashCode(); + } + + /// + /// If there is an inequality between what is in the application modal for links and the database, re-run the + /// validation checks to compare what is stored in the database rather than the local user window + /// + /// Link change the user is requesting + /// List of applications to retrieve their reference numbers for generating links + /// Existing links to compare against for validation + /// + /// List of ApplicationLinkValidationResult + private async Task ValidateOnPostLinks( + List newLinks, + List currentApplications, + List existingLinks) + { + var validateAllLinks = new List(); + + validateAllLinks.AddRange([.. newLinks.Select(link => + new ApplicationLinkValidationRequest + { + TargetApplicationId = currentApplications!.Single(app => app.ReferenceNo == link.ReferenceNumber).Id, + ReferenceNumber = link.ReferenceNumber, + LinkType = link.LinkType + })]); + + validateAllLinks.AddRange([.. existingLinks.Select(app => + new ApplicationLinkValidationRequest + { + TargetApplicationId = app.ApplicationId, + ReferenceNumber = app.ReferenceNumber, + LinkType = app.LinkType + } + )]); + + return await _applicationLinksService.ValidateApplicationLinksAsync(CurrentApplicationId ?? Guid.Empty, validateAllLinks); + } + + + private async Task AddLink(LinkWithType linkWithType, List grantApplications) + { + // Add new link + var targetApplication = grantApplications.Find(app => app.ReferenceNo == linkWithType.ReferenceNumber); + if (targetApplication != null) + { + var linkedApplicationId = targetApplication.Id; + + // For CurrentApplication -> LinkedApplication + await _applicationLinksService.CreateAsync(new ApplicationLinksDto + { + ApplicationId = CurrentApplicationId ?? Guid.Empty, + LinkedApplicationId = linkedApplicationId, + LinkType = linkWithType.LinkType + }); + + // For LinkedApplication -> CurrentApplication (reverse link with appropriate type) + var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); + await _applicationLinksService.CreateAsync(new ApplicationLinksDto + { + ApplicationId = linkedApplicationId, + LinkedApplicationId = CurrentApplicationId ?? Guid.Empty, + LinkType = reverseLinkType + }); + } + } + + + private async Task UpdateLink(LinkWithType linkWithType, ApplicationLinksInfoDto existingLink) + { + // Check if the link type has changed + if (existingLink.LinkType != linkWithType.LinkType) + { + // Update the existing link's type + await _applicationLinksService.UpdateLinkTypeAsync(existingLink.Id, linkWithType.LinkType); + + // Also update the reverse link + var reverseLink = await _applicationLinksService.GetLinkedApplicationAsync(CurrentApplicationId ?? Guid.Empty, existingLink.ApplicationId); + var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); + await _applicationLinksService.UpdateLinkTypeAsync(reverseLink.Id, reverseLinkType); + + Logger.LogInformation("Updated link type for {ReferenceNumber} from {OldType} to {NewType}", + linkWithType.ReferenceNumber, existingLink.LinkType, linkWithType.LinkType); + } + } + + private static ApplicationLinkType GetReverseLinkType(ApplicationLinkType linkType) { return linkType switch 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 3ebe15233..050bfb23b 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 @@ -7,6 +7,8 @@ const formatter = createNumberFormatter(); const l = abp.localization.getResource('GrantManager'); + const defaultQuickDateRange = 'last6months'; + let dt = $('#GrantApplicationsTable'); let dataTable; @@ -92,6 +94,22 @@ dt.search(''); dt.order(initialSortOrder).draw(); + // Reset date range filters + $('#quickDateRange').val(defaultQuickDateRange); + toggleCustomDateInputs(defaultQuickDateRange === 'custom'); + + const range = getDateRange(defaultQuickDateRange); + if (range) { + UIElements.submittedFromInput.val(range.fromDate); + UIElements.submittedToInput.val(range.toDate); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + + localStorage.setItem('GrantApplications_FromDate', range.fromDate); + localStorage.setItem('GrantApplications_ToDate', range.toDate); + localStorage.setItem('GrantApplications_QuickRange', defaultQuickDateRange); + } + // Close the dropdown dt.buttons('.grp-savedStates') .container() @@ -164,50 +182,41 @@ const listColumns = getColumns(); } function initializeSubmittedFilterDates() { - const fromDate = localStorage.getItem('GrantApplications_FromDate'); const toDate = localStorage.getItem('GrantApplications_ToDate'); + const savedRange = localStorage.getItem('GrantApplications_QuickRange') || defaultQuickDateRange; - // Check if localStorage has values and use them + // Set the dropdown value + $('#quickDateRange').val(savedRange); + + // Show/hide custom date inputs based on saved selection + toggleCustomDateInputs(savedRange === 'custom'); + + // If we have saved dates, use them if (fromDate && toDate) { UIElements.submittedFromInput.val(fromDate); UIElements.submittedToInput.val(toDate); grantTableFilters.submittedFromDate = fromDate; grantTableFilters.submittedToDate = toDate; - return; + } else { + const range = getDateRange(defaultQuickDateRange); + if (range?.fromDate && range?.toDate) { + UIElements.submittedFromInput.val(range.fromDate); + UIElements.submittedToInput.val(range.toDate); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + } } - let dtToday = new Date(); - let month = dtToday.getMonth() + 1; - let day = dtToday.getDate(); - let year = dtToday.getFullYear(); - if (month < 10) - month = '0' + month.toString(); - if (day < 10) - day = '0' + day.toString(); - let todayDate = year + '-' + month + '-' + day; - - let dtSixMonthsAgo = new Date(); - dtSixMonthsAgo.setMonth(dtSixMonthsAgo.getMonth() - 6); - let minMonth = dtSixMonthsAgo.getMonth() + 1; - let minDay = dtSixMonthsAgo.getDate(); - let minYear = dtSixMonthsAgo.getFullYear(); - if (minMonth < 10) - minMonth = '0' + minMonth.toString(); - if (minDay < 10) - minDay = '0' + minDay.toString(); - let suggestedMinDate = minYear + '-' + minMonth + '-' + minDay; - - UIElements.submittedToInput.attr({ 'max': todayDate }); - UIElements.submittedToInput.val(todayDate); - UIElements.submittedFromInput.attr({ 'max': todayDate }); - UIElements.submittedFromInput.val(suggestedMinDate); - grantTableFilters.submittedFromDate = suggestedMinDate; - grantTableFilters.submittedToDate = todayDate; + // Set max date to today for both inputs + const today = formatDate(new Date()); + UIElements.submittedToInput.attr({ 'max': today }); + UIElements.submittedFromInput.attr({ 'max': today }); } function bindUIEvents() { - UIElements.inputFilter.on('change', handleInputFilterChange); + UIElements.inputFilter.on('change', handleInputFilterChange); + $('#quickDateRange').on('change', handleQuickDateRangeChange); } function validateDate(dateValue, element) { @@ -243,6 +252,55 @@ const listColumns = getColumns(); return true; } + // Returns a formated { fromDate, toDate } for the filter fields. + // Null if 'custom' or no input provided (assumes custom is default break) + function getDateRange(rangeType) { + let today = new Date(); + const toDate = formatDate(new Date()); + let fromDate; + + switch (rangeType) { + case 'today': + fromDate = toDate; + break; + case 'last7days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 7))); + break; + case 'last30days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 30))); + break; + case 'last3months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 3))); + break; + case 'last6months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 6))); + break; + case 'alltime': + fromDate = null; + return { fromDate: null, toDate: null }; + case 'custom': + default: + return null; // Don't modify dates for custom + } + + return { fromDate, toDate }; + } + function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function toggleCustomDateInputs(show) { + if (show) { + $('#customDateInputs').show(); + } else { + $('#customDateInputs').hide(); + } + } + + // ===================== // Input filter change handler // ===================== @@ -255,6 +313,11 @@ const listColumns = getColumns(); grantTableFilters.submittedFromDate = UIElements.submittedFromInput.val(); grantTableFilters.submittedToDate = UIElements.submittedToInput.val(); + //If the values for FromDate and ToDate are being set outside of the + //quick drop down handler, custom SHOULD be shown, but set just in case + $('#quickDateRange').val('custom'); + localStorage.setItem('GrantApplications_QuickRange', 'custom'); + const dtInstance = $('#GrantApplicationsTable').DataTable(); localStorage.setItem("GrantApplications_FromDate", grantTableFilters.submittedFromDate); @@ -262,6 +325,46 @@ const listColumns = getColumns(); dtInstance.ajax.reload(null, true); } + + function handleQuickDateRangeChange() { + const selectedRange = $(this).val(); + + localStorage.setItem('GrantApplications_QuickRange', selectedRange); + + if (selectedRange === 'custom') { + // Show the custom date inputs and don't modify their values + toggleCustomDateInputs(true); + return; + } + + // Hide custom date inputs for preset ranges + toggleCustomDateInputs(false); + + // Get the date range for the selected option + const range = getDateRange(selectedRange); + + if (range) { + // Populate the hidden date fields + UIElements.submittedFromInput.val(range.fromDate || ''); + UIElements.submittedToInput.val(range.toDate || ''); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + + // Save to localStorage + if (range.fromDate && range.toDate) { + localStorage.setItem('GrantApplications_FromDate', range.fromDate); + localStorage.setItem('GrantApplications_ToDate', range.toDate); + } else { + // For "All time", clear the date filters + localStorage.removeItem('GrantApplications_FromDate'); + localStorage.removeItem('GrantApplications_ToDate'); + } + + // Reload the table with new filters + const dtInstance = $('#GrantApplicationsTable').DataTable(); + dtInstance.ajax.reload(null, true); + } + } function initializeDataTableAndEvents() { dataTable = initializeDataTable({ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs new file mode 100644 index 000000000..46a7fedc9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs @@ -0,0 +1,49 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Collections.Generic; +using Unity.GrantManager.ApplicantProfile.ProfileData; + +namespace Unity.GrantManager.Swagger +{ + public class ApplicantProfileDataSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type != typeof(ApplicantProfileDataDto)) + return; + + var subTypes = new Dictionary + { + ["CONTACTINFO"] = typeof(ApplicantContactInfoDto), + ["ORGINFO"] = typeof(ApplicantOrgInfoDto), + ["ADDRESSINFO"] = typeof(ApplicantAddressInfoDto), + ["SUBMISSIONINFO"] = typeof(ApplicantSubmissionInfoDto), + ["PAYMENTINFO"] = typeof(ApplicantPaymentInfoDto) + }; + + var oneOfSchemas = new List(); + foreach (var (_, subType) in subTypes) + { + var subSchema = context.SchemaGenerator.GenerateSchema(subType, context.SchemaRepository); + oneOfSchemas.Add(subSchema); + } + + schema.OneOf = oneOfSchemas; + schema.Discriminator = new OpenApiDiscriminator + { + PropertyName = "dataType", + Mapping = new Dictionary() + }; + + foreach (var (discriminatorValue, subType) in subTypes) + { + var schemaId = context.SchemaRepository.Schemas.ContainsKey(subType.FullName!) + ? subType.FullName! + : subType.Name; + schema.Discriminator.Mapping[discriminatorValue] = $"#/components/schemas/{schemaId}"; + } + + schema.Description = "Polymorphic data payload. The shape depends on the 'dataType' discriminator (key parameter)."; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index eaf1c5bb6..bb425251d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -17,29 +17,43 @@
- - + + +
+ -
- - -
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css index 81685f369..cca218855 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css @@ -37,3 +37,9 @@ margin-bottom: -10px; padding-bottom: 0px !important; } + +.custom-date-range-container-div { + display: inline-block; + padding: 0px; + margin: 0px; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css index 7ae7cc4f7..d6f31385e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css @@ -24,7 +24,6 @@ } .applicant-organization-info { - background-color: #f8f9fa; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index 7f30880c2..ad1d64a50 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -30,7 +30,6 @@ }); bool IsViewEditable = !updatePermissionResult.AllProhibited; - bool IsAdditionalContactAddable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create); bool IsAdditionalContactEditable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update); bool IsAssignApplicant = await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.AssignApplicant); bool IsLookupEnabled = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.Summary.Update); @@ -369,18 +368,7 @@ form-id="@Model.ApplicationFormId" show-legend="false" editable-if="IsAdditionalContactEditable"> -
@L["Summary:ContactsTitle"].Value
-
- @await Component.InvokeAsync("ApplicationContactsWidget", new { applicationId = Model.ApplicationId, isReadOnly = !IsAdditionalContactEditable }) -
- - @if (IsAdditionalContactAddable) - { -
- -
- } + @await Component.InvokeAsync("ApplicationContactsWidget", new { applicationId = Model.ApplicationId })
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml index 0c7366d67..552b70232 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml @@ -40,26 +40,19 @@ - + - + - +
- -
-
- -
-
-
@@ -74,7 +67,7 @@ - + - - + @@ -128,7 +127,11 @@ - +
+
+ +
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css index a1c02309a..7f9d5e60a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css @@ -31,7 +31,6 @@ } .applicant-organization-info { - background-color: #f8f9fa; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs index fe8ef8e38..355613cac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs @@ -6,7 +6,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Payments; -using Unity.Payments.Enums; +using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -62,7 +62,8 @@ public async Task InvokeAsync(Guid applicantId) { var paymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(applicationIds); paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) + .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) + && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .GroupBy(pr => pr.CorrelationId) .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css index 295de3ccd..7a017fac3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css @@ -108,9 +108,9 @@ word-wrap: break-word; } -/* Override tab-content scrolling for Submissions tab - no scrolling */ -#detailsTab .tab-content:has(#SubmissionsWidget) { - overflow: hidden !important; +/* Submissions tab pane - handle overflow internally via dt-scroll-body */ +#nav-submissions { + overflow: hidden; } /* Make Submissions widget fill available space */ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs index 3d1a62cd1..bd396f328 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs @@ -15,14 +15,14 @@ public class ApplicationContactsWidgetController : AbpController [HttpGet] [Route("RefreshApplicationContacts")] - public IActionResult ApplicationContacts(Guid applicationId, Boolean isReadOnly = false) + public IActionResult ApplicationContacts(Guid applicationId) { if (!ModelState.IsValid) { logger.LogWarning("Invalid model state for ApplicationContactsWidgetController: RefreshApplicationContacts"); - return ViewComponent("ApplicationContactsWidget"); + return ViewComponent("ApplicationContactsWidget", new { applicationId }); } - return ViewComponent("ApplicationContactsWidget", new { applicationId, isReadOnly }); + return ViewComponent("ApplicationContactsWidget", new { applicationId }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs index 9022e29a1..d65bba4c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicationContactsWidg RefreshUrl = "Widgets/ApplicationContacts/RefreshApplicationContacts", ScriptTypes = new[] { typeof(ApplicationContactsWidgetScriptBundleContributor) }, StyleTypes = new[] { typeof(ApplicationContactsWidgetStyleBundleContributor) }, - AutoInitialize = true)] + AutoInitialize = false)] public class ApplicationContactsWidgetViewComponent : AbpViewComponent { private readonly IApplicationContactService _applicationContactService; @@ -23,13 +23,12 @@ public ApplicationContactsWidgetViewComponent(IApplicationContactService applica _applicationContactService = applicationContactService; } - public async Task InvokeAsync(Guid applicationId, Boolean isReadOnly) + public async Task InvokeAsync(Guid applicationId) { List applicationContacts = await _applicationContactService.GetListByApplicationAsync(applicationId); ApplicationContactsWidgetViewModel model = new() { ApplicationContacts = applicationContacts, - ApplicationId = applicationId, - IsReadOnly = isReadOnly + ApplicationId = applicationId }; return View(model); @@ -52,7 +51,7 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files .AddIfNotContains("/Views/Shared/Components/ApplicationContactsWidget/Default.js"); context.Files - .AddIfNotContains("/libs/pubsub-js/src/pubsub.js"); + .AddIfNotContains("/Pages/ApplicationContact/EditContactModal.js"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs index e76480ab1..13afa46c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs @@ -14,7 +14,6 @@ public ApplicationContactsWidgetViewModel() public List ApplicationContacts { get; set; } public Guid ApplicationId { get; set; } - public Boolean IsReadOnly { get; set; } public static String ContactTypeValue(String contactType) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml index ae8f74799..1f127697b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml @@ -1,52 +1,77 @@ +@using Microsoft.Extensions.Localization @using Unity.GrantManager.Web.Views.Shared.Components.ApplicationContactsWidget; +@using Unity.GrantManager.Localization; +@using Unity.Modules.Shared +@using Volo.Abp.Authorization.Permissions + +@inject IPermissionChecker PermissionChecker +@inject IStringLocalizer L @model ApplicationContactsWidgetViewModel @{ Layout = null; } -
- @if (Model.ApplicationContacts.Count > 0) { -

Info

-
- } - @foreach (var contact in Model.ApplicationContacts) - { -
-
-
-

@ApplicationContactsWidgetViewModel.ContactTypeValue(contact.ContactType)

-

@contact.ContactFullName, @contact.ContactTitle

- @if (!contact.ContactEmail.IsNullOrEmpty()) - { -
- -
@contact.ContactEmail
-
- } - @if (!contact.ContactMobilePhone.IsNullOrEmpty()) - { -
- -
@contact.ContactMobilePhone
-
- } - @if (!contact.ContactWorkPhone.IsNullOrEmpty()) - { -
- -
@contact.ContactWorkPhone
-
- } -
- @if(!(Model.IsReadOnly)) { -
- +@{ + bool IsAdditionalContactAddable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create); + bool IsAdditionalContactEditable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update); +} + +
@L["Summary:ContactsTitle"].Value
+ +@if (Model.ApplicationContacts.Count > 0) { +

Info

+
+} + +@foreach (var contact in Model.ApplicationContacts) +{ +
+
+
+

@ApplicationContactsWidgetViewModel.ContactTypeValue(contact.ContactType)

+

@contact.ContactFullName, @contact.ContactTitle

+ @if (!contact.ContactEmail.IsNullOrEmpty()) + { +
+ +
@contact.ContactEmail
+
+ } + @if (!contact.ContactMobilePhone.IsNullOrEmpty()) + { +
+ +
@contact.ContactMobilePhone
+
+ } + @if (!contact.ContactWorkPhone.IsNullOrEmpty()) + { +
+ +
@contact.ContactWorkPhone
}
-
+ @if (IsAdditionalContactEditable) + { +
+ +
+ }
- } -
+
+
+} + +@if (IsAdditionalContactAddable) +{ +
+ +
+} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js index 5e48b25eb..f1858be26 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js @@ -1,74 +1,102 @@ $(function () { - - let contactModal = new abp.ModalManager({ + let applicantContactsWidgetToken = null; + let _createContactModal = new abp.ModalManager(abp.appPath + 'ApplicationContact/CreateContactModal'); + let _editContactModal = new abp.ModalManager({ viewUrl: abp.appPath + 'ApplicationContact/EditContactModal', - modalClass: "editContactModal" + scriptUrl: abp.appPath + 'Pages/ApplicationContact/EditContactModal.js', + modalClass: "editOrDeleteContactModal" }); - abp.modals.editContactModal = function () { - let initModal = function (publicApi, args) { - setupContactModal(args); - }; - return { initModal: initModal }; - } - - $('body').on('click','.contact-edit-btn',function(e){ - e.preventDefault(); - let itemId = $(this).data('id'); - contactModal.open({ - id: itemId - }); + // Handle modal result - refresh the widget after successful contact creation + _createContactModal.onResult(function () { + PubSub.publish("refresh_application_contacts"); + abp.notify.success( + 'The application contact has been successfully added.', + 'Application Contacts' + ); }); - contactModal.onResult(function () { + _editContactModal.onResult(function () { + PubSub.publish("refresh_application_contacts"); abp.notify.success( - 'The application contact have been successfully updated.', + 'The application contact has been successfully updated.', 'Application Contacts' ); - PubSub.publish("refresh_application_contacts"); }); - let setupContactModal = function (args) { - $('#DeleteContactButton').click(handleDeleteContact); + abp.widgets.ApplicationContactsWidget = function ($wrapper) { - function handleDeleteContact(e) { - e.preventDefault(); - showDeleteConfirmation(); - } - - function showDeleteConfirmation() { - abp.message.confirm('Are you sure to delete this contact?') - .then(processDeleteConfirmation); - } - - function processDeleteConfirmation(confirmed) { - if (confirmed) { - deleteContact(); - } - } - - function deleteContact() { - try { - unity.grantManager.grantApplications.applicationContact - .delete(args.id) - .done(onContactDeleted) - .fail(onDeleteFailure); - } catch (error) { - onDeleteFailure(error); - } - } - - function onContactDeleted() { - PubSub.publish("refresh_application_contacts"); - contactModal.close(); - abp.notify.success('The contact has been deleted.'); - } - - function onDeleteFailure(error) { - abp.notify.error('Contact deletion failed.'); - if (error) { - console.log(error); + let _widgetManager = $wrapper.data('abp-widget-manager'); + + let widgetApi = { + applicationId: null, // Cache the applicationId to prevent reading from stale DOM + + getFilters: function () { + const appId = this.applicationId || $wrapper.find('#ApplicationContactsWidget_ApplicationId').val(); + + return { + applicationId: appId + }; + }, + + init: function (filters) { + this.applicationId = $wrapper.find('#ApplicationContactsWidget_ApplicationId').val(); + this.setupEventHandlers(); + }, + + refresh: function () { + const currentFilters = this.getFilters(); + _widgetManager.refresh($wrapper, currentFilters); + }, + + setupEventHandlers: function() { + const self = this; + + // Unsubscribe from previous subscription if it exists + // This prevents duplicate event handlers after widget refresh + if (applicantContactsWidgetToken) { + PubSub.unsubscribe(applicantContactsWidgetToken); + applicantContactsWidgetToken = null; + } + + applicantContactsWidgetToken = PubSub.subscribe( + 'refresh_application_contacts', + () => { + self.refresh(); + } + ); + + // Prevent duplicate delegated click handlers on re-init by removing any + // existing handlers in this widget's namespace before re-binding. + $wrapper.off('click.ApplicationContactsWidget', '#CreateContactButton'); + $wrapper.off('click.ApplicationContactsWidget', '.contact-edit-btn'); + + // Handle Add Contact button click + $wrapper.on('click.ApplicationContactsWidget', '#CreateContactButton', function (e) { + e.preventDefault(); + _createContactModal.open({ + applicationId: self.applicationId || $wrapper.find('#ApplicationContactsWidget_ApplicationId').val() + }); + }); + + $wrapper.on('click.ApplicationContactsWidget', '.contact-edit-btn', function (e) { + e.preventDefault(); + let itemId = $(this).data('id'); + _editContactModal.open({ + id: itemId + }); + }); } } - } + + return widgetApi; + }; + + // Initialize the ApplicationContactsWidget manager with filter callback + let applicationContactsWidgetManager = new abp.WidgetManager({ + wrapper: '.abp-widget-wrapper[data-widget-name="ApplicationContactsWidget"]' + }); + + // Initialize the widget + applicationContactsWidgetManager.init(); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js index 4e29d4aea..dcaeb4b1a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js @@ -136,6 +136,7 @@ $(function () { }) .catch(function (error) { abp.notify.error('Error deleting application link.'); + dataTable.ajax.reload(); }); } } @@ -163,6 +164,9 @@ $(function () { 'The application links have been successfully updated.', 'Application Links' ); + }); + + applicationLinksModal.onClose(function () { dataTable.ajax.reload(); }); @@ -921,17 +925,28 @@ $(function () { data: formData, processData: false, contentType: false, - success: () => { + success: (response) => { applicationLinksModal.close(); - abp.notify.success( - 'The application links have been successfully updated.', - 'Application Links' - ); + if (response.success) + { + abp.notify.success('The application links have been successfully updated.','Application Links'); + } + else + { // Display the error message from the server + abp.notify.error(response.message || 'Failed to update application links.','Application Links'); + } dataTable.ajax.reload(); }, error: (xhr, status, error) => { console.error('Error updating application links:', status, error); - abp.notify.error('Error updating application links: ' + error); + let errorMessage = 'Error updating application links.'; + + // Try to extract error message from response + if (xhr.responseJSON?.message) { + errorMessage = xhr.responseJSON.message; + } + + abp.notify.error(errorMessage); } }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js index 84d64f5e4..72d6b2e03 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js @@ -1,38 +1,5 @@ $(function () { - let applicationId = document.getElementById('SummaryWidgetApplicationId').value; - let isReadOnly = document.getElementById('SummaryWidgetIsReadOnly').value; - let contactModal = new abp.ModalManager(abp.appPath + 'ApplicationContact/CreateContactModal'); - - let applicationContactsWidgetManager = new abp.WidgetManager({ - wrapper: '#applicationContactsWidget', - filterCallback: function () { - return { - 'applicationId': applicationId, - 'isReadOnly': isReadOnly - }; - } - }); - - $('#AddContactButton').click(function (e) { - e.preventDefault(); - contactModal.open({ - applicationId: applicationId - }); - }); - - contactModal.onResult(function () { - abp.notify.success( - 'The application contact have been successfully added.', - 'Application Contacts' - ); - applicationContactsWidgetManager.refresh(); - }); - - PubSub.subscribe( - 'refresh_application_contacts', - (msg, data) => { - applicationContactsWidgetManager.refresh(); - } - ); + // SummaryWidget initialization + // Contact modal and widget management moved to ApplicationContactsWidget component }); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs index 29cde4344..81f518089 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs @@ -1,8 +1,8 @@ using Shouldly; using System; using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ApplicantProfile; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Xunit; using Xunit.Abstractions; @@ -21,7 +21,7 @@ public ApplicantProfileAppServiceTests(ITestOutputHelper outputHelper) : base(ou { ProfileId = Guid.NewGuid(), Subject = "testuser@idir", - TenantId = Guid.NewGuid(), + TenantId = Guid.Empty, Key = key }; @@ -37,7 +37,7 @@ public async Task GetApplicantProfileAsync_WithValidKey_ShouldReturnData(string var request = CreateRequest(key); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.ShouldNotBeNull(); @@ -60,7 +60,7 @@ public async Task GetApplicantProfileAsync_WithValidKey_ShouldReturnCorrectDataT var request = CreateRequest(key); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.Data.ShouldNotBeNull(); @@ -89,7 +89,7 @@ public async Task GetApplicantProfileAsync_KeyLookupIsCaseInsensitive() var request = CreateRequest("contactinfo"); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.Data.ShouldNotBeNull(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index c9fa64a0f..74ccc3f9a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -1,9 +1,12 @@ +using NSubstitute; using Shouldly; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ApplicantProfile; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.MultiTenancy; using Xunit; namespace Unity.GrantManager.Applicants @@ -18,17 +21,29 @@ public class ApplicantProfileDataProviderTests Key = key }; + private static ContactInfoDataProvider CreateContactInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var applicantProfileContactService = Substitute.For(); + applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); + applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); + return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); + } + [Fact] public void ContactInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new ContactInfoDataProvider(); + var provider = CreateContactInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.ContactInfo); } [Fact] public async Task ContactInfoDataProvider_GetDataAsync_ShouldReturnContactInfoDto() { - var provider = new ContactInfoDataProvider(); + var provider = CreateContactInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.ContactInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -103,7 +118,7 @@ public void AllProviders_ShouldHaveUniqueKeys() { IApplicantProfileDataProvider[] providers = [ - new ContactInfoDataProvider(), + CreateContactInfoDataProvider(), new OrgInfoDataProvider(), new AddressInfoDataProvider(), new SubmissionInfoDataProvider(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs new file mode 100644 index 000000000..e44be9fb4 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs @@ -0,0 +1,604 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.TestHelpers; +using Volo.Abp; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ContactAppServiceTests + { + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactAppService _service; + + public ContactAppServiceTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + + _service = new ContactAppService( + _contactRepository, + _contactLinkRepository); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + #region GetContactsByEntityAsync + + [Fact] + public async Task GetContactsByEntityAsync_WithMatchingLinks_ShouldReturnAllFields() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact + { + Name = "John Doe", + Title = "Manager", + Email = "john@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101" + }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + Role = "Primary Contact", + IsPrimary = true, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(contactId); + contact.Name.ShouldBe("John Doe"); + contact.Title.ShouldBe("Manager"); + contact.Email.ShouldBe("john@example.com"); + contact.HomePhoneNumber.ShouldBe("111-1111"); + contact.MobilePhoneNumber.ShouldBe("222-2222"); + contact.WorkPhoneNumber.ShouldBe("333-3333"); + contact.WorkPhoneExtension.ShouldBe("101"); + contact.Role.ShouldBe("Primary Contact"); + contact.IsPrimary.ShouldBeTrue(); + } + + [Fact] + public async Task GetContactsByEntityAsync_WithMultipleContacts_ShouldReturnAll() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId1 = Guid.NewGuid(); + var contactId2 = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Contact One" }, contactId1), + WithId(new Contact { Name = "Contact Two" }, contactId2) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId1, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }, + new ContactLink + { + ContactId = contactId2, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.Count.ShouldBe(2); + result.ShouldContain(c => c.Name == "Contact One" && c.IsPrimary); + result.ShouldContain(c => c.Name == "Contact Two" && !c.IsPrimary); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeInactiveLinks() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Inactive Contact" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsActive = false + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeDifferentEntityType() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Wrong Type" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "OtherType", + RelatedEntityId = entityId, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeDifferentEntityId() + { + // Arrange + var entityId = Guid.NewGuid(); + var otherEntityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Other Entity" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = otherEntityId, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_WithNoLinks_ShouldReturnEmpty() + { + // Arrange + _contactRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + _contactLinkRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", Guid.NewGuid()); + + // Assert + result.ShouldBeEmpty(); + } + + #endregion + + #region CreateContactAsync + + [Fact] + public async Task CreateContactAsync_ShouldCreateContactAndLink() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + _contactLinkRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + var input = new CreateContactLinkDto + { + Name = "New Contact", + Title = "Analyst", + Email = "new@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101", + Role = "Reviewer", + IsPrimary = false, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + var result = await _service.CreateContactAsync(input); + + // Assert + result.ContactId.ShouldBe(contactId); + result.Name.ShouldBe("New Contact"); + result.Title.ShouldBe("Analyst"); + result.Email.ShouldBe("new@example.com"); + result.HomePhoneNumber.ShouldBe("111-1111"); + result.MobilePhoneNumber.ShouldBe("222-2222"); + result.WorkPhoneNumber.ShouldBe("333-3333"); + result.WorkPhoneExtension.ShouldBe("101"); + result.Role.ShouldBe("Reviewer"); + result.IsPrimary.ShouldBeFalse(); + + await _contactRepository.Received(1).InsertAsync( + Arg.Is(c => + c.Name == "New Contact" + && c.Title == "Analyst" + && c.Email == "new@example.com" + && c.HomePhoneNumber == "111-1111" + && c.MobilePhoneNumber == "222-2222" + && c.WorkPhoneNumber == "333-3333" + && c.WorkPhoneExtension == "101"), + true, + Arg.Any()); + + await _contactLinkRepository.Received(1).InsertAsync( + Arg.Is(l => + l.ContactId == contactId + && l.RelatedEntityType == "TestEntity" + && l.RelatedEntityId == entityId + && l.Role == "Reviewer" + && !l.IsPrimary + && l.IsActive), + true, + Arg.Any()); + } + + [Fact] + public async Task CreateContactAsync_NonPrimary_ShouldNotClearExistingPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + var input = new CreateContactLinkDto + { + Name = "Non-Primary Contact", + IsPrimary = false, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + await _service.CreateContactAsync(input); + + // Assert GetQueryableAsync should not be called (ClearPrimaryAsync not invoked) + await _contactLinkRepository.DidNotReceive().GetQueryableAsync(); + } + + [Fact] + public async Task CreateContactAsync_WhenPrimary_ShouldClearExistingPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var existingLinkId = Guid.NewGuid(); + + var existingLink = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(existingLink, () => existingLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { existingLink }.AsAsyncQueryable(), + Array.Empty().AsAsyncQueryable()); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + var input = new CreateContactLinkDto + { + Name = "Primary Contact", + IsPrimary = true, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + var result = await _service.CreateContactAsync(input); + + // Assert + result.IsPrimary.ShouldBeTrue(); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == existingLinkId && !l.IsPrimary), + true, + Arg.Any()); + } + + #endregion + + #region SetPrimaryContactAsync + + [Fact] + public async Task SetPrimaryContactAsync_ShouldClearExistingAndSetNew() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var existingPrimaryLinkId = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var existingPrimaryLink = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(existingPrimaryLink, () => existingPrimaryLinkId); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { existingPrimaryLink }.AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == existingPrimaryLinkId && !l.IsPrimary), + true, + Arg.Any()); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_WithNoExistingPrimary_ShouldSetNew() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert only the target link should be updated (set to primary) + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_WithMultipleExistingPrimaries_ShouldClearAll() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var primaryLinkId1 = Guid.NewGuid(); + var primaryLinkId2 = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var primaryLink1 = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(primaryLink1, () => primaryLinkId1); + + var primaryLink2 = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(primaryLink2, () => primaryLinkId2); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { primaryLink1, primaryLink2 }.AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert both existing primaries cleared + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == primaryLinkId1 && !l.IsPrimary), + true, + Arg.Any()); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == primaryLinkId2 && !l.IsPrimary), + true, + Arg.Any()); + // Target set as primary + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_ShouldNotMatchInactiveLink() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var inactiveLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = false + }; + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + new[] { inactiveLink }.AsAsyncQueryable()); + + // Act & Assert + await Should.ThrowAsync( + () => _service.SetPrimaryContactAsync("TestEntity", entityId, contactId)); + } + + [Fact] + public async Task SetPrimaryContactAsync_WhenContactLinkNotFound_ShouldThrow() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + Array.Empty().AsAsyncQueryable()); + + // Act & Assert + var ex = await Should.ThrowAsync( + () => _service.SetPrimaryContactAsync("TestEntity", entityId, contactId)); + ex.Code.ShouldBe("Contacts:ContactLinkNotFound"); + } + + #endregion + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs new file mode 100644 index 000000000..976ad574c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -0,0 +1,180 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ContactInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IApplicantProfileContactService _applicantProfileContactService; + private readonly ContactInfoDataProvider _provider; + + public ContactInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _applicantProfileContactService = Substitute.For(); + _provider = new ContactInfoDataProvider(_currentTenant, _applicantProfileContactService); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.ContactInfo + }; + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetProfileContactsWithProfileId() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetProfileContactsAsync(request.ProfileId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetApplicationContactsWithSubject() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync(request.Subject); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineBothContactSets() + { + // Arrange + var request = CreateRequest(); + var profileContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "Profile Contact 1", IsEditable = true }, + new() { ContactId = Guid.NewGuid(), Name = "Profile Contact 2", IsEditable = true } + }; + var appContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "App Contact 1", IsEditable = false } + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId).Returns(profileContacts); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject).Returns(appContacts); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.Count.ShouldBe(3); + dto.Contacts.Count(c => c.IsEditable).ShouldBe(2); + dto.Contacts.Count(c => !c.IsEditable).ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_WithNoContacts_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ProfileContactsShouldAppearBeforeApplicationContacts() + { + // Arrange + var request = CreateRequest(); + var profileContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "Profile First", + IsEditable = true + }; + var appContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "App Second", + IsEditable = false + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId) + .Returns(new List { profileContact }); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject) + .Returns(new List { appContact }); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts[0].Name.ShouldBe("Profile First"); + dto.Contacts[1].Name.ShouldBe("App Second"); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("CONTACTINFO"); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs new file mode 100644 index 000000000..48fadd7b9 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs @@ -0,0 +1,385 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ApplicantProfileContactServiceTests + { + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly IRepository _submissionRepository; + private readonly IRepository _applicationContactRepository; + private readonly ApplicantProfileContactService _service; + + public ApplicantProfileContactServiceTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + _submissionRepository = Substitute.For>(); + _applicationContactRepository = Substitute.For>(); + + _service = new ApplicantProfileContactService( + _contactRepository, + _contactLinkRepository, + _submissionRepository, + _applicationContactRepository); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + [Fact] + public async Task GetProfileContactsAsync_WithMatchingLinks_ShouldReturnContacts() + { + // Arrange + var profileId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact + { + Name = "John Doe", + Title = "Manager", + Email = "john@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101" + }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "ApplicantProfile", + RelatedEntityId = profileId, + Role = "Primary Contact", + IsPrimary = true, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetProfileContactsAsync(profileId); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(contactId); + contact.Name.ShouldBe("John Doe"); + contact.Title.ShouldBe("Manager"); + contact.Email.ShouldBe("john@example.com"); + contact.HomePhoneNumber.ShouldBe("111-1111"); + contact.MobilePhoneNumber.ShouldBe("222-2222"); + contact.WorkPhoneNumber.ShouldBe("333-3333"); + contact.WorkPhoneExtension.ShouldBe("101"); + contact.Role.ShouldBe("Primary Contact"); + contact.IsPrimary.ShouldBeTrue(); + contact.IsEditable.ShouldBeTrue(); + contact.ApplicationId.ShouldBeNull(); + } + + [Fact] + public async Task GetProfileContactsAsync_WithNoLinks_ShouldReturnEmpty() + { + // Arrange + _contactRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + _contactLinkRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetProfileContactsAsync(Guid.NewGuid()); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_ShouldReturnContacts() + { + // Arrange + var applicationId = Guid.NewGuid(); + var appContactId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Jane Smith", + ContactTitle = "Director", + ContactEmail = "jane@example.com", + ContactMobilePhone = "444-4444", + ContactWorkPhone = "555-5555", + ContactType = "ADDITIONAL_SIGNING_AUTHORITY" + }, appContactId) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(appContactId); + contact.Name.ShouldBe("Jane Smith"); + contact.Title.ShouldBe("Director"); + contact.Email.ShouldBe("jane@example.com"); + contact.MobilePhoneNumber.ShouldBe("444-4444"); + contact.WorkPhoneNumber.ShouldBe("555-5555"); + contact.ContactType.ShouldBe("Application"); + contact.Role.ShouldBe("Additional Signing Authority"); + contact.IsPrimary.ShouldBeFalse(); + contact.IsEditable.ShouldBeFalse(); + contact.ApplicationId.ShouldBe(applicationId); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitively() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Case Test", + ContactType = "ADDITIONAL_CONTACT" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@IDIR"); + + // Assert + result.Count.ShouldBe(1); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_ShouldStripDomainFromSubject() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "MYUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Domain Strip Test", + ContactType = "ADDITIONAL_CONTACT" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("myuser@differentdomain"); + + // Assert + result.Count.ShouldBe(1); + result[0].Name.ShouldBe("Domain Strip Test"); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithSubjectWithoutAtSign_ShouldStillMatch() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "PLAINUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Plain User Contact", + ContactType = "ADDITIONAL_CONTACT" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("plainuser"); + + // Assert + result.Count.ShouldBe(1); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithNonMatchingSubject_ShouldReturnEmpty() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "OTHERUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Should Not Match" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("differentuser@idir"); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithNoSubmissions_ShouldReturnEmpty() + { + // Arrange + _submissionRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationContactRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithMultipleSubmissions_ShouldReturnAllContacts() + { + // Arrange + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = appId1, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + }, + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = appId2, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = appId1, + ContactFullName = "Contact App 1", + ContactType = "ADDITIONAL_CONTACT" + }, Guid.NewGuid()), + WithId(new ApplicationContact + { + ApplicationId = appId2, + ContactFullName = "Contact App 2", + ContactType = "ADDITIONAL_SIGNING_AUTHORITY" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.Count.ShouldBe(2); + result.ShouldAllBe(c => !c.IsEditable); + result.ShouldAllBe(c => !c.IsPrimary); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs new file mode 100644 index 000000000..26b6bcd97 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Query; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.GrantManager.TestHelpers +{ + internal class TestAsyncEnumerableQueryable : EnumerableQuery, IAsyncEnumerable, IQueryable + { + public TestAsyncEnumerableQueryable(IEnumerable enumerable) : base(enumerable) { } + public TestAsyncEnumerableQueryable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); + } + + internal class TestAsyncEnumerator(IEnumerator inner) : IAsyncEnumerator + { + public T Current => inner.Current; + public ValueTask MoveNextAsync() => new(inner.MoveNext()); + public ValueTask DisposeAsync() + { + inner.Dispose(); + return default; + } + } + + internal class TestAsyncQueryProvider(IQueryProvider inner) : IQueryProvider, IAsyncQueryProvider + { + public IQueryable CreateQuery(Expression expression) + => new TestAsyncEnumerableQueryable(expression); + + public IQueryable CreateQuery(Expression expression) + => new TestAsyncEnumerableQueryable(expression); + + public object? Execute(Expression expression) + => inner.Execute(expression); + + public TResult Execute(Expression expression) + => inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + // TResult is typically Task for async operations + var resultType = typeof(TResult); + + // Get the actual result synchronously + var syncResult = inner.Execute(expression); + + // If TResult is Task, extract T and wrap the result + if (resultType.IsGenericType && resultType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var taskResultType = resultType.GetGenericArguments()[0]; + var taskFromResult = typeof(Task) + .GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(taskResultType); + return (TResult)taskFromResult.Invoke(null, new[] { syncResult })!; + } + + // For non-generic Task or other types, just return as-is + return (TResult)(object)Task.CompletedTask; + } + } + + internal static class TestQueryableExtensions + { + public static IQueryable AsAsyncQueryable(this IEnumerable source) + => new TestAsyncEnumerableQueryable(source); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs index abab7db3d..9b59458b0 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs @@ -64,7 +64,7 @@ public async Task ApplicationContactWidgetReturnsStatus() }; //Act - var result = await viewComponent.InvokeAsync(applicationId, true) as ViewViewComponentResult; + var result = await viewComponent.InvokeAsync(applicationId) as ViewViewComponentResult; ApplicationContactsWidgetViewModel? resultModel; resultModel = result!.ViewData!.Model! as ApplicationContactsWidgetViewModel;